mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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" />
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user