mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
e8327109b1
commit
7f44e2fa37
2 files changed
+135
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user