diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 507a74ad6..93044b04d 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -11,10 +11,11 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; +import type { Formatters } from "@/lib/format-locale"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ListRow } from "@/components/ui/list-row"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; @@ -114,35 +115,30 @@ function TokenStatusBadge({ const TOKEN_NAME_ISO_RE = /^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/; -// Locale-fixed dd.MM.yyyy HH:mm in the Europe/Berlin display zone — the -// rest of the app shows timestamps in Berlin time (`format-locale.ts`), -// so a token issued at 19:46 UTC must read as 21:46 in summer / 20:46 -// in winter, not the raw UTC clock. -const TOKEN_NAME_DATE_FORMATTER = new Intl.DateTimeFormat("de-DE", { - timeZone: "Europe/Berlin", - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, -}); - -function formatTokenName(name: string): string { +/** + * Issue #66 (date-format sweep) — this used to hard-code `de-DE` + + * `Europe/Berlin`, so a non-German admin saw a German-formatted + * timestamp baked into the token name regardless of their own locale + * or profile timezone. Routes through the same `fmt.dateTime` every + * other surface renders through, so the admin's locale, hour-cycle + * preference, and profile timezone all apply here too. + */ +function formatTokenName(name: string, fmt: Formatters): string { const match = TOKEN_NAME_ISO_RE.exec(name); if (!match) return name; const [, prefix, iso, rest] = match; const d = new Date(iso); if (Number.isNaN(d.getTime())) return name; - // Intl emits "05.05.2026, 21:46" — strip the comma so the suffix - // reads as a single date+time chunk. - const stamp = TOKEN_NAME_DATE_FORMATTER.format(d).replace(",", ""); + // `fmt.dateTime` emits "05.05.2026, 21:46" — strip the comma so the + // suffix reads as a single date+time chunk. + const stamp = fmt.dateTime(d).replace(",", ""); const formatted = `${prefix} · ${stamp}`; return rest && rest.trim() ? `${formatted} ${rest.trim()}` : formatted; } export function ApiTokenOverviewSection() { const { t } = useTranslations(); + const fmt = useFormatters(); // v1.4.19 phase A7 — `/admin/api-tokens` is a dedicated single- // section route. The previous pattern carried a "Collapse / Expand" // toggle as an escape hatch from the v1.4 shared admin page where @@ -232,7 +228,9 @@ export function ApiTokenOverviewSection() { - +
@@ -303,7 +301,7 @@ export function ApiTokenOverviewSection() {
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index adb101917..ee68e1142 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -952,7 +952,7 @@ export function HealthChart({ const allData: ChartDataPoint[] = Array.from(dailyAggregates.values()) .map((bucket) => { const point: ChartDataPoint = { - date: tzFmt.dateShort(new Date(bucket.timestamp)), + date: tzFmt.dateShortSmart(new Date(bucket.timestamp)), timestamp: bucket.timestamp, }; @@ -1079,7 +1079,7 @@ export function HealthChart({ ).points.map((point) => { const date = new Date(point.timestamp); const out: ChartDataPoint = { - date: tzFmt.dateShort(date), + date: tzFmt.dateShortSmart(date), timestamp: point.timestamp, }; for (const [type, value] of Object.entries(point.values)) { @@ -1209,7 +1209,7 @@ export function HealthChart({ // Index shifted rows by the same day-key the chart already uses. const shiftedByDay = new Map>(); for (const row of shifted) { - const dayKey = tzFmt.dateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp)); const slot = shiftedByDay.get(dayKey) ?? {}; for (const [type, value] of Object.entries(row.values)) { if (typeof value === "number" && Number.isFinite(value)) { diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index de819d182..303cd8cde 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -284,7 +284,7 @@ export function MedicationComplianceChart({ const chartData = useMemo( () => data - ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShort(d)) + ? aggregateMedicationCompliance(data, (d) => tzFmt.dateShortSmart(d)) : [], [data, tzFmt], ); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index d641e9ce6..33715a22e 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -400,7 +400,7 @@ export function MoodChart({ const allPoints: ChartDataPoint[] = data.entries .map((entry) => ({ - date: tzFmt.dateShort(new Date(dayKeyToTimestamp(entry.date))), + date: tzFmt.dateShortSmart(new Date(dayKeyToTimestamp(entry.date))), timestamp: dayKeyToTimestamp(entry.date), pointIndex: 0, score: entry.score, @@ -436,7 +436,7 @@ export function MoodChart({ })), { bucket: bucketType }, ).points.map((point) => ({ - date: tzFmt.dateShort(new Date(point.timestamp)), + date: tzFmt.dateShortSmart(new Date(point.timestamp)), timestamp: point.timestamp, pointIndex: 0, score: point.values.score, @@ -513,7 +513,7 @@ export function MoodChart({ const shiftedByDay = new Map(); for (const row of shifted) { - const dayKey = tzFmt.dateShort(new Date(row.timestamp)); + const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp)); if (typeof row.score === "number" && Number.isFinite(row.score)) { shiftedByDay.set(dayKey, row.score); } diff --git a/src/components/charts/nutrient-daily-bar-chart.tsx b/src/components/charts/nutrient-daily-bar-chart.tsx index 84bd26eaa..074418f28 100644 --- a/src/components/charts/nutrient-daily-bar-chart.tsx +++ b/src/components/charts/nutrient-daily-bar-chart.tsx @@ -14,7 +14,7 @@ import { import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; +import { useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; @@ -49,6 +49,7 @@ export function NutrientDailyBarChart({ valueLabel, referenceValue, }: NutrientDailyBarChartProps) { + const fmt = useFormatters(); const points = useMemo( () => days.map((d) => ({ day: d.day, amount: Math.round(d.amount) })), [days], @@ -77,7 +78,7 @@ export function NutrientDailyBarChart({ /> formatDateShort(dayToDate(day))} + tickFormatter={(day: string) => fmt.dateShortSmart(dayToDate(day))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={24} @@ -116,7 +117,7 @@ export function NutrientDailyBarChart({ return ( ); diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index 9e98a35ff..9cfadde5e 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -16,8 +16,7 @@ import { import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import { formatMetricValue } from "./format-value"; @@ -62,6 +61,7 @@ export function CustomMetricChart({ decimals: number | null; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [range, setRange] = useState("365"); const [nowMs] = useState(() => Date.now()); @@ -136,7 +136,7 @@ export function CustomMetricChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -192,7 +192,7 @@ export function CustomMetricChart({ return ( ); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index 4f7f81656..d3378192c 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -191,7 +191,9 @@ export function BbtChart({ scale="time" domain={["dataMin", "dataMax"]} ticks={ticks} - tickFormatter={(v) => fmt.dateShort(new Date(v as number))} + tickFormatter={(v) => + fmt.dateShortSmart(new Date(v as number)) + } tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} @@ -285,7 +287,9 @@ function BbtTooltip(props: { "bg-popover border-border rounded-md border px-2.5 py-2 text-xs shadow-md", )} > -

{fmt.dateShort(new Date(p.t))}

+

+ {fmt.dateShortSmart(new Date(p.t))} +

{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}

diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index f7175c2c8..66e428f0d 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -11,7 +11,8 @@ import { } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { TileHeader } from "@/components/insights/tile-header"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; import type { CyclePrediction, CycleHistoryResponse } from "./types"; @@ -26,12 +27,17 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types"; * cycle-tracking on-ramp, not repeated beneath every prediction surface. */ -function formatDate(d: string): string { +/** + * Issue #66 — was a bespoke `toLocaleDateString(undefined, …)` (browser + * locale, never the app's) with no year. Predictions run 1-2 cycles out, so + * a window straddling New Year's would otherwise print an unadorned "Jan 3" + * in December. Routes through the app-locale conditional-year formatter + * instead; `dateShortSmart` already renders day+month in the locale's field + * order and only adds the year when it differs from the current one. + */ +function formatDate(d: string, fmt: Formatters): string { // Render at noon UTC so the YYYY-MM-DD never rolls a day across tz. - return new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); + return fmt.dateShortSmart(`${d}T12:00:00Z`); } function confidenceTone(c: number): { @@ -60,6 +66,7 @@ export function PredictionsPanel({ history, }: PredictionsPanelProps) { const { t } = useTranslations(); + const fmt = useFormatters(); return (
@@ -92,8 +99,9 @@ export function PredictionsPanel({ ) : null} @@ -105,7 +113,7 @@ export function PredictionsPanel({ ? t("cycle.predictions.ovulationConfirmed") : t("cycle.predictions.ovulation") } - value={formatDate(prediction.predictedOvulation)} + value={formatDate(prediction.predictedOvulation, fmt)} /> ) : null} @@ -120,6 +128,7 @@ export function PredictionsPanel({ function NextPeriod({ prediction }: { prediction: CyclePrediction }) { const { t } = useTranslations(); + const fmt = useFormatters(); const tone = confidenceTone(prediction.confidence); return (
@@ -128,8 +137,8 @@ function NextPeriod({ prediction }: { prediction: CyclePrediction }) { hue={FLOW_HUE} label={t("cycle.predictions.nextPeriod")} value={t("cycle.predictions.nextPeriodWindow", { - low: formatDate(prediction.nextPeriodStartLow), - high: formatDate(prediction.nextPeriodStartHigh), + low: formatDate(prediction.nextPeriodStartLow, fmt), + high: formatDate(prediction.nextPeriodStartHigh, fmt), })} /> @@ -186,6 +195,7 @@ function HistoryCard({ history: CycleHistoryResponse | undefined; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const stats = history?.stats; // Most-recent observed cycles (the spread the regularity number summarises — // seeing it builds trust; the data was already on the wire). QA H2. @@ -253,10 +263,10 @@ function HistoryCard({ className="flex items-center justify-between gap-3 py-2 text-sm" > - {formatDate(c.startDate)} + {formatDate(c.startDate, fmt)} {" – "} {c.endDate - ? formatDate(c.endDate) + ? formatDate(c.endDate, fmt) : t("cycle.history.ongoing")} diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index 674686a3c..54841722e 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -34,7 +34,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { return (

- {fmt.dateShort(`${log.date}T12:00:00`)} + {fmt.dateShortSmart(`${log.date}T12:00:00`)}

{log.symptoms.length > 0 ? ( diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 0f61ba6d3..79e4a8a17 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -82,11 +82,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { {t("illness.onsetOn", { - date: fmt.dateShort(new Date(episode.onsetAt)), + date: fmt.dateShortSmart(new Date(episode.onsetAt)), })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { - date: fmt.dateShort(new Date(episode.resolvedAt)), + date: fmt.dateShortSmart(new Date(episode.resolvedAt)), })}` : ""} diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx index d6952bc5e..f7d859ca2 100644 --- a/src/components/illness/illness-settings.tsx +++ b/src/components/illness/illness-settings.tsx @@ -43,7 +43,7 @@ export function IllnessSettings() { : e.lifecycle === "CHRONIC_ONGOING" ? t("illness.status.ongoing") : t("illness.status.active") - } · ${fmt.dateShort(new Date(e.onsetAt))}`, + } · ${fmt.dateShortSmart(new Date(e.onsetAt))}`, })); return ( diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 9b7efe866..da308b211 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -103,7 +103,7 @@ function EpisodeCard({ : t("illness.status.recovered")} - {fmt.dateShort(new Date(episode.onsetAt))} + {fmt.dateShortSmart(new Date(episode.onsetAt))}
@@ -167,11 +167,11 @@ function EpisodeCard({

{t("illness.onsetOn", { - date: fmt.dateShort(new Date(episode.onsetAt)), + date: fmt.dateShortSmart(new Date(episode.onsetAt)), })} {episode.resolvedAt ? ` · ${t("illness.recoveredOn", { - date: fmt.dateShort(new Date(episode.resolvedAt)), + date: fmt.dateShortSmart(new Date(episode.resolvedAt)), })}` : ""}

@@ -192,7 +192,7 @@ function EpisodeCard({ > {flare.label} - {fmt.dateShort(new Date(flare.onsetAt))} + {fmt.dateShortSmart(new Date(flare.onsetAt))} diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 8ecfcca1f..e9a29f0d1 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -175,7 +175,7 @@ export function IntradayPulseChart({ const dayLabel = isToday ? t("insights.intradayPulse.today") - : fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`)); + : fmt.dateShortSmart(new Date(`${dateKey}T12:00:00.000Z`)); return (
("365"); // Capture "now" once at mount so the recency filter stays pure across // re-renders (an inline `Date.now()` in render is impure). @@ -145,7 +145,7 @@ export function LabBiomarkerChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -204,7 +204,7 @@ export function LabBiomarkerChart({ return ( ); diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 1be882b5a..c15a22de7 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -82,7 +82,7 @@ import { useFormatters, useDateFormatPreference, } from "@/lib/i18n/context"; -import { formatDateWithWeekday } from "@/lib/date-format"; +import { formatDateWithWeekdaySmart } from "@/lib/date-format"; import { invalidateKeys, medicationDependentKeys, @@ -448,7 +448,7 @@ export function DoseHistoryLedger({ the DEVICE zone and could shift the heading a day when browser ≠ profile zone; the day-key label renders UTC-pinned instead (zone-independent, weekday included). */} - {formatDateWithWeekday(group.dayKey, dateFormatPref, locale)} + {formatDateWithWeekdaySmart(group.dayKey, dateFormatPref, locale)}
    {group.rows.map((row, i) => ( diff --git a/src/components/medications/dose-strength-curve.tsx b/src/components/medications/dose-strength-curve.tsx index 3c11d42b3..c4f4990db 100644 --- a/src/components/medications/dose-strength-curve.tsx +++ b/src/components/medications/dose-strength-curve.tsx @@ -218,7 +218,7 @@ export function DoseStrengthCurve({ scale="time" domain={["dataMin", "dataMax"]} ticks={ticks} - tickFormatter={(v) => fmt.dateShort(new Date(v as number))} + tickFormatter={(v) => fmt.dateShortSmart(new Date(v as number))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} @@ -237,7 +237,7 @@ export function DoseStrengthCurve({ strokeOpacity: 0.3, strokeDasharray: "3 3", }} - labelFormatter={(v) => fmt.dateShort(new Date(v as number))} + labelFormatter={(v) => fmt.dateShortSmart(new Date(v as number))} formatter={(value) => [ `${fmt.number(value as number)} ${unit}`, t("medications.doseStrength.tooltipLabel"), diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index ce4c86a3f..a10519cbb 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -420,7 +420,7 @@ export function Glp1MedicationCard({ if (next.daysAway === 0) return t("medications.glp1NextInjectionToday"); if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow"); const dayName = weekdayLabel(getDayOfWeekInTz(next.date, displayTz)); - const dateShort = fmt.dateShort(next.date); + const dateShort = fmt.dateShortSmart(next.date); return t("medications.glp1NextInjectionDays", { label: `${dayName}, ${dateShort}`, days: next.daysAway, @@ -469,7 +469,7 @@ export function Glp1MedicationCard({ medication.nextDueOverdue && next ? diffDays(next.date, now) === 0 ? formatTime(next.date.toISOString()) - : `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShort(next.date)}` + : `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShortSmart(next.date)}` : null; const nextLine = diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index a84cedc0b..b6c64fa5c 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -437,7 +437,7 @@ export function MedicationCard({ } else if (diffDays <= 5) { dayLabel = weekdayLabel(nextDate.getDay()); } else { - dayLabel = fmt.dateWithWeekday(nextDate); + dayLabel = fmt.dateWithWeekdaySmart(nextDate); } } diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 2a8f5c65e..b7f045796 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -551,7 +551,7 @@ function MedicationTableRowItem({ ? t("medications.tomorrow") : diffDays <= 5 ? weekdayLabel(nextDate.getDay()) - : fmt.dateWithWeekday(nextDate); + : fmt.dateWithWeekdaySmart(nextDate); nextCell = ( {dayLabel}, {formatTime(new Date(nextAt).toISOString())} diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index ae6e0338f..9b07cf638 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -324,7 +324,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums" data-slot="side-effect-row-date" > - {fmt.dateShort(new Date(row.occurredAt))} + {fmt.dateShortSmart(new Date(row.occurredAt))}

{row.notes && ( diff --git a/src/components/medications/titration-timeline.tsx b/src/components/medications/titration-timeline.tsx index 3fafd3024..1bc91e710 100644 --- a/src/components/medications/titration-timeline.tsx +++ b/src/components/medications/titration-timeline.tsx @@ -221,9 +221,11 @@ export function TitrationTimeline({

{step.isPast - ? fmt.dateShort(new Date(step.effectiveFrom)) + ? fmt.dateShortSmart(new Date(step.effectiveFrom)) : t("medications.titration.plannedFor", { - date: fmt.dateShort(new Date(step.effectiveFrom)), + date: fmt.dateShortSmart( + new Date(step.effectiveFrom), + ), })}

diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx index 95c4dc8fe..8dd09f896 100644 --- a/src/components/mental-health/assessment-history-chart.tsx +++ b/src/components/mental-health/assessment-history-chart.tsx @@ -34,8 +34,7 @@ import { import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import type { AssessmentRow, InstrumentId } from "./types"; @@ -55,6 +54,7 @@ export function AssessmentHistoryChart({ rows: AssessmentRow[]; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const def = INSTRUMENTS[instrument]; const points = useMemo(() => { @@ -103,7 +103,7 @@ export function AssessmentHistoryChart({ type="number" scale="time" domain={["dataMin", "dataMax"]} - tickFormatter={(ts: number) => formatDateShort(new Date(ts))} + tickFormatter={(ts: number) => fmt.dateShortSmart(new Date(ts))} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} stroke="var(--border)" minTickGap={32} @@ -139,7 +139,7 @@ export function AssessmentHistoryChart({ return ( ); diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index f8ecf9326..79d2745a7 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -15,7 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiDelete } from "@/lib/api/api-fetch"; @@ -34,6 +34,7 @@ export function TrustedDevicesCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const [status, setStatus] = useState(null); @@ -125,7 +126,7 @@ export function TrustedDevicesCard({

{t("settings.security.trustedDevices.expires", { - date: new Date(d.expiresAt).toLocaleDateString(), + date: fmt.date(d.expiresAt), })}

diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx index 032c0c563..f8fb6d155 100644 --- a/src/components/ui/filter-bar.tsx +++ b/src/components/ui/filter-bar.tsx @@ -229,7 +229,7 @@ export function FilterBarDateRange({ const fmt = useFormatters(); const active = from !== "" || to !== ""; const display = active - ? `${from ? fmt.dateShort(from) : "…"} – ${to ? fmt.dateShort(to) : "…"}` + ? `${from ? fmt.dateShortSmart(from) : "…"} – ${to ? fmt.dateShortSmart(to) : "…"}` : null; const clear = () => { onFromChange(""); diff --git a/src/lib/__tests__/date-format.test.ts b/src/lib/__tests__/date-format.test.ts index e67ef4a96..3e990cb83 100644 --- a/src/lib/__tests__/date-format.test.ts +++ b/src/lib/__tests__/date-format.test.ts @@ -5,11 +5,12 @@ * field order the `` primitive and `useFormatters().date()` render. * AUTO defers to the active locale; DMY / MDY / YMD pin the order regardless. */ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatDate, formatDateWithWeekday, + formatDateWithWeekdaySmart, parseIsoDate, resolveDateLocale, isDateFormatPreference, @@ -112,6 +113,41 @@ describe("formatDateWithWeekday (#490)", () => { }); }); +// Issue #66 (date-format sweep) — the dose-history ledger scrolls day +// groups across year boundaries; a bare "Di., 16.12." from last December +// must not read as this year's. "Now" is faked so the boundary is +// deterministic regardless of the day this suite runs. +describe("formatDateWithWeekdaySmart (#66)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-10T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("omits the year for a day key in the current UTC year", () => { + expect(formatDateWithWeekdaySmart("2026-07-14", "AUTO", "en")).toBe( + "Tue, 07/14", + ); + }); + + it("adds the year for a day key from a prior UTC year", () => { + expect(formatDateWithWeekdaySmart("2025-12-16", "AUTO", "en")).toBe( + "Tue, 12/16/2025", + ); + expect(formatDateWithWeekdaySmart("2025-12-16", "AUTO", "de")).toBe( + "Di., 16.12.2025", + ); + }); + + it("returns an empty string for empty / unparseable input", () => { + expect(formatDateWithWeekdaySmart("", "AUTO", "en")).toBe(""); + expect(formatDateWithWeekdaySmart(null, "AUTO", "en")).toBe(""); + }); +}); + describe("parseIsoDate", () => { it("parses a valid ISO calendar date to a UTC midnight Date", () => { const d = parseIsoDate("2026-02-19"); diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index dea53e046..505b8af00 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -265,17 +265,6 @@ describe("makeFormatters", () => { // that prints as 2025 in New York must carry the year. expect(nyFmt.dateShortSmart(newYearUtcEve)).toContain("2025"); }); - - it("monthYear always carries the year (chart axis / next-dose labels)", () => { - const de = makeFormatters("de"); - const en = makeFormatters("en"); - const dec = new Date("2025-12-30T10:00:00Z"); - const jan = new Date("2026-01-15T10:00:00Z"); - expect(de.monthYear(dec)).toContain("2025"); - expect(de.monthYear(jan)).toContain("2026"); - expect(en.monthYear(dec)).toBe("Dec 2025"); - expect(en.monthYear(jan)).toBe("Jan 2026"); - }); }); }); diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts index 085292833..1808b9df9 100644 --- a/src/lib/date-format.ts +++ b/src/lib/date-format.ts @@ -167,6 +167,31 @@ export function formatDateWithWeekday( }); } +/** + * Same field set as {@link formatDateWithWeekday}, but adds the year + * whenever `value`'s UTC calendar year differs from the current UTC year + * — issue #66 (date-format sweep). A day group from a previous year (e.g. + * the dose-history ledger scrolled past a year boundary) must not read as + * this year's "Di., 16.12.". + */ +export function formatDateWithWeekdaySmart( + value: Date | string | null | undefined, + pref: DateFormatPreference, + locale: Locale, +): string { + if (value === null || value === undefined || value === "") return ""; + const date = value instanceof Date ? value : parseIsoDate(value); + if (date === null || Number.isNaN(date.getTime())) return ""; + const showYear = date.getUTCFullYear() !== new Date().getUTCFullYear(); + return date.toLocaleDateString(resolveDateLocale(pref, locale), { + timeZone: "UTC", + weekday: "short", + day: "2-digit", + month: "2-digit", + ...(showYear ? { year: "numeric" as const } : {}), + }); +} + /** * Parse a `yyyy-MM-dd` string to a UTC `Date` at midnight. Returns null on * a value that is not exactly the ISO calendar-date shape so callers can diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 7f5d42529..725e9b8f0 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -171,11 +171,6 @@ export interface Formatters { dateWithWeekdaySmart: (value: DateInput) => string; /** For axis labels: 3-letter month abbreviation. */ monthShort: (value: DateInput) => string; - /** - * Month + year, e.g. "Feb. 2026" / "Feb 2026" — for axis labels and - * next-dose captions that need to disambiguate a Dec → Jan rollover. - */ - monthYear: (value: DateInput) => string; } export function makeFormatters( @@ -299,13 +294,6 @@ export function makeFormatters( timeZone: tz, month: "short", }).format(asDate(value)), - - monthYear: (value) => - getDateTimeFormat(intlLocale, { - timeZone: tz, - month: "short", - year: "numeric", - }).format(asDate(value)), }; }