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.
This commit is contained in:
m
2026-05-07 23:07:26 +02:00
parent 552c9200bc
commit f90bfeda9b
4 changed files with 266 additions and 42 deletions

View File

@@ -0,0 +1,109 @@
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/);
});
});

View File

@@ -0,0 +1,122 @@
import { getLang } from "../i18n";
import type { ViewRow } from "./types";
// Shared date/time formatters for the views shapes (list / cards / calendar).
//
// The 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
// component. Feeding that into new Date() + toLocaleTimeString() in a
// non-UTC browser produces "02:00" (CEST), "01:00" (CET), "20:00" the day
// before (EST), and so on — a phantom hour that the source data never had.
//
// The fix is to recognise the date-only shape and either render the date
// (formatted in UTC so the day matches the source day everywhere) or render
// nothing in the time slot. The kind-aware helpers below thread that
// distinction through the shapes; see t-paliad-153.
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T00:00:00(?:\.0+)?Z)?$/;
export function isDateOnly(iso: string): boolean {
return typeof iso === "string" && DATE_ONLY_RE.test(iso);
}
export function parseDateOnly(iso: string): Date | null {
if (typeof iso !== "string") return null;
const m = iso.match(DATE_ONLY_RE);
if (!m) return null;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return isNaN(d.getTime()) ? null : d;
}
function locale(): string {
return getLang() === "de" ? "de-DE" : "en-GB";
}
export function formatDate(iso: string): string {
const dateOnly = parseDateOnly(iso);
if (dateOnly) {
return dateOnly.toLocaleDateString(locale(), {
day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC",
});
}
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(locale(), {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export function formatLongDate(iso: string): string {
const dateOnly = parseDateOnly(iso);
if (dateOnly) {
return dateOnly.toLocaleDateString(locale(), {
weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
});
}
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(locale(), {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
}
// formatTime returns "" for date-only inputs — they have no real time and
// rendering them as HH:MM leaks the local UTC offset.
export function formatTime(iso: string): string {
if (isDateOnly(iso)) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" });
}
// formatRowTime: the time-slot helper used by shape-cards. When the
// surrounding shape already shows the date (e.g. day-grouped headings),
// deadlines render nothing — the date is implicit. Otherwise the deadline
// row falls back to its date so the user still knows when it's due.
export function formatRowTime(row: ViewRow, opts: { dateAvailable: boolean }): string {
if (row.kind === "deadline" || isDateOnly(row.event_date)) {
return opts.dateAvailable ? "" : formatDate(row.event_date);
}
return formatTime(row.event_date);
}
// formatRelative: deadlines reduce to day precision so a Frist due
// "tomorrow" never shows up as "in 2h" because of the UTC offset.
export function formatRelative(iso: string, kind?: ViewRow["kind"]): string {
if (kind === "deadline" || isDateOnly(iso)) return formatDayRelative(iso);
return formatMomentRelative(iso);
}
function formatDayRelative(iso: string): string {
const due = parseDateOnly(iso);
if (!due) return formatMomentRelative(iso);
const today = new Date();
const todayUTC = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
const diffDays = Math.round((due.getTime() - todayUTC) / 86400000);
const lang = getLang();
if (diffDays < 0) {
const n = Math.abs(diffDays);
return lang === "de"
? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
: (n === 1 ? "1 day ago" : `${n} days ago`);
}
if (diffDays === 0) return lang === "de" ? "heute" : "today";
if (diffDays === 1) return lang === "de" ? "morgen" : "tomorrow";
return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
}
function formatMomentRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
}

View File

@@ -1,5 +1,6 @@
import { t, type I18nKey, getLang } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { formatLongDate, formatRowTime, parseDateOnly } from "./format";
// shape-cards: day-grouped chronological cards. Same layout style as the
// existing /agenda timeline; works for any source mix.
@@ -11,13 +12,13 @@ export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: Ren
const sort = cfg.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
const aT = sortKey(a.event_date);
const bT = sortKey(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (groupBy === "none") {
host.appendChild(renderCardList(sorted));
host.appendChild(renderCardList(sorted, "none"));
return;
}
@@ -29,14 +30,17 @@ export function renderCardsShape(host: HTMLElement, rows: ViewRow[], render: Ren
heading.className = "views-cards-day-heading";
heading.textContent = key;
section.appendChild(heading);
section.appendChild(renderCardList(items));
section.appendChild(renderCardList(items, groupBy));
host.appendChild(section);
}
}
function renderCardList(rows: ViewRow[]): HTMLElement {
function renderCardList(rows: ViewRow[], groupBy: "day" | "week" | "none"): HTMLElement {
const ul = document.createElement("ul");
ul.className = "views-cards-list";
// The day-grouped heading already shows the date — only that mode lets the
// per-row time slot stay blank for date-only sources.
const dateAvailable = groupBy === "day";
for (const row of rows) {
const li = document.createElement("li");
li.className = `views-card views-card--${row.kind}`;
@@ -55,9 +59,12 @@ function renderCardList(rows: ViewRow[]): HTMLElement {
const meta = document.createElement("div");
meta.className = "views-card-meta";
const time = document.createElement("span");
time.textContent = formatTime(row.event_date);
meta.appendChild(time);
const timeText = formatRowTime(row, { dateAvailable });
if (timeText) {
const time = document.createElement("span");
time.textContent = timeText;
meta.appendChild(time);
}
if (row.project_title) {
const proj = document.createElement("span");
proj.className = "views-card-project";
@@ -95,11 +102,14 @@ function groupRows(rows: ViewRow[], groupBy: "day" | "week"): Array<[string, Vie
}
function bucketKey(iso: string, groupBy: "day" | "week"): string {
const d = new Date(iso);
// Date-only inputs (deadlines) are anchored to UTC midnight so the bucket
// matches the source day in every timezone — otherwise a UTC-X user would
// see deadlines slip into the previous day.
const dateOnly = parseDateOnly(iso);
const d = dateOnly ?? new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
if (groupBy === "week") {
// Round down to Monday, format as "KW NN, YYYY".
const monday = new Date(d);
const day = monday.getDay() || 7; // Sunday=0 → 7
monday.setDate(monday.getDate() - day + 1);
@@ -107,12 +117,12 @@ function bucketKey(iso: string, groupBy: "day" | "week"): string {
const weekNo = Math.ceil(((monday.getTime() - yearStart.getTime()) / 86400000 + yearStart.getDay() + 1) / 7);
return `KW ${weekNo}, ${monday.getFullYear()}`;
}
if (dateOnly) return formatLongDate(iso);
return d.toLocaleDateString(lang, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const lang = getLang() === "de" ? "de-DE" : "en-GB";
return d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" });
function sortKey(iso: string): number {
const dateOnly = parseDateOnly(iso);
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}

View File

@@ -1,5 +1,6 @@
import { t, type I18nKey, getLang } from "../i18n";
import { t, type I18nKey } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
import { formatDate, formatRelative, parseDateOnly } from "./format";
// shape-list: renders ViewRows as a table (density=comfortable) or a
// compact one-line stream (density=compact). The "activity feed" look
@@ -13,8 +14,8 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
const sort = list.sort ?? "date_asc";
const sorted = [...rows].sort((a, b) => {
const aT = Date.parse(a.event_date);
const bT = Date.parse(b.event_date);
const aT = sortKey(a.event_date);
const bT = sortKey(b.event_date);
return sort === "date_asc" ? aT - bT : bT - aT;
});
@@ -34,7 +35,7 @@ function renderCompact(rows: ViewRow[]): HTMLElement {
const time = document.createElement("span");
time.className = "views-list-time";
time.textContent = formatRelative(row.event_date);
time.textContent = formatRelative(row.event_date, row.kind);
li.appendChild(time);
const kindIcon = document.createElement("span");
@@ -122,7 +123,7 @@ function formatColumn(row: ViewRow, col: string): string {
case "date":
return formatDate(row.event_date);
case "time":
return formatRelative(row.event_date);
return formatRelative(row.event_date, row.kind);
case "title":
return row.title;
case "project":
@@ -156,26 +157,8 @@ function kindLabel(kind: string): string {
return t(("views.kind." + kind) as I18nKey);
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric", month: "2-digit", day: "2-digit",
});
}
function formatRelative(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;
const diffMs = t0 - Date.now();
const past = diffMs < 0;
const sec = Math.abs(Math.floor(diffMs / 1000));
const lang = getLang();
if (sec < 60) return past ? (lang === "de" ? `vor ${sec}s` : `${sec}s ago`) : (lang === "de" ? `in ${sec}s` : `in ${sec}s`);
const min = Math.floor(sec / 60);
if (min < 60) return past ? (lang === "de" ? `vor ${min}m` : `${min}m ago`) : (lang === "de" ? `in ${min}m` : `in ${min}m`);
const hr = Math.floor(min / 60);
if (hr < 24) return past ? (lang === "de" ? `vor ${hr}h` : `${hr}h ago`) : (lang === "de" ? `in ${hr}h` : `in ${hr}h`);
const day = Math.floor(hr / 24);
return past ? (lang === "de" ? `vor ${day}d` : `${day}d ago`) : (lang === "de" ? `in ${day}d` : `in ${day}d`);
function sortKey(iso: string): number {
const dateOnly = parseDateOnly(iso);
if (dateOnly) return dateOnly.getTime();
return Date.parse(iso);
}