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.
110 lines
4.0 KiB
TypeScript
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/);
|
|
});
|
|
});
|