fix(dates): show the year whenever a date is not the current one

Sweeps every surface that renders a date from before this year without
a year suffix — illness episodes/flares/day-logs, lab and custom-metric
charts, the mood chart family, medication titration/side-effects/dose-
history/next-dose captions (incl. the Dec→Jan rollover), cycle
predictions and history, the BBT chart, the intraday-pulse day-nav, the
date-range filter chip, and the nutrient/compliance charts. Each now
routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts)
or the matching date-format.ts helper, which add the year only when it
differs from the current one — mirroring the workout list's existing
model instead of a bare "19.02." that reads as this year's.

Also fixes two sites that bypassed the app's locale entirely: the
trusted-devices expiry (bare toLocaleDateString, host locale) and the
admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both
render through the user's own locale, hour-cycle, and timezone
preference.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 01:23:37 +02:00
1 parent 7f44e2fa37
commit 1ff179178f
28 files changed
+158 -104

No files matched your search

@@ -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() {
<TruncatedCell value={token.user.username} />
</td>
<td className="max-w-0 px-3 py-2">
<TruncatedCell value={formatTokenName(token.name)} />
<TruncatedCell
value={formatTokenName(token.name, fmt)}
/>
</td>
<td className="max-w-0 px-3 py-2">
<div className="flex flex-wrap gap-1">
@@ -303,7 +301,7 @@ export function ApiTokenOverviewSection() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<TruncatedCell
value={formatTokenName(token.name)}
value={formatTokenName(token.name, fmt)}
className="min-w-0 flex-1 font-medium"
/>
<TokenStatusBadge token={token} size="xs" />
+3 -3
View File
@@ -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<ChartDataPoint>((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<string, Record<string, number>>();
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)) {
@@ -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],
);
+3 -3
View File
@@ -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<string, number>();
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);
}
@@ -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({
/>
<XAxis
dataKey="day"
tickFormatter={(day: string) => 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 (
<RichChartTooltip
active
label={formatDateShort(dayToDate(point.day))}
label={fmt.dateShortSmart(dayToDate(point.day))}
rows={rows}
/>
);
@@ -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<RangeKey>("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 (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
/>
);
+6 -2
View File
@@ -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",
)}
>
<p className="text-muted-foreground">{fmt.dateShort(new Date(p.t))}</p>
<p className="text-muted-foreground">
{fmt.dateShortSmart(new Date(p.t))}
</p>
<p className="text-foreground font-medium tabular-nums">
{t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })}
</p>
+22 -12
View File
@@ -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 (
<div className="space-y-4">
@@ -92,8 +99,9 @@ export function PredictionsPanel({
<Row
hue={FERTILE_HUE}
label={t("cycle.predictions.fertileWindow")}
value={`${formatDate(prediction.fertileWindowStart)} – ${formatDate(
value={`${formatDate(prediction.fertileWindowStart, fmt)} – ${formatDate(
prediction.fertileWindowEnd,
fmt,
)}`}
/>
) : 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 (
<div className="space-y-1.5">
@@ -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),
})}
/>
<Badge variant={tone.variant} className="shrink-0">
@@ -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"
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate)}
{formatDate(c.startDate, fmt)}
{" – "}
{c.endDate
? formatDate(c.endDate)
? formatDate(c.endDate, fmt)
: t("cycle.history.ongoing")}
</span>
<span className="text-muted-foreground flex items-center gap-2 tabular-nums">
@@ -34,7 +34,7 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
return (
<div className="border-border/60 space-y-2 border-l-2 pl-3">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{fmt.dateShort(`${log.date}T12:00:00`)}
{fmt.dateShortSmart(`${log.date}T12:00:00`)}
</p>
{log.symptoms.length > 0 ? (
@@ -82,11 +82,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
</Badge>
<span className="text-muted-foreground text-xs">
{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)),
})}`
: ""}
</span>
+1 -1
View File
@@ -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 (
+4 -4
View File
@@ -103,7 +103,7 @@ function EpisodeCard({
: t("illness.status.recovered")}
</Badge>
<span className="text-muted-foreground">
{fmt.dateShort(new Date(episode.onsetAt))}
{fmt.dateShortSmart(new Date(episode.onsetAt))}
</span>
</div>
</div>
@@ -167,11 +167,11 @@ function EpisodeCard({
<p className="text-muted-foreground text-xs">
{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)),
})}`
: ""}
</p>
@@ -192,7 +192,7 @@ function EpisodeCard({
>
<span className="truncate">{flare.label}</span>
<span className="text-muted-foreground shrink-0">
{fmt.dateShort(new Date(flare.onsetAt))}
{fmt.dateShortSmart(new Date(flare.onsetAt))}
</span>
</Link>
</li>
@@ -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 (
<div
+4 -4
View File
@@ -16,9 +16,8 @@ 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 { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import type { LabResultDto } from "./types";
@@ -62,6 +61,7 @@ export function LabBiomarkerChart({
upperBound: number | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const [range, setRange] = useState<RangeKey>("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 (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rows}
/>
);
@@ -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)}
</h3>
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
{group.rows.map((row, i) => (
@@ -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"),
@@ -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 =
@@ -437,7 +437,7 @@ export function MedicationCard({
} else if (diffDays <= 5) {
dayLabel = weekdayLabel(nextDate.getDay());
} else {
dayLabel = fmt.dateWithWeekday(nextDate);
dayLabel = fmt.dateWithWeekdaySmart(nextDate);
}
}
@@ -551,7 +551,7 @@ function MedicationTableRowItem({
? t("medications.tomorrow")
: diffDays <= 5
? weekdayLabel(nextDate.getDay())
: fmt.dateWithWeekday(nextDate);
: fmt.dateWithWeekdaySmart(nextDate);
nextCell = (
<span>
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
@@ -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))}
</p>
</div>
{row.notes && (
@@ -221,9 +221,11 @@ export function TitrationTimeline({
</p>
<p className="text-muted-foreground shrink-0 text-xs">
{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),
),
})}
</p>
</div>
@@ -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<ChartPoint[]>(() => {
@@ -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 (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
label={fmt.dateShortSmart(new Date(point.timestamp))}
rows={rowsOut}
/>
);
@@ -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<string | null>(null);
@@ -125,7 +126,7 @@ export function TrustedDevicesCard({
</p>
<p className="text-muted-foreground text-xs">
{t("settings.security.trustedDevices.expires", {
date: new Date(d.expiresAt).toLocaleDateString(),
date: fmt.date(d.expiresAt),
})}
</p>
</div>
+1 -1
View File
@@ -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("");
+37 -1
View File
@@ -5,11 +5,12 @@
* field order the `<DateField>` 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");
-11
View File
@@ -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");
});
});
});
+25
View File
@@ -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
-12
View File
@@ -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)),
};
}