diff --git a/messages/de.json b/messages/de.json index c6dc64916..eb0dd4835 100644 --- a/messages/de.json +++ b/messages/de.json @@ -731,6 +731,7 @@ "trendArrow": "Trend-Pfeil", "targetRange": "Zielbereich", "comparisonBaseline": "Vergleich", + "comparisonUnavailable": "Kein Vorzeitraum verfügbar", "tooltip": { "openSettings": "Chart-Overlay-Einstellungen" } diff --git a/messages/en.json b/messages/en.json index b901cdf1c..47a49442b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -731,6 +731,7 @@ "trendArrow": "Trend arrow", "targetRange": "Target range", "comparisonBaseline": "Comparison", + "comparisonUnavailable": "No prior-period data yet", "tooltip": { "openSettings": "Chart overlay settings" } diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index ed7332cf9..504cea090 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -4,6 +4,7 @@ import { I18nProvider } from "@/lib/i18n/context"; import { ChartOverlayControls, + ChartOverlayControlsBody, DEFAULT_CHART_OVERLAY_PREFS, type ChartOverlayPrefs, } from "../chart-overlay-controls"; @@ -95,4 +96,135 @@ describe("", () => { expect(html).toContain('aria-label="Chart-Overlay-Einstellungen"'); }); + + /** + * v1.4.25 W3f — comparison-baseline grey-out when the parent chart + * reports `hasComparisonData=false`. The disabled state communicates + * "you selected a comparison but there's no prior-period series to + * paint" instead of silently hiding the overlay. + * + * The popover body is portalled by Radix at runtime — it's not in + * SSR markup. The tests target the extracted `` + * so the JSX inside the popover is renderable in `renderToStaticMarkup`. + */ + describe("comparison-baseline grey-out (v1.4.25 W3f)", () => { + it("renders comparison buttons in normal state when prior data exists", () => { + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastMonth", + }; + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + + expect(html).not.toContain('data-comparison-disabled="true"'); + expect(html).not.toContain('data-comparison-greyed="true"'); + expect(html).not.toContain( + 'data-slot="chart-overlay-comparison-unavailable-hint"', + ); + // The lastMonth button has no aria-disabled set when data is + // available. + expect(html).not.toMatch( + /data-slot="chart-overlay-comparison-lastMonth"[^>]*aria-disabled="true"/, + ); + }); + + it("greys out lastMonth + lastYear when prior period is empty AND a baseline is selected", () => { + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastMonth", + }; + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + + expect(html).toContain('data-comparison-disabled="true"'); + // The lastMonth + lastYear buttons grey out, "none" does not. + expect(html).toMatch( + /data-slot="chart-overlay-comparison-lastMonth"[^>]*data-comparison-greyed="true"/, + ); + expect(html).toMatch( + /data-slot="chart-overlay-comparison-lastYear"[^>]*data-comparison-greyed="true"/, + ); + // The "none" button stays enabled — it's the escape hatch. + expect(html).not.toMatch( + /data-slot="chart-overlay-comparison-none"[^>]*data-comparison-greyed="true"/, + ); + // aria-disabled + opacity-50 + tooltip surface. + expect(html).toMatch( + /data-slot="chart-overlay-comparison-lastMonth"[^>]*aria-disabled="true"/, + ); + expect(html).toContain("opacity-50"); + expect(html).toContain("No prior-period data yet"); + // The footer hint paragraph is also rendered so screen readers + // hear the explanation. + expect(html).toContain( + 'data-slot="chart-overlay-comparison-unavailable-hint"', + ); + }); + + it("keeps all buttons enabled when the baseline is 'none' even with no prior data", () => { + // The "none" baseline is the safe default — the grey-out only + // triggers once a user has opted in to comparing. + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + + expect(html).not.toContain('data-comparison-disabled="true"'); + expect(html).not.toContain('data-comparison-greyed="true"'); + expect(html).not.toContain( + 'data-slot="chart-overlay-comparison-unavailable-hint"', + ); + }); + + it("emits the localised tooltip + hint copy under de locale", () => { + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastYear", + }; + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("Kein Vorzeitraum verfügbar"); + }); + + it("defaults hasComparisonData to true when the prop is omitted", () => { + // Charts that haven't been updated to thread the flag keep the + // pre-W3f behaviour — every button stays enabled. + const prefs: ChartOverlayPrefs = { + ...DEFAULT_CHART_OVERLAY_PREFS, + comparisonBaseline: "lastMonth", + }; + const html = renderToStaticMarkup( + withProvider( + , + ), + ); + expect(html).not.toContain('data-comparison-disabled="true"'); + }); + }); }); diff --git a/src/components/charts/chart-overlay-controls.tsx b/src/components/charts/chart-overlay-controls.tsx index c2d674bc6..e7b53a692 100644 --- a/src/components/charts/chart-overlay-controls.tsx +++ b/src/components/charts/chart-overlay-controls.tsx @@ -70,28 +70,27 @@ export interface ChartOverlayControlsProps { * wrapper align the cog with its title row. */ triggerClassName?: string; + /** + * v1.4.25 W3f — when the parent chart already knows the prior period + * carries no overlay-able rows, it threads `hasComparisonData=false` + * here so the comparison-baseline buttons render in a visibly + * disabled state (reduced opacity + `aria-disabled` + tooltip) + * instead of letting the user pick a value that paints nothing on + * the chart. + * + * Default `true` (i.e. enabled) so charts that never compute the + * flag (or compute it asynchronously) keep their existing behaviour. + */ + hasComparisonData?: boolean; } export function ChartOverlayControls({ prefs, onChange, triggerClassName, + hasComparisonData = true, }: ChartOverlayControlsProps): ReactElement { const { t } = useTranslations(); - const trendIndicatorId = useId(); - const trendArrowId = useId(); - const targetRangeId = useId(); - - const setToggle = ( - key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange", - value: boolean, - ): void => { - onChange({ ...prefs, [key]: value }); - }; - - const setComparisonBaseline = (value: ComparisonBaseline): void => { - onChange({ ...prefs, comparisonBaseline: value }); - }; return ( @@ -113,70 +112,135 @@ export function ChartOverlayControls({ className="w-[240px] p-3" data-slot="chart-overlay-controls-content" > - - {t("chart.overlay.controls.title")} - - -
-
- - - setToggle("showTrendIndicator", Boolean(value)) - } - data-slot="chart-overlay-toggle-trend-indicator" - /> -
-
- - - setToggle("showTrendArrow", Boolean(value)) - } - data-slot="chart-overlay-toggle-trend-arrow" - /> -
-
- - - setToggle("showTargetRange", Boolean(value)) - } - data-slot="chart-overlay-toggle-target-range" - /> -
-
- -
- -
+ + + ); +} + +/** + * v1.4.25 W3f — the dropdown body is extracted so unit tests can render + * it without spinning up Radix's portalled popover (which is invisible + * to `renderToStaticMarkup`). Production callers always go through + * `` above; the body export is purely a test + * affordance + a future hook for in-line (non-popover) renders. + */ +export interface ChartOverlayControlsBodyProps { + prefs: ChartOverlayPrefs; + onChange: (next: ChartOverlayPrefs) => void; + hasComparisonData?: boolean; +} + +export function ChartOverlayControlsBody({ + prefs, + onChange, + hasComparisonData = true, +}: ChartOverlayControlsBodyProps): ReactElement { + const { t } = useTranslations(); + const trendIndicatorId = useId(); + const trendArrowId = useId(); + const targetRangeId = useId(); + + // v1.4.25 W3f — when the underlying prior-period series is empty for + // BOTH lastMonth and lastYear (we only know about the currently + // selected baseline), grey out the non-"none" buttons. We can only + // verify the active baseline truthfully, so the disabled state is + // gated on `comparisonBaseline !== "none" && !hasComparisonData`: + // the user has already opted in to comparing and we discovered the + // prior period is empty. From the "none" baseline we keep all + // buttons enabled so the user can still toggle in for the chart to + // re-evaluate. + const comparisonGreyOut = + prefs.comparisonBaseline !== "none" && !hasComparisonData; + const disabledTooltip = t("chart.overlay.controls.comparisonUnavailable"); + + const setToggle = ( + key: "showTrendIndicator" | "showTrendArrow" | "showTargetRange", + value: boolean, + ): void => { + onChange({ ...prefs, [key]: value }); + }; + + const setComparisonBaseline = (value: ComparisonBaseline): void => { + onChange({ ...prefs, comparisonBaseline: value }); + }; + + return ( + <> + + {t("chart.overlay.controls.title")} + + +
+
+ + + setToggle("showTrendIndicator", Boolean(value)) + } + data-slot="chart-overlay-toggle-trend-indicator" + /> +
+
+ + + setToggle("showTrendArrow", Boolean(value)) + } + data-slot="chart-overlay-toggle-trend-arrow" + /> +
+
+ + + setToggle("showTargetRange", Boolean(value)) + } + data-slot="chart-overlay-toggle-target-range" + /> +
+
+ +
+ +
+ {COMPARISON_BASELINES.map((value) => { + // The "none" choice never greys out — it's the safe + // escape hatch when prior data is missing. + const isNoneOption = value === "none"; + const isGreyed = comparisonGreyOut && !isNoneOption; + return ( - ))} -
+ ); + })}
- - + {comparisonGreyOut && ( +

+ {disabledTooltip} +

+ )} +
+ ); } diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index afe78c52b..1f967dca7 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1138,6 +1138,7 @@ export function HealthChart({ ) : null}
diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index eb8af2ce4..9eb63a74b 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -337,10 +337,16 @@ export function MedicationComplianceChart({ ))} {/* v1.4.18 — overlay-controls dropdown next to the range - tabs. */} + tabs. + v1.4.25 W3f — the compliance card never paints a + prior-period overlay (the heatmap doesn't support it), + so the comparison buttons grey out when a baseline is + already selected to signal "this metric has no + comparison available". */} diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index d17f14cda..1276d36c4 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -604,6 +604,7 @@ export function MoodChart({ ) : null}