From c61bc4ff74700d4e83f11a45a02250146e90f3ef Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 03:06:42 +0200 Subject: [PATCH 1/5] fix(format): relative time labelled every tier one unit too small The units table paired each divisor with the NEXT row's label, so the count came out right and the word wrong, all the way up the scale: minutes rendered as "Ns ago", hours as "N min ago", days as "N hours ago", months as "N weeks ago". Most visible on the audit log, where scheduler rows three minutes apart read "1s / 4s / 7s ago". Each row now carries its own label and is read whole, so a divisor can no longer drift from its word. Also guard a non-finite age, which rendered "NaN mo ago", and drop time.secondsAgo now that nothing emits it. Present since ea317c0 swapped the hardcoded English strings for i18n keys; shipped in every release from 0.2.0 on. --- frontend/src/i18n/locales/en/time.json | 1 - frontend/src/i18n/locales/hu/time.json | 1 - frontend/src/lib/format.ts | 36 +++++++++++++------------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/frontend/src/i18n/locales/en/time.json b/frontend/src/i18n/locales/en/time.json index f6d16ee..40217d1 100644 --- a/frontend/src/i18n/locales/en/time.json +++ b/frontend/src/i18n/locales/en/time.json @@ -1,6 +1,5 @@ { "justNow": "just now", - "secondsAgo": "{{count}}s ago", "minutesAgo": "{{count}} min ago", "hoursAgo": "{{count}}h ago", "daysAgo": "{{count}}d ago", diff --git a/frontend/src/i18n/locales/hu/time.json b/frontend/src/i18n/locales/hu/time.json index 71ce423..4ad3a2b 100644 --- a/frontend/src/i18n/locales/hu/time.json +++ b/frontend/src/i18n/locales/hu/time.json @@ -1,6 +1,5 @@ { "justNow": "épp most", - "secondsAgo": "{{count}} mp-e", "minutesAgo": "{{count}} perce", "hoursAgo": "{{count}} órája", "daysAgo": "{{count}} napja", diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 7461c35..64ff796 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -30,27 +30,27 @@ export function formatSpeed(bps: number | null | undefined): string { * set of `time.*` strings — shared instead of re-implemented per component. */ 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.secondsAgo"], - [3600, "time.minutesAgo"], - [86400, "time.hoursAgo"], - [604800, "time.daysAgo"], - [2592000, "time.weeksAgo"], - [31536000, "time.monthsAgo"], + [60, "time.minutesAgo"], + [3600, "time.hoursAgo"], + [86400, "time.daysAgo"], + [604800, "time.weeksAgo"], + [2592000, "time.monthsAgo"], + [31536000, "time.yearsAgo"], ]; - if (secs < 60) return i18n.t("time.justNow"); - for (let i = 0; i < units.length - 1; i++) { - const [, key] = units[i]; - const next = units[i + 1][0]; - if (secs < next) { - const v = Math.floor(secs / units[i][0]); - return i18n.t(key, { count: v }); - } + if (secs < units[0][0]) return i18n.t("time.justNow"); + let [div, key] = units[0]; + for (const [unitSecs, unitKey] of units) { + if (secs < unitSecs) break; + div = unitSecs; + key = unitKey; } - const years = Math.floor(secs / 31536000); - if (years >= 1) return i18n.t("time.yearsAgo", { count: years }); - const months = Math.floor(secs / 2592000); - return i18n.t("time.monthsAgo", { count: months }); + return i18n.t(key, { count: Math.floor(secs / div) }); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the From da728f0c5ed84aaaf28d6790890b1758aaacbc6f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 03:06:50 +0200 Subject: [PATCH 2/5] test(format): cover the relative-time tier boundaries The repo had no unit-test runner, so this adds the minimum: vitest (3.x, as 4.x needs Vite 6), a node-env config deliberately separate from vite.config.ts so the production build never loads it, npm test / test:watch, and a knip entry so the config and specs are not reported unused. Browser flows stay in e2e/ under Playwright. The suite samples every tier at its exact opening second as well as inside it, and stubs i18n so each assertion lands on the key rather than the copy. An interior-only suite still passes when the range comparison flips to <=, which is how the tier shift went unnoticed for so long. --- frontend/knip.json | 4 + frontend/package-lock.json | 465 +++++++++++++++++++++++++++++++- frontend/package.json | 7 +- frontend/src/lib/format.test.ts | 82 ++++++ frontend/vitest.config.ts | 13 + 5 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/format.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/knip.json b/frontend/knip.json index 03dea5b..58513c6 100644 --- a/frontend/knip.json +++ b/frontend/knip.json @@ -3,5 +3,9 @@ "playwright": { "config": ["playwright.config.ts"], "entry": ["e2e/**/*.@(spec|setup).ts"] + }, + "vitest": { + "config": ["vitest.config.ts"], + "entry": ["src/**/*.test.ts"] } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e875693..757d351 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,8 @@ "postcss": "^8.4.39", "tailwindcss": "^3.4.6", "typescript": "^5.5.3", - "vite": "^5.3.4" + "vite": "^5.3.4", + "vitest": "^3.2.7" } }, "node_modules/@alloc/quick-lru": { @@ -1340,6 +1341,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1406,6 +1425,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1434,6 +1568,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1544,6 +1688,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1575,6 +1729,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1677,6 +1858,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1708,6 +1899,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1757,6 +1955,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2065,6 +2283,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2084,6 +2309,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2193,6 +2428,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2665,6 +2917,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2675,6 +2934,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2772,6 +3065,20 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2820,6 +3127,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2965,6 +3302,115 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -2974,6 +3420,23 @@ "node": ">=0.10.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 83d361a..9bb720c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -32,6 +34,7 @@ "postcss": "^8.4.39", "tailwindcss": "^3.4.6", "typescript": "^5.5.3", - "vite": "^5.3.4" + "vite": "^5.3.4", + "vitest": "^3.2.7" } } diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts new file mode 100644 index 0000000..a4e4e9f --- /dev/null +++ b/frontend/src/lib/format.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { relativeTime, relativeFromMs } from "./format"; +import en from "../i18n/locales/en/time.json"; +import hu from "../i18n/locales/hu/time.json"; + +// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it to +// echo `key:count`. That is exactly what these cases need to lock: which LABEL a range is paired +// with. The bug this suite exists for had every label one tier too small — right number, wrong +// word ("4 min ago" rendered as "4s ago"). +vi.mock("../i18n", () => ({ + default: { + t: (key: string, opts?: { count?: number }) => (opts ? `${key}:${opts.count}` : key), + }, +})); + +const MIN = 60; +const HOUR = 3600; +const DAY = 86400; +const WEEK = 604800; +const MONTH = 2592000; +const YEAR = 31536000; + +// [name, age in seconds, expected label]. Every tier is sampled both at its exact opening second +// and somewhere inside it — an interior-only suite still passes if the comparison flips to `<=`. +const CASES: [string, number, string][] = [ + ["just under a minute", MIN - 1, "time.justNow"], + ["exactly a minute", MIN, "time.minutesAgo:1"], + ["a minute and a half", 90, "time.minutesAgo:1"], + ["the last second before an hour", HOUR - 1, "time.minutesAgo:59"], + ["exactly an hour", HOUR, "time.hoursAgo:1"], + ["two hours", 2 * HOUR, "time.hoursAgo:2"], + ["the last second before a day", DAY - 1, "time.hoursAgo:23"], + ["exactly a day", DAY, "time.daysAgo:1"], + ["just past a day", 25 * HOUR, "time.daysAgo:1"], + ["the last second before a week", WEEK - 1, "time.daysAgo:6"], + ["exactly a week", WEEK, "time.weeksAgo:1"], + ["eight days", 8 * DAY, "time.weeksAgo:1"], + ["the last second before a month", MONTH - 1, "time.weeksAgo:4"], + ["exactly a month", MONTH, "time.monthsAgo:1"], + ["forty days", 40 * DAY, "time.monthsAgo:1"], + ["the last second before a year", YEAR - 1, "time.monthsAgo:12"], + ["exactly a year", YEAR, "time.yearsAgo:1"], + ["four hundred days", 400 * DAY, "time.yearsAgo:1"], + ["a decade", 10 * YEAR, "time.yearsAgo:10"], +]; + +// Fixed clock: relativeFromMs reads Date.now(), so the age under test has to be exact. +const NOW = Date.UTC(2026, 6, 20, 12, 0, 0); + +describe("relativeFromMs", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + for (const [name, secs, expected] of CASES) { + it(`${name} → ${expected}`, () => { + expect(relativeFromMs(NOW - secs * 1000)).toBe(expected); + }); + } + + it("clamps a future timestamp instead of counting up", () => { + expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow"); + }); + + it("renders nothing for a timestamp it cannot read", () => { + expect(relativeTime("not-a-date")).toBe(""); + expect(relativeTime(null)).toBe(""); + 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.", "")))]; + 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); + } + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..1ebbfaa --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// Unit tests for the pure helpers in src/lib — deliberately separate from vite.config.ts so the +// production build never has to load vitest. Plain node env (no jsdom): these tests stub the +// modules that touch the DOM. Browser flows stay in e2e/ under Playwright (`siftlode e2e`). +// Being its own file means vite.config.ts is NOT merged in — a future test that renders a .tsx +// component has to wire @vitejs/plugin-react (and a DOM environment) here itself. +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); From dea63d8fd2ec1f241eb45cb790144692e748c24e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 22:17:51 +0200 Subject: [PATCH 3/5] refactor(format): hoist the relative-time tiers and lock their invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/knip.json | 4 --- frontend/src/components/auditColumns.tsx | 3 +- frontend/src/lib/format.test.ts | 25 +++++++++++++-- frontend/src/lib/format.ts | 41 ++++++++++++++---------- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/frontend/knip.json b/frontend/knip.json index 58513c6..03dea5b 100644 --- a/frontend/knip.json +++ b/frontend/knip.json @@ -3,9 +3,5 @@ "playwright": { "config": ["playwright.config.ts"], "entry": ["e2e/**/*.@(spec|setup).ts"] - }, - "vitest": { - "config": ["vitest.config.ts"], - "entry": ["src/**/*.test.ts"] } } diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index 642a9df..f301a1e 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -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) || "—"} ), }, diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index a4e4e9f..ac47eb9 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -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); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 64ff796..4c55ec6 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -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; From 7cd5adc78f625de4d960a08ec91ba20112700fad Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 22:28:09 +0200 Subject: [PATCH 4/5] refactor(format): localize relative time with Intl.RelativeTimeFormat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier table kept a divisor and a translation key as two independently maintained halves, and a mispairing there rendered minutes as "Ns ago" for 59 releases. The table now pairs a divisor with the Intl unit it produces and the platform supplies the words, so the wrong half is at least self-evident on sight — "minute" next to 3600 reads wrong in a way "time.minutesAgo" never did. It also removes 12 hand-written strings and hands plural rules to CLDR for any language added later. Hungarian output is byte-identical to the strings it replaces. English gets shorter: "59 min ago" to "59m ago", "1 wk ago" to "1w ago", "1 mo ago" to "1mo ago", "1 yr ago" to "1y ago"; "2h ago" and "1d ago" are unchanged. style narrow matches the app's compact timestamps and numeric always keeps it counting, rather than "yesterday"/"last week". time.justNow stays because Intl has no wording for the sub-minute case. Formatters are memoized per language, since this runs once per rendered timestamp across a virtualized feed. --- frontend/src/i18n/locales/en/time.json | 6 -- frontend/src/i18n/locales/hu/time.json | 6 -- frontend/src/lib/format.test.ts | 99 ++++++++++++++------------ frontend/src/lib/format.ts | 54 ++++++++------ 4 files changed, 88 insertions(+), 77 deletions(-) diff --git a/frontend/src/i18n/locales/en/time.json b/frontend/src/i18n/locales/en/time.json index 40217d1..5b87d93 100644 --- a/frontend/src/i18n/locales/en/time.json +++ b/frontend/src/i18n/locales/en/time.json @@ -1,11 +1,5 @@ { "justNow": "just now", - "minutesAgo": "{{count}} min ago", - "hoursAgo": "{{count}}h ago", - "daysAgo": "{{count}}d ago", - "weeksAgo": "{{count}} wk ago", - "monthsAgo": "{{count}} mo ago", - "yearsAgo": "{{count}} yr ago", "eta": { "now": "now", "done": "done", diff --git a/frontend/src/i18n/locales/hu/time.json b/frontend/src/i18n/locales/hu/time.json index 4ad3a2b..9fd19e9 100644 --- a/frontend/src/i18n/locales/hu/time.json +++ b/frontend/src/i18n/locales/hu/time.json @@ -1,11 +1,5 @@ { "justNow": "épp most", - "minutesAgo": "{{count}} perce", - "hoursAgo": "{{count}} órája", - "daysAgo": "{{count}} napja", - "weeksAgo": "{{count}} hete", - "monthsAgo": "{{count}} hónapja", - "yearsAgo": "{{count}} éve", "eta": { "now": "most", "done": "kész", diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index ac47eb9..5d09d96 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -3,15 +3,12 @@ import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format"; import en from "../i18n/locales/en/time.json"; import hu from "../i18n/locales/hu/time.json"; -// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it to -// echo `key:count`. That is exactly what these cases need to lock: which LABEL a range is paired -// with. The bug this suite exists for had every label one tier too small — right number, wrong -// word ("4 min ago" rendered as "4s ago"). -vi.mock("../i18n", () => ({ - default: { - t: (key: string, opts?: { count?: number }) => (opts ? `${key}:${opts.count}` : key), - }, -})); +// i18n boots the whole locale glob (and touches localStorage/document) on import, so stub it. +// `language` is mutable because relativeFromMs reads it per call to pick the Intl locale — these +// cases assert both languages by flipping it. `t` echoes the key: the only string still coming +// from the locale files is justNow. +const i18nStub = vi.hoisted(() => ({ language: "en", t: (key: string) => key })); +vi.mock("../i18n", () => ({ default: i18nStub })); const MIN = 60; const HOUR = 3600; @@ -20,28 +17,31 @@ const WEEK = 604800; const MONTH = 2592000; const YEAR = 31536000; -// [name, age in seconds, expected label]. Every tier is sampled both at its exact opening second -// and somewhere inside it — an interior-only suite still passes if the comparison flips to `<=`. -const CASES: [string, number, string][] = [ - ["just under a minute", MIN - 1, "time.justNow"], - ["exactly a minute", MIN, "time.minutesAgo:1"], - ["a minute and a half", 90, "time.minutesAgo:1"], - ["the last second before an hour", HOUR - 1, "time.minutesAgo:59"], - ["exactly an hour", HOUR, "time.hoursAgo:1"], - ["two hours", 2 * HOUR, "time.hoursAgo:2"], - ["the last second before a day", DAY - 1, "time.hoursAgo:23"], - ["exactly a day", DAY, "time.daysAgo:1"], - ["just past a day", 25 * HOUR, "time.daysAgo:1"], - ["the last second before a week", WEEK - 1, "time.daysAgo:6"], - ["exactly a week", WEEK, "time.weeksAgo:1"], - ["eight days", 8 * DAY, "time.weeksAgo:1"], - ["the last second before a month", MONTH - 1, "time.weeksAgo:4"], - ["exactly a month", MONTH, "time.monthsAgo:1"], - ["forty days", 40 * DAY, "time.monthsAgo:1"], - ["the last second before a year", YEAR - 1, "time.monthsAgo:12"], - ["exactly a year", YEAR, "time.yearsAgo:1"], - ["four hundred days", 400 * DAY, "time.yearsAgo:1"], - ["a decade", 10 * YEAR, "time.yearsAgo:10"], +// [age in seconds, expected tier, expected en, expected hu]. Every tier is sampled at its exact +// opening second and inside it — an interior-only suite still passes if the range comparison +// flips to `<=`. The wording comes from Intl/CLDR rather than from us, so a failure here after a +// runtime upgrade means the platform's copy moved, not that tier selection broke; the tier column +// is what pins our own logic. +const CASES: [number, string, string, string][] = [ + [MIN - 1, "justNow", "time.justNow", "time.justNow"], + [MIN, "minute", "1m ago", "1 perce"], + [90, "minute", "1m ago", "1 perce"], + [HOUR - 1, "minute", "59m ago", "59 perce"], + [HOUR, "hour", "1h ago", "1 órája"], + [2 * HOUR, "hour", "2h ago", "2 órája"], + [DAY - 1, "hour", "23h ago", "23 órája"], + [DAY, "day", "1d ago", "1 napja"], + [25 * HOUR, "day", "1d ago", "1 napja"], + [WEEK - 1, "day", "6d ago", "6 napja"], + [WEEK, "week", "1w ago", "1 hete"], + [8 * DAY, "week", "1w ago", "1 hete"], + [MONTH - 1, "week", "4w ago", "4 hete"], + [MONTH, "month", "1mo ago", "1 hónapja"], + [40 * DAY, "month", "1mo ago", "1 hónapja"], + [YEAR - 1, "month", "12mo ago", "12 hónapja"], + [YEAR, "year", "1y ago", "1 éve"], + [400 * DAY, "year", "1y ago", "1 éve"], + [10 * YEAR, "year", "10y ago", "10 éve"], ]; // Fixed clock: relativeFromMs reads Date.now(), so the age under test has to be exact. @@ -51,17 +51,29 @@ describe("relativeFromMs", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); + i18nStub.language = "en"; }); afterEach(() => { vi.useRealTimers(); }); - for (const [name, secs, expected] of CASES) { - it(`${name} → ${expected}`, () => { - expect(relativeFromMs(NOW - secs * 1000)).toBe(expected); + for (const [secs, tier, expectedEn, expectedHu] of CASES) { + it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => { + expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn); + i18nStub.language = "hu"; + expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu); }); } + it("follows a language switch at runtime", () => { + const twoHoursAgo = NOW - 2 * HOUR * 1000; + expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); + i18nStub.language = "hu"; + expect(relativeFromMs(twoHoursAgo)).toBe("2 órája"); + i18nStub.language = "en"; + expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); + }); + it("clamps a future timestamp instead of counting up", () => { expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow"); }); @@ -72,8 +84,8 @@ describe("relativeFromMs", () => { expect(relativeFromMs(NaN)).toBe(""); }); - // 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. + // 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( @@ -85,17 +97,14 @@ describe("relativeFromMs", () => { }); 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); + const covered = new Set(CASES.map(([, tier]) => tier)); + for (const [, unit] of RELATIVE_UNITS) { + expect(covered, `no case covers ${unit}`).toContain(unit); } }); - 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); - } + it("still has the one string Intl cannot produce, in both languages", () => { + expect(en).toHaveProperty("justNow"); + expect(hu).toHaveProperty("justNow"); }); }); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 4c55ec6..ab8ef47 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -25,39 +25,53 @@ 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. +/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces, + * and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no + * longer be paired with the wrong label, which is exactly what 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). * * 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"], + * Module-level because it is constant, and exported so the tests can hold that invariant. */ +export const RELATIVE_UNITS: readonly [number, Intl.RelativeTimeFormatUnit][] = [ + [60, "minute"], + [3600, "hour"], + [86400, "day"], + [604800, "week"], + [2592000, "month"], + [31536000, "year"], ]; +// One formatter per language, built lazily: relativeFromMs runs once per rendered timestamp +// across a virtualized feed, and constructing an Intl formatter is the expensive part. Keyed by +// language because the user can switch it at runtime. `narrow` is the style that matches the +// app's compact timestamps; `numeric: "always"` keeps it counting ("1d ago", never "yesterday"). +const relativeFormatters = new Map(); +function relativeFormatter(lang: string): Intl.RelativeTimeFormat { + let formatter = relativeFormatters.get(lang); + if (!formatter) { + formatter = new Intl.RelativeTimeFormat(lang, { style: "narrow", numeric: "always" }); + relativeFormatters.set(lang, formatter); + } + return formatter; +} + /** 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. Empty string for a - * timestamp that can't be read, so a caller's `|| "—"` fallback covers it like a missing one. */ + * client-side notifications whose timestamps are already numbers). One implementation 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 ""; + // Sub-minute is the one case Intl has no wording for — it would say "0m ago". if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow"); - let [div, key] = RELATIVE_UNITS[0]; - for (const [unitSecs, unitKey] of RELATIVE_UNITS) { + let [div, unit] = RELATIVE_UNITS[0]; + for (const [unitSecs, unitName] of RELATIVE_UNITS) { if (secs < unitSecs) break; div = unitSecs; - key = unitKey; + unit = unitName; } - return i18n.t(key, { count: Math.floor(secs / div) }); + return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the From 742cf2df73df18c8693f7c2621a20ca2ad0366a2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 22:31:30 +0200 Subject: [PATCH 5/5] =?UTF-8?q?release:=200.47.0=20=E2=80=94=20relative=20?= =?UTF-8?q?timestamps=20fixed,=20and=20localized=20by=20the=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 3010923..421ab54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.0 +0.47.0 diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index e28e392..2b194df 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,20 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.47.0", + date: "2026-07-20", + summary: "Relative timestamps tell the truth again — and now come from your language itself.", + fixes: [ + 'Every "time ago" label named the wrong unit: something four minutes old read "4s ago", an hour old "1 min ago", a day old "1 hour ago". The number was right all along, only the word was wrong. This affected every timestamp in the app — the feed, channels, messages, notifications, the player and the audit log.', + ], + features: [ + 'Relative timestamps are now produced by your language\'s own rules rather than written by hand, so they read naturally in Hungarian and English alike. Hungarian is unchanged; in English they are a little shorter ("59m ago" rather than "59 min ago").', + ], + chores: [ + "First unit tests in the project, covering every relative-time range at its exact boundary so this class of mistake cannot come back unnoticed.", + ], + }, { version: "0.46.0", date: "2026-07-20",