From 4116db4090f73b0b7aa45e71357acfb70fc230bf Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 15 Apr 2026 14:24:11 -0400 Subject: [PATCH] refactor(reports/compare): unify MoM/YoY under one Actual-vs-actual mode with reference month picker (#96) Collapse the three Compare tabs (MoM / YoY / Budget) into two modes. The new "Actual vs actual" mode exposes an explicit reference-month dropdown in the header (defaults to the previous month, wraps around January) and a MoM/YoY sub-toggle. The chart is rewritten to a grouped side-by-side BarChart with two bars per category (reference period vs comparison period) so both values are visible at a glance instead of just the delta. The URL PeriodSelector stays in sync with the reference month. - useCompare: state splits into { mode: "actual"|"budget", subMode: "mom"|"yoy", year, month }. Pure helpers previousMonth(), defaultReferencePeriod(), comparisonMeta() extracted for tests - CompareModeTabs: 2 modes instead of 3 - New CompareSubModeToggle and CompareReferenceMonthPicker components - ComparePeriodChart: grouped bars via two on a vertical BarChart - i18n: modeActual / subModeMoM / subModeYoY / referenceMonth (FR+EN), retire modeMoM / modeYoY - 9 new vitest cases covering the pure helpers (January wrap-around for both MoM and YoY, default reference period, month/year arithmetic) Closes #96 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.fr.md | 3 + CHANGELOG.md | 3 + docs/architecture.md | 2 +- src/components/reports/CompareModeTabs.tsx | 3 +- src/components/reports/ComparePeriodChart.tsx | 78 ++++++++----- .../reports/CompareReferenceMonthPicker.tsx | 93 +++++++++++++++ .../reports/CompareSubModeToggle.tsx | 40 +++++++ src/hooks/useCompare.test.ts | 47 ++++++++ src/hooks/useCompare.ts | 109 +++++++++++++----- src/i18n/locales/en.json | 11 +- src/i18n/locales/fr.json | 11 +- src/pages/ReportsComparePage.tsx | 72 ++++++++---- 12 files changed, 383 insertions(+), 89 deletions(-) create mode 100644 src/components/reports/CompareReferenceMonthPicker.tsx create mode 100644 src/components/reports/CompareSubModeToggle.tsx create mode 100644 src/hooks/useCompare.test.ts diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 58ee54e..61e944f 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,9 @@ ## [Non publié] +### Modifié +- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96) + ## [0.8.0] - 2026-04-14 ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 088f71a..23d39ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed +- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96) + ## [0.8.0] - 2026-04-14 ### Added diff --git a/docs/architecture.md b/docs/architecture.md index 755a346..5415bec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,7 +149,7 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | | `useHighlights` | Panneau de faits saillants du hub rapports | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | -| `useCompare` | Rapport Comparables (mode MoM / YoY / budget) | +| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) | | `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories | | `useDataExport` | Export de données | | `useTheme` | Thème clair/sombre | diff --git a/src/components/reports/CompareModeTabs.tsx b/src/components/reports/CompareModeTabs.tsx index a33d985..745b09f 100644 --- a/src/components/reports/CompareModeTabs.tsx +++ b/src/components/reports/CompareModeTabs.tsx @@ -10,8 +10,7 @@ export default function CompareModeTabs({ value, onChange }: CompareModeTabsProp const { t } = useTranslation(); const modes: { id: CompareMode; labelKey: string }[] = [ - { id: "mom", labelKey: "reports.compare.modeMoM" }, - { id: "yoy", labelKey: "reports.compare.modeYoY" }, + { id: "actual", labelKey: "reports.compare.modeActual" }, { id: "budget", labelKey: "reports.compare.modeBudget" }, ]; diff --git a/src/components/reports/ComparePeriodChart.tsx b/src/components/reports/ComparePeriodChart.tsx index edd946b..688707f 100644 --- a/src/components/reports/ComparePeriodChart.tsx +++ b/src/components/reports/ComparePeriodChart.tsx @@ -4,16 +4,17 @@ import { Bar, XAxis, YAxis, - Cell, - ReferenceLine, Tooltip, + Legend, ResponsiveContainer, + CartesianGrid, } from "recharts"; import type { CategoryDelta } from "../../shared/types"; -import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns"; export interface ComparePeriodChartProps { rows: CategoryDelta[]; + previousLabel: string; + currentLabel: string; } function formatCurrency(amount: number, language: string): string { @@ -24,7 +25,11 @@ function formatCurrency(amount: number, language: string): string { }).format(amount); } -export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) { +export default function ComparePeriodChart({ + rows, + previousLabel, + currentLabel, +}: ComparePeriodChartProps) { const { t, i18n } = useTranslation(); if (rows.length === 0) { @@ -35,31 +40,44 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) { ); } - const chartData = rows - .map((r, i) => ({ + // Sort by current-period amount (largest spending first) so the user's eye + // lands on the biggest categories, then reverse so the biggest appears at + // the top of the vertical bar chart. + const chartData = [...rows] + .sort((a, b) => b.currentAmount - a.currentAmount) + .map((r) => ({ name: r.categoryName, + previousAmount: r.previousAmount, + currentAmount: r.currentAmount, color: r.categoryColor, - delta: r.deltaAbs, - index: i, - })) - .sort((a, b) => a.delta - b.delta); + })); + + const previousFill = "var(--muted-foreground)"; + const currentFill = "var(--primary)"; return (
- - - ({ color: d.color, index: d.index }))} - /> + + + formatCurrency(v, i18n.language)} stroke="var(--muted-foreground)" fontSize={11} /> - - + typeof value === "number" ? formatCurrency(value, i18n.language) : String(value) @@ -69,15 +87,23 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) { border: "1px solid var(--border)", borderRadius: "0.5rem", }} + cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }} + /> + + + - - {chartData.map((entry) => ( - - ))} -
diff --git a/src/components/reports/CompareReferenceMonthPicker.tsx b/src/components/reports/CompareReferenceMonthPicker.tsx new file mode 100644 index 0000000..edb7501 --- /dev/null +++ b/src/components/reports/CompareReferenceMonthPicker.tsx @@ -0,0 +1,93 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +export interface CompareReferenceMonthPickerProps { + year: number; + month: number; + onChange: (year: number, month: number) => void; + /** Number of recent months to show in the dropdown. Default: 24. */ + monthCount?: number; + /** "today" override for tests. */ + today?: Date; +} + +interface Option { + value: string; // "YYYY-MM" + year: number; + month: number; + label: string; +} + +function formatMonth(year: number, month: number, language: string): string { + const date = new Date(year, month - 1, 1); + return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", { + month: "long", + year: "numeric", + }).format(date); +} + +export default function CompareReferenceMonthPicker({ + year, + month, + onChange, + monthCount = 24, + today = new Date(), +}: CompareReferenceMonthPickerProps) { + const { t, i18n } = useTranslation(); + + const options = useMemo(() => { + const list: Option[] = []; + let y = today.getFullYear(); + let m = today.getMonth() + 1; + for (let i = 0; i < monthCount; i++) { + list.push({ + value: `${y}-${String(m).padStart(2, "0")}`, + year: y, + month: m, + label: formatMonth(y, m, i18n.language), + }); + m -= 1; + if (m === 0) { + m = 12; + y -= 1; + } + } + return list; + }, [today, monthCount, i18n.language]); + + const currentValue = `${year}-${String(month).padStart(2, "0")}`; + const isKnown = options.some((o) => o.value === currentValue); + const displayOptions = isKnown + ? options + : [ + { + value: currentValue, + year, + month, + label: formatMonth(year, month, i18n.language), + }, + ...options, + ]; + + return ( + + ); +} diff --git a/src/components/reports/CompareSubModeToggle.tsx b/src/components/reports/CompareSubModeToggle.tsx new file mode 100644 index 0000000..62190a5 --- /dev/null +++ b/src/components/reports/CompareSubModeToggle.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import type { CompareSubMode } from "../../hooks/useCompare"; + +export interface CompareSubModeToggleProps { + value: CompareSubMode; + onChange: (subMode: CompareSubMode) => void; +} + +export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) { + const { t } = useTranslation(); + + const items: { id: CompareSubMode; labelKey: string }[] = [ + { id: "mom", labelKey: "reports.compare.subModeMoM" }, + { id: "yoy", labelKey: "reports.compare.subModeYoY" }, + ]; + + return ( +
+ {items.map(({ id, labelKey }) => ( + + ))} +
+ ); +} diff --git a/src/hooks/useCompare.test.ts b/src/hooks/useCompare.test.ts new file mode 100644 index 0000000..1258b3f --- /dev/null +++ b/src/hooks/useCompare.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare"; + +describe("useCompare helpers", () => { + describe("previousMonth", () => { + it("goes back one month within the same year", () => { + expect(previousMonth(2026, 3)).toEqual({ year: 2026, month: 2 }); + expect(previousMonth(2026, 12)).toEqual({ year: 2026, month: 11 }); + }); + + it("wraps around January to December of previous year", () => { + expect(previousMonth(2026, 1)).toEqual({ year: 2025, month: 12 }); + }); + }); + + describe("defaultReferencePeriod", () => { + it("returns the month before the given date", () => { + expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 }); + }); + + it("wraps around when today is in January", () => { + expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 }); + }); + + it("handles the last day of a month", () => { + expect(defaultReferencePeriod(new Date(2026, 6, 31))).toEqual({ year: 2026, month: 6 }); + }); + }); + + describe("comparisonMeta", () => { + it("MoM returns the previous month", () => { + expect(comparisonMeta("mom", 2026, 3)).toEqual({ previousYear: 2026, previousMonth: 2 }); + }); + + it("MoM wraps around January", () => { + expect(comparisonMeta("mom", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 12 }); + }); + + it("YoY returns the same month in the previous year", () => { + expect(comparisonMeta("yoy", 2026, 3)).toEqual({ previousYear: 2025, previousMonth: 3 }); + }); + + it("YoY for January stays on January of previous year", () => { + expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 }); + }); + }); +}); diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts index ddce800..846f914 100644 --- a/src/hooks/useCompare.ts +++ b/src/hooks/useCompare.ts @@ -3,10 +3,12 @@ import type { CategoryDelta } from "../shared/types"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { useReportsPeriod } from "./useReportsPeriod"; -export type CompareMode = "mom" | "yoy" | "budget"; +export type CompareMode = "actual" | "budget"; +export type CompareSubMode = "mom" | "yoy"; interface State { mode: CompareMode; + subMode: CompareSubMode; year: number; month: number; rows: CategoryDelta[]; @@ -16,16 +18,52 @@ interface State { type Action = | { type: "SET_MODE"; payload: CompareMode } - | { type: "SET_PERIOD"; payload: { year: number; month: number } } + | { type: "SET_SUB_MODE"; payload: CompareSubMode } + | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ERROR"; payload: string }; -const today = new Date(); +/** + * Wrap-around helper: returns (year, month) shifted back by one month. + * Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }. + */ +export function previousMonth(year: number, month: number): { year: number; month: number } { + if (month === 1) return { year: year - 1, month: 12 }; + return { year, month: month - 1 }; +} + +/** + * Default reference period for the Compare report: the month preceding `today`. + * Exported for unit tests. + */ +export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } { + return previousMonth(today.getFullYear(), today.getMonth() + 1); +} + +/** + * Returns the comparison meta for a given subMode + reference period. + * - MoM: previous month vs current month + * - YoY: same month previous year vs current year + */ +export function comparisonMeta( + subMode: CompareSubMode, + year: number, + month: number, +): { previousYear: number; previousMonth: number } { + if (subMode === "mom") { + const prev = previousMonth(year, month); + return { previousYear: prev.year, previousMonth: prev.month }; + } + return { previousYear: year - 1, previousMonth: month }; +} + +const defaultRef = defaultReferencePeriod(); const initialState: State = { - mode: "mom", - year: today.getFullYear(), - month: today.getMonth() + 1, + mode: "actual", + subMode: "mom", + year: defaultRef.year, + month: defaultRef.month, rows: [], isLoading: false, error: null, @@ -35,7 +73,9 @@ function reducer(state: State, action: Action): State { switch (action.type) { case "SET_MODE": return { ...state, mode: action.payload }; - case "SET_PERIOD": + case "SET_SUB_MODE": + return { ...state, subMode: action.payload }; + case "SET_REFERENCE_PERIOD": return { ...state, year: action.payload.year, month: action.payload.month }; case "SET_LOADING": return { ...state, isLoading: action.payload }; @@ -53,33 +93,38 @@ export function useCompare() { const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); - const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => { - if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly - const id = ++fetchIdRef.current; - dispatch({ type: "SET_LOADING", payload: true }); - try { - const rows = - mode === "mom" - ? await getCompareMonthOverMonth(year, month) - : await getCompareYearOverYear(year); - if (id !== fetchIdRef.current) return; - dispatch({ type: "SET_ROWS", payload: rows }); - } catch (e) { - if (id !== fetchIdRef.current) return; - dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); - } - }, []); + const fetch = useCallback( + async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => { + if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly + const id = ++fetchIdRef.current; + dispatch({ type: "SET_LOADING", payload: true }); + try { + const rows = + subMode === "mom" + ? await getCompareMonthOverMonth(year, month) + : await getCompareYearOverYear(year); + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_ROWS", payload: rows }); + } catch (e) { + if (id !== fetchIdRef.current) return; + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + }, + [], + ); useEffect(() => { - fetch(state.mode, state.year, state.month); - }, [fetch, state.mode, state.year, state.month]); + fetch(state.mode, state.subMode, state.year, state.month); + }, [fetch, state.mode, state.subMode, state.year, state.month]); - // When the URL period changes, use the `to` date to infer the target year/month. + // When the URL period changes, align the reference month with `to`. + // The explicit dropdown remains the primary selector — this effect only + // keeps the two in sync when the user navigates via PeriodSelector. useEffect(() => { const [y, m] = to.split("-").map(Number); if (!Number.isFinite(y) || !Number.isFinite(m)) return; if (y !== state.year || m !== state.month) { - dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } }); + dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [to]); @@ -88,9 +133,13 @@ export function useCompare() { dispatch({ type: "SET_MODE", payload: m }); }, []); - const setTargetPeriod = useCallback((year: number, month: number) => { - dispatch({ type: "SET_PERIOD", payload: { year, month } }); + const setSubMode = useCallback((s: CompareSubMode) => { + dispatch({ type: "SET_SUB_MODE", payload: s }); }, []); - return { ...state, setMode, setTargetPeriod, from, to }; + const setReferencePeriod = useCallback((year: number, month: number) => { + dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } }); + }, []); + + return { ...state, setMode, setSubMode, setReferencePeriod, from, to }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 85d4e5d..cd1174d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -395,7 +395,7 @@ "trends": "Trends", "trendsDescription": "Where you're heading over 12 months", "compare": "Compare", - "compareDescription": "Month, year, and budget comparisons", + "compareDescription": "Compare a reference month against previous month, previous year, or budget", "categoryZoom": "Category Analysis", "categoryZoomDescription": "Zoom in on a single category" }, @@ -404,9 +404,12 @@ "subviewByCategory": "By category" }, "compare": { - "modeMoM": "Month vs previous month", - "modeYoY": "Year vs previous year", - "modeBudget": "Actual vs budget" + "modeActual": "Actual vs actual", + "modeBudget": "Actual vs budget", + "subModeMoM": "Previous month", + "subModeYoY": "Previous year", + "subModeAria": "Comparison period", + "referenceMonth": "Reference month" }, "category": { "selectCategory": "Select a category", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index fe8bd27..2a90a66 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -395,7 +395,7 @@ "trends": "Tendances", "trendsDescription": "Où vous allez sur 12 mois", "compare": "Comparables", - "compareDescription": "Comparaisons mois, année et budget", + "compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget", "categoryZoom": "Analyse par catégorie", "categoryZoomDescription": "Zoom sur une catégorie" }, @@ -404,9 +404,12 @@ "subviewByCategory": "Par catégorie" }, "compare": { - "modeMoM": "Mois vs mois précédent", - "modeYoY": "Année vs année précédente", - "modeBudget": "Réel vs budget" + "modeActual": "Réel vs réel", + "modeBudget": "Réel vs budget", + "subModeMoM": "Mois précédent", + "subModeYoY": "Année précédente", + "subModeAria": "Période de comparaison", + "referenceMonth": "Mois de référence" }, "category": { "selectCategory": "Choisir une catégorie", diff --git a/src/pages/ReportsComparePage.tsx b/src/pages/ReportsComparePage.tsx index 871c26e..25463af 100644 --- a/src/pages/ReportsComparePage.tsx +++ b/src/pages/ReportsComparePage.tsx @@ -4,42 +4,53 @@ import { Link } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import PeriodSelector from "../components/dashboard/PeriodSelector"; import CompareModeTabs from "../components/reports/CompareModeTabs"; +import CompareSubModeToggle from "../components/reports/CompareSubModeToggle"; +import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import ComparePeriodTable from "../components/reports/ComparePeriodTable"; import ComparePeriodChart from "../components/reports/ComparePeriodChart"; import CompareBudgetView from "../components/reports/CompareBudgetView"; import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle"; -import { useCompare } from "../hooks/useCompare"; +import { useCompare, comparisonMeta } from "../hooks/useCompare"; import { useReportsPeriod } from "../hooks/useReportsPeriod"; const STORAGE_KEY = "reports-viewmode-compare"; -const MONTH_NAMES_EN = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -]; -const MONTH_NAMES_FR = [ - "Janvier", "Février", "Mars", "Avril", "Mai", "Juin", - "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre", -]; - -function monthName(month: number, language: string): string { - return (language === "fr" ? MONTH_NAMES_FR : MONTH_NAMES_EN)[month - 1] ?? String(month); +function formatMonthLabel(year: number, month: number, language: string): string { + const date = new Date(year, month - 1, 1); + return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", { + month: "long", + year: "numeric", + }).format(date); } export default function ReportsComparePage() { const { t, i18n } = useTranslation(); const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); - const { mode, setMode, year, month, rows, isLoading, error } = useCompare(); + const { + mode, + subMode, + setMode, + setSubMode, + setReferencePeriod, + year, + month, + rows, + isLoading, + error, + } = useCompare(); const [viewMode, setViewMode] = useState(() => readViewMode(STORAGE_KEY)); const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; - const previousLabel = - mode === "mom" - ? `${monthName(month === 1 ? 12 : month - 1, i18n.language)} ${month === 1 ? year - 1 : year}` - : `${year - 1}`; + const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month); const currentLabel = - mode === "mom" ? `${monthName(month, i18n.language)} ${year}` : `${year}`; + subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year); + const previousLabel = + subMode === "mom" + ? formatMonthLabel(previousYear, prevMonth, i18n.language) + : String(previousYear); + + const showActualControls = mode === "actual"; return (
@@ -54,7 +65,7 @@ export default function ReportsComparePage() {

{t("reports.hub.compare")}

-
+
- {mode !== "budget" && ( - +
+
+ +
+
+ + {showActualControls && ( + )}
+ {showActualControls && ( + + )}
{error && ( @@ -79,7 +103,11 @@ export default function ReportsComparePage() { {mode === "budget" ? ( ) : viewMode === "chart" ? ( - + ) : ( )} -- 2.45.2