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:
109
frontend/src/client/views/format.test.ts
Normal file
109
frontend/src/client/views/format.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
122
frontend/src/client/views/format.ts
Normal file
122
frontend/src/client/views/format.ts
Normal 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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user