Files
paliad/frontend/src/client/views/format.test.ts
m f90bfeda9b fix(t-paliad-153): deadline due_date renders 02:00 in CEST (UTC-midnight leak)
Substrate marshals deadline.due_date as time.Date(...,0,0,0,0,UTC), so the
JSON arrives as "YYYY-MM-DDT00:00:00Z" — UTC midnight, no real time. Feeding
that into new Date() + toLocaleTimeString() produced "02:00" in CEST,
"01:00" in CET, "20:00 the day before" in EST, etc.

Pattern A: don't render time for date-only fields.

- Centralised the date/time formatters used by the views shapes into
  frontend/src/client/views/format.ts. parseDateOnly recognises both
  "YYYY-MM-DD" and the substrate's "YYYY-MM-DDT00:00:00Z" form; formatDate
  formats those in UTC so the day matches the source day in every timezone.
- shape-cards.ts: per-row time slot is empty for deadlines when the day is
  already in the heading (groupBy=day). Falls back to formatDate when
  groupBy=week|none. Bucketing now anchors date-only inputs to UTC so a
  deadline can't slip into the previous day in negative-offset zones.
- shape-list.ts: formatRelative is kind-aware — deadlines reduce to
  day-precision ("morgen" / "in 3 Tagen") instead of leaking hour math
  ("in 2h") off the UTC midnight.
- Appointments and other timestamped sources are untouched.
- format.test.ts: regression coverage in CEST / PST / UTC. 14 tests pass.
2026-05-07 23:07:26 +02:00

110 lines
4.0 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
formatDate,
formatRelative,
formatRowTime,
formatTime,
isDateOnly,
parseDateOnly,
} from "./format";
import type { ViewRow } from "./types";
// Regression tests for t-paliad-153: deadline due_date renders as 02:00 in
// CEST. The substrate marshals deadline.due_date as "YYYY-MM-DDT00:00:00Z";
// the formatters must treat that as a calendar day with no time component.
const stubRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
kind: "deadline",
id: "00000000-0000-0000-0000-000000000000",
title: "Call me",
event_date: "2026-05-08T00:00:00Z",
detail: {},
...overrides,
});
describe("isDateOnly / parseDateOnly", () => {
test("recognises YYYY-MM-DD", () => {
expect(isDateOnly("2026-05-08")).toBe(true);
expect(parseDateOnly("2026-05-08")).not.toBeNull();
});
test("recognises the substrate's UTC-midnight serialisation", () => {
expect(isDateOnly("2026-05-08T00:00:00Z")).toBe(true);
expect(parseDateOnly("2026-05-08T00:00:00Z")).not.toBeNull();
});
test("rejects timestamps with a real time component", () => {
expect(isDateOnly("2026-05-08T14:30:00Z")).toBe(false);
expect(parseDateOnly("2026-05-08T14:30:00Z")).toBeNull();
});
test("rejects garbage", () => {
expect(isDateOnly("not-a-date")).toBe(false);
expect(parseDateOnly("not-a-date")).toBeNull();
});
});
describe("formatTime", () => {
test("returns empty string for date-only inputs (no phantom 02:00)", () => {
expect(formatTime("2026-05-08T00:00:00Z")).toBe("");
expect(formatTime("2026-05-08")).toBe("");
});
test("renders HH:MM for real timestamps", () => {
expect(formatTime("2026-05-08T14:30:00Z")).toMatch(/\d{2}:\d{2}/);
});
});
describe("formatDate", () => {
test("date-only input formats the source day in any timezone", () => {
// Whatever locale getLang() resolves to, the day portion must be 08.
const out = formatDate("2026-05-08T00:00:00Z");
expect(out).toContain("08");
expect(out).toContain("2026");
});
});
describe("formatRowTime", () => {
test("deadline + dateAvailable=true returns empty (heading shows the day)", () => {
expect(formatRowTime(stubRow(), { dateAvailable: true })).toBe("");
});
test("deadline + dateAvailable=false falls back to the date", () => {
expect(formatRowTime(stubRow(), { dateAvailable: false })).toContain("2026");
});
test("appointment with a real start_at still renders HH:MM", () => {
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T14:30:00Z" });
expect(formatRowTime(row, { dateAvailable: true })).toMatch(/\d{2}:\d{2}/);
});
test("appointment with date-only event_date does not leak phantom time", () => {
const row = stubRow({ kind: "appointment", event_date: "2026-05-08T00:00:00Z" });
// Belt-and-braces: even if a stray date-only value shows up under a
// non-deadline kind, the helper detects it and returns "" instead of
// "02:00" / "01:00" / etc.
expect(formatRowTime(row, { dateAvailable: true })).toBe("");
});
});
describe("formatRelative", () => {
test("deadline kind reduces to day precision", () => {
const today = new Date();
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T00:00:00Z`;
const out = formatRelative(todayISO, "deadline");
expect(out.toLowerCase()).toMatch(/heute|today/);
});
test("date-only iso reduces to day precision even without an explicit kind", () => {
const today = new Date();
const todayISO = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const out = formatRelative(todayISO);
expect(out.toLowerCase()).toMatch(/heute|today/);
});
test("real timestamp keeps moment-precision relative", () => {
const inAnHour = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(formatRelative(inAnHour, "appointment")).toMatch(/\d/);
});
});