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/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/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/i18n/locales/en/time.json b/frontend/src/i18n/locales/en/time.json index f6d16ee..5b87d93 100644 --- a/frontend/src/i18n/locales/en/time.json +++ b/frontend/src/i18n/locales/en/time.json @@ -1,12 +1,5 @@ { "justNow": "just now", - "secondsAgo": "{{count}}s ago", - "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 71ce423..9fd19e9 100644 --- a/frontend/src/i18n/locales/hu/time.json +++ b/frontend/src/i18n/locales/hu/time.json @@ -1,12 +1,5 @@ { "justNow": "épp most", - "secondsAgo": "{{count}} mp-e", - "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 new file mode 100644 index 0000000..5d09d96 --- /dev/null +++ b/frontend/src/lib/format.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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. +// `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; +const DAY = 86400; +const WEEK = 604800; +const MONTH = 2592000; +const YEAR = 31536000; + +// [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. +const NOW = Date.UTC(2026, 6, 20, 12, 0, 0); + +describe("relativeFromMs", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + i18nStub.language = "en"; + }); + afterEach(() => { + vi.useRealTimers(); + }); + + 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"); + }); + + it("renders nothing for a timestamp it cannot read", () => { + expect(relativeTime("not-a-date")).toBe(""); + expect(relativeTime(null)).toBe(""); + expect(relativeFromMs(NaN)).toBe(""); + }); + + // 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(([, tier]) => tier)); + for (const [, unit] of RELATIVE_UNITS) { + expect(covered, `no case covers ${unit}`).toContain(unit); + } + }); + + 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 7461c35..ab8ef47 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -25,32 +25,53 @@ export function formatSpeed(bps: number | null | undefined): string { return `${formatBytes(bps)}/s`; } +/** 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 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. */ + * 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); - const units: [number, string][] = [ - [60, "time.secondsAgo"], - [3600, "time.minutesAgo"], - [86400, "time.hoursAgo"], - [604800, "time.daysAgo"], - [2592000, "time.weeksAgo"], - [31536000, "time.monthsAgo"], - ]; - 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 (!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, unit] = RELATIVE_UNITS[0]; + for (const [unitSecs, unitName] of RELATIVE_UNITS) { + if (secs < unitSecs) break; + div = unitSecs; + unit = unitName; } - 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 relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the 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", 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", + }, +});