feat(format): add conditional-year date formatters

dateShortSmart / dateWithWeekdaySmart add the year only when a date
falls outside the current calendar year, so a chart axis or history
row never shows a bare "19.02." for a reading from last year. Also
adds monthYear for Dec-to-Jan rollover labels (next-dose captions).
Year comparison runs in the formatter's own timezone so UTC-pinned
bucket-label formatters agree with what they print.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 01:23:37 +02:00
1 parent e8327109b1
commit 7f44e2fa37
2 files changed
+135 -1

No files matched your search

+66 -1
View File
@@ -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,
+69
View File
@@ -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)),
};
}