diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index cab583128..dea53e046 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { hourCycleOptions, makeFormatters, @@ -212,6 +212,71 @@ describe("makeFormatters", () => { expect(fmt.date(new Date("2026-10-25T01:30:00Z"))).toBe("25.10.2026"); }); }); + + // Issue #66 (date-format sweep) — `dateShortSmart` / `dateWithWeekdaySmart` + // add the year only when it differs from "now"'s year, so a chart axis, + // history list, or timeline never reads a bare "19.02." for a reading + // from a prior year and mistakes it for this year's. "Now" is faked so + // the boundary is deterministic regardless of the day this suite runs. + describe("conditional-year formatters (#66)", () => { + const RIGHT_NOW = new Date("2026-07-10T12:00:00Z"); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(RIGHT_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("dateShortSmart omits the year for a date in the current year", () => { + const de = makeFormatters("de"); + const en = makeFormatters("en"); + const thisYear = new Date("2026-02-19T10:00:00Z"); + expect(de.dateShortSmart(thisYear)).toBe("19.02."); + expect(en.dateShortSmart(thisYear)).not.toContain("2026"); + }); + + it("dateShortSmart includes the year for a date from a prior year", () => { + const de = makeFormatters("de"); + const en = makeFormatters("en"); + // A December-of-last-year date — the exact regression this guards. + const lastDecember = new Date("2025-12-16T10:00:00Z"); + expect(de.dateShortSmart(lastDecember)).toBe("16.12.2025"); + expect(en.dateShortSmart(lastDecember)).toContain("2025"); + }); + + it("dateWithWeekdaySmart mirrors the same boundary", () => { + const de = makeFormatters("de"); + const thisYear = new Date("2026-02-19T10:00:00Z"); + const lastDecember = new Date("2025-12-16T10:00:00Z"); + expect(de.dateWithWeekdaySmart(thisYear)).not.toContain("2026"); + expect(de.dateWithWeekdaySmart(lastDecember)).toContain("2025"); + }); + + it("compares the year in the formatter's own timezone, not the host's", () => { + // 2026-01-01T00:30Z is still 2025-12-31 in America/New_York (UTC-5) — + // the profile-tz formatter must agree with what it actually prints, + // not with a UTC or host-local read of "now"/the value. + const nyFmt = makeFormatters("en", "America/New_York"); + const newYearUtcEve = new Date("2026-01-01T00:30:00Z"); + // RIGHT_NOW (2026-07-10) is year 2026 in New York too, so a value + // 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"); + }); + }); }); // v1.25.3 — the time-format preference must reach every hour/minute renderer, diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 8628b9ba6..7f5d42529 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -120,6 +120,23 @@ function asDate(value: DateInput): Date { return value instanceof Date ? value : new Date(value); } +/** + * True when `date` falls in the same calendar year as "now", both read in + * `tz`. Backs the `*Smart` conditional-year formatters below — a date is + * compared in the SAME timezone the formatter renders it in (rather than + * the runtime's local timezone) so a UTC-pinned bucket-label formatter + * (`makeBucketLabelFormatters`, chart axes) and a profile-timezone + * formatter agree with what they actually print. `getDateTimeFormat` is + * the same process-wide cache the rest of this module uses, so the extra + * lookup is cheap. + */ +function isCurrentYearInTz(date: Date, tz: string, localeTag: string): boolean { + const yearOnly = { timeZone: tz, year: "numeric" as const }; + const target = getDateTimeFormat(localeTag, yearOnly).format(date); + const now = getDateTimeFormat(localeTag, yearOnly).format(new Date()); + return target === now; +} + export interface Formatters { /** Integer or decimal with the active locale's decimal separator. */ number: (value: number, fractionDigits?: number) => string; @@ -131,14 +148,34 @@ export interface Formatters { date: (value: DateInput) => string; /** Short date without year, e.g. "19.02." / "Feb 19". */ dateShort: (value: DateInput) => string; + /** + * Short date that adds the year whenever `value` falls outside the + * current calendar year, e.g. "19.02." this year vs. "19.02.2025" for + * a date from last year. Use this instead of `dateShort` on any + * surface that can render a date older than the current year (chart + * axes/tooltips, history lists, timelines) — see the "generell + * überall" year-disambiguation rule. + */ + dateShortSmart: (value: DateInput) => string; /** Full date + time; hour cycle follows the `timeFormat` preference. */ dateTime: (value: DateInput) => string; /** Time only, e.g. "14:30" or "2:30 PM" per the `timeFormat` preference. */ time: (value: DateInput) => string; /** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */ dateWithWeekday: (value: DateInput) => string; + /** + * Short weekday + date that adds the year whenever `value` falls + * outside the current calendar year, e.g. "Do., 19.02." this year vs. + * "Do., 19.02.2025" for a date from last year. + */ + 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( @@ -205,6 +242,18 @@ export function makeFormatters( month: "2-digit", }).format(asDate(value)), + dateShortSmart: (value) => { + const d = asDate(value); + return getDateTimeFormat(dateLocale, { + timeZone: tz, + day: "2-digit", + month: "2-digit", + ...(isCurrentYearInTz(d, tz, dateLocale) + ? {} + : { year: "numeric" as const }), + }).format(d); + }, + dateTime: (value) => getDateTimeFormat(dateLocale, { timeZone: tz, @@ -232,11 +281,31 @@ export function makeFormatters( month: "2-digit", }).format(asDate(value)), + dateWithWeekdaySmart: (value) => { + const d = asDate(value); + return getDateTimeFormat(dateLocale, { + timeZone: tz, + weekday: "short", + day: "2-digit", + month: "2-digit", + ...(isCurrentYearInTz(d, tz, dateLocale) + ? {} + : { year: "numeric" as const }), + }).format(d); + }, + monthShort: (value) => getDateTimeFormat(intlLocale, { timeZone: tz, month: "short", }).format(asDate(value)), + + monthYear: (value) => + getDateTimeFormat(intlLocale, { + timeZone: tz, + month: "short", + year: "numeric", + }).format(asDate(value)), }; }