From 791ba5f50700b2f1648a29ccb4512d4e9a73fc76 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 17 Sep 2026 03:53:40 +0200 Subject: [PATCH] fix: a calendar date is the same day everywhere; legal pages use the shared language picker formatDate read a date-only value as UTC midnight and showed it in the viewer's zone, so the legal pages' last update read a day early west of UTC. Date-only values are shown in UTC. The legal pages' hand-rolled language buttons are the landing page's LanguageSwitcher. formatDateTime and formatDate get the two-language test formatNumber already had. --- frontend/src/components/legal/LegalLayout.tsx | 19 +++-------- frontend/src/i18n/locales/en/legal.json | 3 +- frontend/src/i18n/locales/hu/legal.json | 3 +- frontend/src/lib/format.test.ts | 32 +++++++++++++++++++ frontend/src/lib/format.ts | 14 ++++++-- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/legal/LegalLayout.tsx b/frontend/src/components/legal/LegalLayout.tsx index 1fda88ba..5b0d30bd 100644 --- a/frontend/src/components/legal/LegalLayout.tsx +++ b/frontend/src/components/legal/LegalLayout.tsx @@ -9,9 +9,10 @@ import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { LANGUAGES, setLanguage } from "../../i18n"; +import { setLanguage, type LangCode } from "../../i18n"; import { formatDate } from "../../lib/format"; import { send } from "../../lib/http"; +import LanguageSwitcher from "../LanguageSwitcher"; // The day the text last changed materially — bump it with every edit of either body. const LAST_UPDATED = "2026-09-17"; @@ -77,20 +78,8 @@ export default function LegalLayout({ title, children }: { title: string; childr Siftlode -
- {LANGUAGES.map((lang) => ( - - ))} -
+ {/* The landing page's own picker, so the public pages switch language one way. */} +

{title}

diff --git a/frontend/src/i18n/locales/en/legal.json b/frontend/src/i18n/locales/en/legal.json index 40e80736..ab344824 100644 --- a/frontend/src/i18n/locales/en/legal.json +++ b/frontend/src/i18n/locales/en/legal.json @@ -2,6 +2,5 @@ "lastUpdated": "Last updated: {{date}}", "home": "Home", "contact": "Contact", - "operatorFallback": "your instance's administrator", - "languageLabel": "Language" + "operatorFallback": "your instance's administrator" } diff --git a/frontend/src/i18n/locales/hu/legal.json b/frontend/src/i18n/locales/hu/legal.json index 8efe0910..ee2ac430 100644 --- a/frontend/src/i18n/locales/hu/legal.json +++ b/frontend/src/i18n/locales/hu/legal.json @@ -2,6 +2,5 @@ "lastUpdated": "Utoljára frissítve: {{date}}", "home": "Kezdőlap", "contact": "Kapcsolat", - "operatorFallback": "a példány adminisztrátora", - "languageLabel": "Nyelv" + "operatorFallback": "a példány adminisztrátora" } diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index ab28ce42..cb723338 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RELATIVE_UNITS, + formatDate, + formatDateTime, formatNumber, formatRuntime, formatTotalHours, @@ -191,3 +193,33 @@ describe("formatRuntime", () => { } }); }); + +// Dates had the same bug as numbers (a bare `toLocaleString()`), and the same trap for a test: the +// runtime's own locale and time zone are the dev box's, so both languages — and a zone west of UTC — +// are pinned explicitly. +describe("formatDateTime / formatDate", () => { + const savedTz = process.env.TZ; + afterEach(() => { + i18nStub.language = "en"; + if (savedTz === undefined) delete process.env.TZ; + else process.env.TZ = savedTz; + }); + + it("formats a timestamp in the UI language, not in the runtime's", () => { + const iso = "2026-09-17T08:30:14Z"; + const asEnglish = formatDateTime(iso); + i18nStub.language = "hu"; + const asHungarian = formatDateTime(iso); + expect(asEnglish).toBe(new Date(iso).toLocaleString("en")); + expect(asHungarian).toBe(new Date(iso).toLocaleString("hu")); + expect(asEnglish).not.toBe(asHungarian); + expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu")); + }); + + it("shows a date-only value as that calendar day in every time zone", () => { + process.env.TZ = "America/New_York"; + expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026"); + process.env.TZ = "Pacific/Kiritimati"; + expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026"); + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 0dde20e3..ede19328 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -176,8 +176,16 @@ export function formatDateTime(iso: string | null | undefined): string { } // Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null. +// A DATE-only value ("2026-09-17": a release, the legal pages' last update) is a calendar day, not an +// instant: `new Date` reads it as UTC midnight, which is still the day before west of UTC, so it is +// shown in UTC. A timestamp is shown in the viewer's own zone, as it should be. export function formatDate(iso: string | null | undefined, lang: string): string { - return iso - ? new Date(iso).toLocaleDateString(lang, { year: "numeric", month: "short", day: "numeric" }) - : ""; + if (!iso) return ""; + const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso); + return new Date(iso).toLocaleDateString(lang, { + year: "numeric", + month: "short", + day: "numeric", + ...(dateOnly ? { timeZone: "UTC" } : {}), + }); }