refactor(format): hoist the relative-time tiers and lock their invariants
Review follow-ups on the tier-shift fix. The table moves to a module-level RELATIVE_UNITS: it is constant, so rebuilding it per call was pure garbage on a path that runs once per rendered timestamp across a virtualized feed. Its ascending order is load-bearing and was unstated — sorting it largest-first would make every timestamp under a year read "just now", silently, the same shape as the bug this branch fixes. It is now documented and asserted, and the suite derives its tier coverage and translation checks from the table itself rather than from its own case list, so a new tier cannot slip in uncovered. The audit log read the empty string for an unreadable date as a value and rendered a blank cell; it now uses the `|| "—"` idiom the channel table already uses, so missing and unreadable dates look alike.
This commit is contained in:
@@ -3,9 +3,5 @@
|
||||
"playwright": {
|
||||
"config": ["playwright.config.ts"],
|
||||
"entry": ["e2e/**/*.@(spec|setup).ts"]
|
||||
},
|
||||
"vitest": {
|
||||
"config": ["vitest.config.ts"],
|
||||
"entry": ["src/**/*.test.ts"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export function auditColumns(
|
||||
className="text-muted tabular-nums"
|
||||
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
|
||||
>
|
||||
{r.created_at ? relativeTime(r.created_at) : "—"}
|
||||
{/* `|| "—"` not a null check: relativeTime is empty for a missing OR unreadable date. */}
|
||||
{relativeTime(r.created_at) || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { relativeTime, relativeFromMs } from "./format";
|
||||
import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format";
|
||||
import en from "../i18n/locales/en/time.json";
|
||||
import hu from "../i18n/locales/hu/time.json";
|
||||
|
||||
@@ -72,8 +72,27 @@ describe("relativeFromMs", () => {
|
||||
expect(relativeFromMs(NaN)).toBe("");
|
||||
});
|
||||
|
||||
it("translates every label these cases expect, in both languages", () => {
|
||||
const keys = [...new Set(CASES.map(([, , label]) => label.split(":")[0].replace("time.", "")))];
|
||||
// The three below are derived from RELATIVE_UNITS, not from CASES, so adding a tier to the
|
||||
// table without covering it here fails rather than slipping through unnoticed.
|
||||
it("keeps the unit table sorted ascending", () => {
|
||||
const divisors = RELATIVE_UNITS.map(([secs]) => secs);
|
||||
expect(divisors, "row 0 doubles as the just-now threshold").toEqual(
|
||||
[...divisors].sort((a, b) => a - b),
|
||||
);
|
||||
expect(new Set(divisors).size, "duplicate divisors would make a tier unreachable").toBe(
|
||||
divisors.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("exercises every tier in the unit table", () => {
|
||||
const covered = new Set(CASES.map(([, , label]) => label.split(":")[0]));
|
||||
for (const [, key] of RELATIVE_UNITS) {
|
||||
expect(covered, `no case covers ${key}`).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("translates every label the table can emit, in both languages", () => {
|
||||
const keys = ["justNow", ...RELATIVE_UNITS.map(([, key]) => key.replace("time.", ""))];
|
||||
for (const key of keys) {
|
||||
expect(en, `en/time.json is missing ${key}`).toHaveProperty(key);
|
||||
expect(hu, `hu/time.json is missing ${key}`).toHaveProperty(key);
|
||||
|
||||
+24
-17
@@ -25,27 +25,34 @@ export function formatSpeed(bps: number | null | undefined): string {
|
||||
return `${formatBytes(bps)}/s`;
|
||||
}
|
||||
|
||||
/** Relative-time tiers as [seconds-per-unit, label]. Each label names what its OWN divisor
|
||||
* produces — pairing a divisor with the NEXT row's label is what silently shifted every tier by
|
||||
* one from ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up
|
||||
* the scale), so a row is read whole.
|
||||
*
|
||||
* MUST stay sorted ascending, with the first row doubling as the "just now" threshold: sorting it
|
||||
* largest-first — the natural instinct — would make every timestamp under a year read "just now".
|
||||
* Module-level because it is constant, and exported so the tests can hold both invariants (the
|
||||
* ordering, and a translation existing for every label it can emit). */
|
||||
export const RELATIVE_UNITS: readonly [number, string][] = [
|
||||
[60, "time.minutesAgo"],
|
||||
[3600, "time.hoursAgo"],
|
||||
[86400, "time.daysAgo"],
|
||||
[604800, "time.weeksAgo"],
|
||||
[2592000, "time.monthsAgo"],
|
||||
[31536000, "time.yearsAgo"],
|
||||
];
|
||||
|
||||
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
||||
* client-side notifications whose timestamps are already numbers). One implementation, one
|
||||
* set of `time.*` strings — shared instead of re-implemented per component. */
|
||||
* set of `time.*` strings — shared instead of re-implemented per component. Empty string for a
|
||||
* timestamp that can't be read, so a caller's `|| "—"` fallback covers it like a missing one. */
|
||||
export function relativeFromMs(ms: number): string {
|
||||
const secs = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (!Number.isFinite(secs)) return ""; // unparseable timestamp — say nothing, not "NaN yr ago"
|
||||
// [seconds-per-unit, label] — each label names what its OWN divisor produces, and a row is read
|
||||
// whole (largest unit that fits). Pairing a divisor with the NEXT row's label is what silently
|
||||
// shifted every tier by one from ea317c0 until this fix: minutes rendered as "Ns ago", hours as
|
||||
// "N min ago", on up the scale.
|
||||
const units: [number, string][] = [
|
||||
[60, "time.minutesAgo"],
|
||||
[3600, "time.hoursAgo"],
|
||||
[86400, "time.daysAgo"],
|
||||
[604800, "time.weeksAgo"],
|
||||
[2592000, "time.monthsAgo"],
|
||||
[31536000, "time.yearsAgo"],
|
||||
];
|
||||
if (secs < units[0][0]) return i18n.t("time.justNow");
|
||||
let [div, key] = units[0];
|
||||
for (const [unitSecs, unitKey] of units) {
|
||||
if (!Number.isFinite(secs)) return "";
|
||||
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow");
|
||||
let [div, key] = RELATIVE_UNITS[0];
|
||||
for (const [unitSecs, unitKey] of RELATIVE_UNITS) {
|
||||
if (secs < unitSecs) break;
|
||||
div = unitSecs;
|
||||
key = unitKey;
|
||||
|
||||
Reference in New Issue
Block a user