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.
This commit is contained in:
@@ -9,9 +9,10 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LANGUAGES, setLanguage } from "../../i18n";
|
import { setLanguage, type LangCode } from "../../i18n";
|
||||||
import { formatDate } from "../../lib/format";
|
import { formatDate } from "../../lib/format";
|
||||||
import { send } from "../../lib/http";
|
import { send } from "../../lib/http";
|
||||||
|
import LanguageSwitcher from "../LanguageSwitcher";
|
||||||
|
|
||||||
// The day the text last changed materially — bump it with every edit of either body.
|
// The day the text last changed materially — bump it with every edit of either body.
|
||||||
const LAST_UPDATED = "2026-09-17";
|
const LAST_UPDATED = "2026-09-17";
|
||||||
@@ -77,20 +78,8 @@ export default function LegalLayout({ title, children }: { title: string; childr
|
|||||||
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
|
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
|
||||||
Sift<span className="text-accent">lode</span>
|
Sift<span className="text-accent">lode</span>
|
||||||
</a>
|
</a>
|
||||||
<div role="group" aria-label={t("legal.languageLabel")} className="flex gap-1 text-xs">
|
{/* The landing page's own picker, so the public pages switch language one way. */}
|
||||||
{LANGUAGES.map((lang) => (
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
||||||
<button
|
|
||||||
key={lang.code}
|
|
||||||
type="button"
|
|
||||||
lang={lang.code}
|
|
||||||
aria-pressed={i18n.language?.startsWith(lang.code) ?? false}
|
|
||||||
onClick={() => setLanguage(lang.code)}
|
|
||||||
className="rounded-md px-2 py-1 text-muted hover:text-fg aria-pressed:bg-card aria-pressed:text-fg"
|
|
||||||
>
|
|
||||||
{lang.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="glass rounded-2xl p-8">
|
<div className="glass rounded-2xl p-8">
|
||||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||||
|
|||||||
@@ -2,6 +2,5 @@
|
|||||||
"lastUpdated": "Last updated: {{date}}",
|
"lastUpdated": "Last updated: {{date}}",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"operatorFallback": "your instance's administrator",
|
"operatorFallback": "your instance's administrator"
|
||||||
"languageLabel": "Language"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,5 @@
|
|||||||
"lastUpdated": "Utoljára frissítve: {{date}}",
|
"lastUpdated": "Utoljára frissítve: {{date}}",
|
||||||
"home": "Kezdőlap",
|
"home": "Kezdőlap",
|
||||||
"contact": "Kapcsolat",
|
"contact": "Kapcsolat",
|
||||||
"operatorFallback": "a példány adminisztrátora",
|
"operatorFallback": "a példány adminisztrátora"
|
||||||
"languageLabel": "Nyelv"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
RELATIVE_UNITS,
|
RELATIVE_UNITS,
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
formatRuntime,
|
formatRuntime,
|
||||||
formatTotalHours,
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
// 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 {
|
export function formatDate(iso: string | null | undefined, lang: string): string {
|
||||||
return iso
|
if (!iso) return "";
|
||||||
? new Date(iso).toLocaleDateString(lang, { year: "numeric", month: "short", day: "numeric" })
|
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" } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user