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)),
};
}
|