From c61bc4ff74700d4e83f11a45a02250146e90f3ef Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 03:06:42 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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;