From 060d8d9c7047a9d4061fbc424a6cf910a3e0e819 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 20:51:00 +0200 Subject: [PATCH 01/34] chore: enforce LF line endings via .gitattributes, ignore .idea and .superpowers Shell scripts checked out with CRLF on Windows (core.autocrlf=true) broke the Docker entrypoint at container start. Force LF for all text files at the git layer, matching the existing .editorconfig and Prettier settings. --- .gitattributes | 1 + .gitignore | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..94f480de9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index a6612d8a5..74e68a56e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ config/server.yaml /package .env .vscode +.idea .env.* !.env.example vite.config.js.timestamp-* @@ -39,4 +40,5 @@ translation-report.json # AI workflow docs (not version-controlled) CONTEXT.md docs/adr/ -docs/superpowers/ \ No newline at end of file +docs/superpowers/ +.superpowers/ \ No newline at end of file From fca7624252dbfbd8d402928e88959bf447ee864d Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 22:18:03 +0200 Subject: [PATCH 02/34] test: add Vitest server project with tool.ts timestamp tests --- package-lock.json | 336 +++++++++++++++++++++++++++++++++++- package.json | 8 +- src/lib/server/tool.test.ts | 81 +++++++++ vite.config.ts | 14 ++ 4 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/tool.test.ts diff --git a/package-lock.json b/package-lock.json index 6fdbe0ea4..81146d087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,7 +126,8 @@ "vaul-svelte": "^1.0.0-next.7", "vite": "^7.2.2", "vite-node": "^5.3.0", - "vite-plugin-devtools-json": "^1.0.0" + "vite-plugin-devtools-json": "^1.0.0", + "vitest": "^4.1.10" }, "engines": { "node": ">=20.0.0" @@ -2666,6 +2667,17 @@ "@types/node": "*" } }, + "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/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2716,6 +2728,13 @@ "dev": true, "license": "MIT" }, + "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/dns2": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/dns2/-/dns2-2.0.10.tgz", @@ -2900,6 +2919,129 @@ "@codemirror/view": ">=6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@zone-eu/mailsplit": { "version": "5.4.8", "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", @@ -3027,6 +3169,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3539,6 +3691,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3805,6 +3967,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -5018,6 +5187,16 @@ "node": ">=6" } }, + "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/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -9490,6 +9669,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -9614,6 +9800,13 @@ "node": ">= 0.6" } }, + "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/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -9639,6 +9832,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10215,6 +10415,23 @@ "node": ">=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": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10231,6 +10448,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tlds": { "version": "1.261.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", @@ -10729,6 +10956,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10864,6 +11181,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index d3eaaab71..95bb146e7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,10 @@ "preview": "vite preview", "schedule": "vite-node src/lib/server/startup.ts", "seed": "npx knex seed:run", - "start": "node build/main.js" + "start": "node build/main.js", + "test": "vitest run", + "test:server": "vitest run --project=server", + "test:watch": "vitest" }, "devDependencies": { "@internationalized/date": "^3.10.0", @@ -92,7 +95,8 @@ "vaul-svelte": "^1.0.0-next.7", "vite": "^7.2.2", "vite-node": "^5.3.0", - "vite-plugin-devtools-json": "^1.0.0" + "vite-plugin-devtools-json": "^1.0.0", + "vitest": "^4.1.10" }, "engines": { "node": ">=20.0.0" diff --git a/src/lib/server/tool.test.ts b/src/lib/server/tool.test.ts new file mode 100644 index 000000000..226ea6245 --- /dev/null +++ b/src/lib/server/tool.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BeginningOfDay, + BeginningOfMinute, + DurationInMinutes, + GetDayStartTimestampUTC, + GetDayStartWithOffset, + GetMinuteStartNowTimestampUTC, + GetMinuteStartTimestampUTC, + GetNowTimestampUTC, + GetNowTimestampUTCInMs, +} from "./tool"; + +// All fixed timestamps are at 12:00 UTC so that local-date-based functions +// (GetDayStartTimestampUTC mixes local date parts with Date.UTC) produce the +// same result on any machine with a UTC offset between -11 and +11 hours. +const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z +const DAY_START = 1768435200; // 2026-01-15T00:00:00Z + +describe("timestamp helpers", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("GetNowTimestampUTC returns the current epoch in seconds", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00Z")); + expect(GetNowTimestampUTC()).toBe(NOON_UTC); + }); + + it("GetNowTimestampUTCInMs returns the current epoch in milliseconds", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00Z")); + expect(GetNowTimestampUTCInMs()).toBe(NOON_UTC * 1000); + }); + + it("GetMinuteStartTimestampUTC floors a timestamp to the start of its minute", () => { + expect(GetMinuteStartTimestampUTC(NOON_UTC + 45)).toBe(NOON_UTC); + expect(GetMinuteStartTimestampUTC(NOON_UTC)).toBe(NOON_UTC); + expect(GetMinuteStartTimestampUTC(NOON_UTC + 59)).toBe(NOON_UTC); + }); + + it("GetMinuteStartNowTimestampUTC floors the current time to the minute", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:45Z")); + expect(GetMinuteStartNowTimestampUTC()).toBe(NOON_UTC); + }); + + it("GetDayStartTimestampUTC returns midnight UTC of the timestamp's day", () => { + expect(GetDayStartTimestampUTC(NOON_UTC)).toBe(DAY_START); + expect(GetDayStartTimestampUTC(DAY_START + 12 * 3600 + 61)).toBe(DAY_START); + }); + + it("DurationInMinutes floors the difference to whole minutes", () => { + expect(DurationInMinutes(0, 600)).toBe(10); + expect(DurationInMinutes(0, 59)).toBe(0); + expect(DurationInMinutes(DAY_START, NOON_UTC)).toBe(720); + }); + + it("GetDayStartWithOffset shifts the UTC day start by positive offsets", () => { + // 330 minutes = UTC+5:30 (e.g. IST) + expect(GetDayStartWithOffset(NOON_UTC, 330)).toBe(DAY_START + 330 * 60); + expect(GetDayStartWithOffset(NOON_UTC, 0)).toBe(DAY_START); + }); + + it("GetDayStartWithOffset moves to the next day for negative offsets", () => { + expect(GetDayStartWithOffset(NOON_UTC, -330)).toBe(DAY_START + 86400 - 330 * 60); + }); + + it("BeginningOfDay returns midnight in the given timezone", () => { + const date = new Date("2026-01-15T12:00:00Z"); + expect(BeginningOfDay({ date, timeZone: "UTC" })).toBe(DAY_START); + // Midnight in New York (UTC-5 in January) is 05:00 UTC + expect(BeginningOfDay({ date, timeZone: "America/New_York" })).toBe(DAY_START + 5 * 3600); + }); + + it("BeginningOfMinute strips seconds", () => { + const date = new Date("2026-01-15T12:00:45Z"); + expect(BeginningOfMinute({ date, timeZone: "UTC" })).toBe(NOON_UTC); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 5b76075dc..40e0408e7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import tailwindcss from "@tailwindcss/vite"; import { sveltekit } from "@sveltejs/kit/vite"; import version from "vite-plugin-package-version"; @@ -56,5 +57,18 @@ export default defineConfig(({ mode }) => { __KENER_BUILD_ENV__: JSON.stringify(buildEnv), __KENER_IS_PROD__: JSON.stringify(isProduction), }, + test: { + projects: [ + { + extends: "./vite.config.ts", + test: { + name: "server", + environment: "node", + include: ["src/**/*.{test,spec}.{js,ts}"], + exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], + }, + }, + ], + }, }; }); From 180c963b67d0af49d5ab28db8c984f6fe98bee56 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 22:24:26 +0200 Subject: [PATCH 03/34] test: cover tool.ts uptime math (ParseUptime, formulas, UptimeCalculator) --- src/lib/server/tool.test.ts | 125 ++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/lib/server/tool.test.ts b/src/lib/server/tool.test.ts index 226ea6245..5b05b53c1 100644 --- a/src/lib/server/tool.test.ts +++ b/src/lib/server/tool.test.ts @@ -9,7 +9,13 @@ import { GetMinuteStartTimestampUTC, GetNowTimestampUTC, GetNowTimestampUTCInMs, + IsValidUptimeFormula, + ParsePercentage, + ParseUptime, + UnparsePercentage, + UptimeCalculator, } from "./tool"; +import type { TimestampStatusCount } from "./types/db"; // All fixed timestamps are at 12:00 UTC so that local-date-based functions // (GetDayStartTimestampUTC mixes local date parts with Date.UTC) produce the @@ -79,3 +85,122 @@ describe("timestamp helpers", () => { expect(BeginningOfMinute({ date, timeZone: "UTC" })).toBe(NOON_UTC); }); }); + +describe("ParseUptime", () => { + it("returns '-' when there is no data", () => { + expect(ParseUptime(0, 0)).toBe("-"); + }); + + it("returns '0' when nothing is up", () => { + expect(ParseUptime(0, 10)).toBe("0"); + }); + + it("returns whole numbers without decimals", () => { + expect(ParseUptime(10, 10)).toBe("100"); + expect(ParseUptime(5, 10)).toBe("50"); + }); + + it("keeps 4 decimal places and strips trailing zeros", () => { + expect(ParseUptime(1, 3)).toBe("33.3333"); + expect(ParseUptime(2, 3)).toBe("66.6667"); + expect(ParseUptime(1, 8)).toBe("12.5"); + expect(ParseUptime(999, 1000)).toBe("99.9"); + }); +}); + +describe("ParsePercentage / UnparsePercentage", () => { + it("formats numbers as percentage strings", () => { + expect(ParsePercentage(NaN)).toBe("-"); + expect(ParsePercentage(0)).toBe("0"); + expect(ParsePercentage(100)).toBe("100"); + expect(ParsePercentage(99.9)).toBe("99.9000"); + }); + + it("parses percentage strings back to numbers", () => { + expect(UnparsePercentage("99.9")).toBe(99.9); + expect(UnparsePercentage("0")).toBe(0); + expect(Number.isNaN(UnparsePercentage("-"))).toBe(true); + }); +}); + +describe("IsValidUptimeFormula", () => { + it("accepts single variables and operator chains", () => { + expect(IsValidUptimeFormula("up")).toBe(true); + expect(IsValidUptimeFormula("up + down")).toBe(true); + expect(IsValidUptimeFormula("up+down+degraded+maintenance")).toBe(true); + expect(IsValidUptimeFormula("up - down * maintenance / degraded")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(IsValidUptimeFormula("UP + Maintenance")).toBe(true); + }); + + it("rejects malformed formulas", () => { + expect(IsValidUptimeFormula("")).toBe(false); + expect(IsValidUptimeFormula(" ")).toBe(false); + expect(IsValidUptimeFormula("up +")).toBe(false); + expect(IsValidUptimeFormula("+ up")).toBe(false); + expect(IsValidUptimeFormula("banana + up")).toBe(false); + expect(IsValidUptimeFormula("up ** down")).toBe(false); + expect(IsValidUptimeFormula("up down")).toBe(false); + expect(IsValidUptimeFormula("42")).toBe(false); + }); +}); + +describe("UptimeCalculator", () => { + const day = (overrides: Partial): TimestampStatusCount => ({ + ts: 0, + countOfUp: 0, + countOfDown: 0, + countOfDegraded: 0, + countOfMaintenance: 0, + avgLatency: 0, + maxLatency: 0, + minLatency: 0, + ...overrides, + }); + + it("computes uptime and latency aggregates with the default formulas", () => { + // Defaults (global-constants): numerator "up + maintenance + degraded", + // denominator "up + down + degraded + maintenance" + const data = [ + day({ countOfUp: 30, countOfDown: 10, avgLatency: 100, maxLatency: 150, minLatency: 50 }), + day({ countOfUp: 50, countOfDegraded: 10, avgLatency: 200, maxLatency: 300, minLatency: 80 }), + ]; + expect(UptimeCalculator(data)).toEqual({ + uptime: "90", // (80 up + 0 maintenance + 10 degraded) / 100 total + avgLatency: "150ms", // (100 + 200) / 2 + maxLatency: "300ms", + minLatency: "50ms", + }); + }); + + it("supports a custom numerator formula", () => { + const data = [day({ countOfUp: 30, countOfDown: 10 }), day({ countOfUp: 50, countOfDegraded: 10 })]; + expect(UptimeCalculator(data, "up").uptime).toBe("80"); + }); + + it("falls back to the default formula when the custom one is invalid", () => { + const data = [day({ countOfUp: 30, countOfDown: 10 }), day({ countOfUp: 50, countOfDegraded: 10 })]; + expect(UptimeCalculator(data, "banana + up").uptime).toBe("90"); + }); + + it("returns '-' uptime and empty latencies for no data", () => { + expect(UptimeCalculator([])).toEqual({ + uptime: "-", + avgLatency: "", + maxLatency: "", + minLatency: "", + }); + }); + + it("only counts entries with a positive avgLatency toward the average", () => { + const data = [ + day({ countOfUp: 10, avgLatency: 0 }), + day({ countOfUp: 10, avgLatency: 100, maxLatency: 100, minLatency: 100 }), + ]; + const result = UptimeCalculator(data); + expect(result.uptime).toBe("100"); + expect(result.avgLatency).toBe("100ms"); // (0 + 100) / 1 entry with latency > 0 + }); +}); From 048518f2a88f5a1b7d771831d5827d78ac339e3e Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 22:29:55 +0200 Subject: [PATCH 04/34] test: cover tool.ts validation and string helpers --- src/lib/server/tool.test.ts | 174 +++++++++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/src/lib/server/tool.test.ts b/src/lib/server/tool.test.ts index 5b05b53c1..c574949bf 100644 --- a/src/lib/server/tool.test.ts +++ b/src/lib/server/tool.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BeginningOfDay, BeginningOfMinute, + checkIfDuplicateExists, DurationInMinutes, GetDayStartTimestampUTC, GetDayStartWithOffset, @@ -9,11 +10,25 @@ import { GetMinuteStartTimestampUTC, GetNowTimestampUTC, GetNowTimestampUTCInMs, + GetRequiredSecrets, + GetWordsStartingWithDollar, + HashString, + IsStringURLSafe, + IsValidHost, + IsValidHTTPMethod, + IsValidNameServer, + IsValidURL, IsValidUptimeFormula, + MaskString, ParsePercentage, ParseUptime, + ReplaceAllOccurrences, UnparsePercentage, UptimeCalculator, + ValidateEmail, + ValidateIpAddress, + ValidateMonitorAlerts, + ValidateURL, } from "./tool"; import type { TimestampStatusCount } from "./types/db"; @@ -204,3 +219,160 @@ describe("UptimeCalculator", () => { expect(result.avgLatency).toBe("100ms"); // (0 + 100) / 1 entry with latency > 0 }); }); + +describe("ValidateIpAddress", () => { + it("classifies IPv4, IPv6, domain names, and invalid input", () => { + expect(ValidateIpAddress("192.168.1.1")).toBe("IPv4"); + expect(ValidateIpAddress("8.8.8.8")).toBe("IPv4"); + expect(ValidateIpAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe("IPv6"); + expect(ValidateIpAddress("example.com")).toBe("Domain Name"); + expect(ValidateIpAddress("kener.ing")).toBe("Domain Name"); + expect(ValidateIpAddress("not valid")).toBe("Invalid"); + }); + + it("documents quirks: permissive IPv4 octets, no shortened IPv6", () => { + // The regex does not range-check octets: + expect(ValidateIpAddress("999.999.999.999")).toBe("IPv4"); + // Only full-form IPv6 is recognized: + expect(ValidateIpAddress("::1")).toBe("Invalid"); + }); +}); + +describe("host / nameserver / URL / method validators", () => { + it("IsValidHost accepts dotted domains with a TLD", () => { + expect(IsValidHost("example.com")).toBe(true); + expect(IsValidHost("sub.domain.co.uk")).toBe(true); + expect(IsValidHost("my-site.example.org")).toBe(true); + expect(IsValidHost("localhost")).toBe(false); + expect(IsValidHost("-bad.com")).toBe(false); + expect(IsValidHost("")).toBe(false); + }); + + it("IsValidNameServer accepts only dotted-quad IPs", () => { + expect(IsValidNameServer("8.8.8.8")).toBe(true); + expect(IsValidNameServer("example.com")).toBe(false); + expect(IsValidNameServer("8.8.8")).toBe(false); + }); + + it("IsValidURL requires http(s) and no spaces", () => { + expect(IsValidURL("https://example.com")).toBe(true); + expect(IsValidURL("http://example.com/path?q=1")).toBe(true); + expect(IsValidURL("ftp://example.com")).toBe(false); + expect(IsValidURL("https://exa mple.com")).toBe(false); + }); + + it("ValidateURL parses with the URL constructor and allows only http(s)", () => { + expect(ValidateURL("https://example.com")).toBe(true); + expect(ValidateURL("http://localhost:3000")).toBe(true); + expect(ValidateURL("ftp://example.com")).toBe(false); + expect(ValidateURL("javascript:alert(1)")).toBe(false); + expect(ValidateURL("not a url")).toBe(false); + }); + + it("IsValidHTTPMethod is uppercase-only", () => { + expect(IsValidHTTPMethod("GET")).toBe(true); + expect(IsValidHTTPMethod("PATCH")).toBe(true); + expect(IsValidHTTPMethod("get")).toBe(false); + expect(IsValidHTTPMethod("FETCH")).toBe(false); + }); + + it("IsStringURLSafe allows unreserved URL characters only", () => { + expect(IsStringURLSafe("abc-123_~.")).toBe(true); + expect(IsStringURLSafe("a b")).toBe(false); + expect(IsStringURLSafe("a/b")).toBe(false); + }); +}); + +describe("ValidateEmail", () => { + it("accepts common address shapes", () => { + expect(ValidateEmail("user@example.com")).toBe(true); + expect(ValidateEmail("first.last@sub.example.co")).toBe(true); + expect(ValidateEmail("user+tag@example.com")).toBe(true); + }); + + it("rejects malformed addresses", () => { + expect(ValidateEmail("no-at.example.com")).toBe(false); + expect(ValidateEmail("user@")).toBe(false); + expect(ValidateEmail("user@nodot")).toBe(false); + expect(ValidateEmail("@example.com")).toBe(false); + }); +}); + +describe("string helpers", () => { + it("MaskString masks everything but the last 4 characters", () => { + expect(MaskString("secret123")).toBe("*****t123"); + expect(MaskString("abcd")).toBe("****"); + expect(MaskString("ab")).toBe("**"); + expect(MaskString("")).toBe(""); + }); + + it("HashString returns the sha256 hex digest", () => { + expect(HashString("hello")).toBe("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + }); + + it("GetWordsStartingWithDollar extracts $-prefixed words", () => { + expect(GetWordsStartingWithDollar("run $FOO with $BAR_2")).toEqual(["$FOO", "$BAR_2"]); + expect(GetWordsStartingWithDollar("nothing here")).toEqual([]); + }); + + it("ReplaceAllOccurrences replaces every occurrence of a $-token", () => { + expect(ReplaceAllOccurrences("a $X b $X", "$X", "y")).toBe("a y b y"); + }); + + it("checkIfDuplicateExists detects duplicates", () => { + expect(checkIfDuplicateExists([1, 2, 2])).toBe(true); + expect(checkIfDuplicateExists(["a", "b"])).toBe(false); + }); +}); + +describe("GetRequiredSecrets", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("resolves $-tokens that exist in the environment", () => { + vi.stubEnv("KENER_TEST_SECRET", "s3cret"); + expect(GetRequiredSecrets("token: $KENER_TEST_SECRET")).toEqual([ + { find: "$KENER_TEST_SECRET", replace: "s3cret" }, + ]); + }); + + it("ignores tokens with no matching environment variable", () => { + expect(GetRequiredSecrets("token: $KENER_TEST_DEFINITELY_UNSET_VAR")).toEqual([]); + }); +}); + +describe("ValidateMonitorAlerts", () => { + beforeEach(() => { + // The function console.logs every rejection reason; keep test output clean. + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const validAlert = { triggers: [1], failureThreshold: 1, successThreshold: 2 }; + + it("accepts DOWN and/or DEGRADED alerts with triggers and integer thresholds", () => { + expect(ValidateMonitorAlerts({ DOWN: validAlert })).toBe(true); + expect(ValidateMonitorAlerts({ DOWN: validAlert, DEGRADED: validAlert })).toBe(true); + }); + + it("rejects missing/empty/unknown-key alert objects", () => { + expect(ValidateMonitorAlerts(null)).toBe(false); + expect(ValidateMonitorAlerts({})).toBe(false); + expect(ValidateMonitorAlerts({ UP: validAlert } as never)).toBe(false); + }); + + it("rejects missing or empty triggers", () => { + expect(ValidateMonitorAlerts({ DOWN: { failureThreshold: 1, successThreshold: 1 } })).toBe(false); + expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, triggers: [] } })).toBe(false); + }); + + it("rejects missing, non-integer, or non-positive thresholds", () => { + expect(ValidateMonitorAlerts({ DOWN: { triggers: [1] } })).toBe(false); + expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, failureThreshold: 1.5 } })).toBe(false); + expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, successThreshold: 0 } })).toBe(false); + }); +}); From e8008671d9b220f5d7b23bb5cd47616a81690cc3 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 22:52:18 +0200 Subject: [PATCH 05/34] test: add Vitest browser-mode client project with CopyButton test --- package-lock.json | 170 ++++++++++++++++++- package.json | 5 +- src/lib/components/CopyButton.svelte.test.ts | 46 +++++ vite.config.ts | 18 ++ vitest-setup-client.ts | 27 +++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/CopyButton.svelte.test.ts create mode 100644 vitest-setup-client.ts diff --git a/package-lock.json b/package-lock.json index 81146d087..c2d01ea7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,7 @@ "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", "@types/nodemailer": "^7.0.4", + "@vitest/browser-playwright": "^4.1.10", "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "concurrently": "^9.2.1", @@ -127,7 +128,8 @@ "vite": "^7.2.2", "vite-node": "^5.3.0", "vite-plugin-devtools-json": "^1.0.0", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "vitest-browser-svelte": "^3.0.0" }, "engines": { "node": ">=20.0.0" @@ -158,6 +160,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -2646,6 +2655,19 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2919,6 +2941,63 @@ "@codemirror/view": ">=6.0.0" } }, + "node_modules/@vitest/browser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -8470,6 +8549,56 @@ "node": ">=22.0.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", @@ -11046,6 +11175,23 @@ } } }, + "node_modules/vitest-browser-svelte": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-3.0.0.tgz", + "integrity": "sha512-BKM9iBGNsEQzigWuSZFAiw6AJDqQqMttApoD8gM8LTI6vxfd423rDMN8GqUHJyGbhH3XcOo9N9u/4hu47Nxhsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/svelte-core": "^1.1.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^4.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -11221,6 +11367,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", diff --git a/package.json b/package.json index 95bb146e7..ccc0e2067 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "seed": "npx knex seed:run", "start": "node build/main.js", "test": "vitest run", + "test:client": "vitest run --project=client", "test:server": "vitest run --project=server", "test:watch": "vitest" }, @@ -72,6 +73,7 @@ "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", "@types/nodemailer": "^7.0.4", + "@vitest/browser-playwright": "^4.1.10", "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "concurrently": "^9.2.1", @@ -96,7 +98,8 @@ "vite": "^7.2.2", "vite-node": "^5.3.0", "vite-plugin-devtools-json": "^1.0.0", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "vitest-browser-svelte": "^3.0.0" }, "engines": { "node": ">=20.0.0" diff --git a/src/lib/components/CopyButton.svelte.test.ts b/src/lib/components/CopyButton.svelte.test.ts new file mode 100644 index 000000000..7ceb0a8e1 --- /dev/null +++ b/src/lib/components/CopyButton.svelte.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-svelte"; +import CopyButton from "./CopyButton.svelte"; + +// The clipboard is spied on rather than read back: headless Chromium does not +// grant clipboard-read permission, and the component's contract is "calls +// navigator.clipboard.writeText and flips to the copied state". +describe("CopyButton", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("copies the text prop and shows the copied state on click", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + const screen = await render(CopyButton, { text: "copy-me" }); + const copiedTooltip = screen.getByText("Copied"); + + // Hidden before the click (class-based visibility) + await expect.element(copiedTooltip).toHaveClass(/opacity-0/); + + await screen.getByRole("button").click(); + + expect(writeText).toHaveBeenCalledExactlyOnceWith("copy-me"); + await expect.element(copiedTooltip).toHaveClass(/scale-100/); + }); + + it("invokes the onclick callback", async () => { + vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + const onclick = vi.fn(); + + const screen = await render(CopyButton, { text: "x", onclick }); + await screen.getByRole("button").click(); + + expect(onclick).toHaveBeenCalledOnce(); + }); + + it("does not touch the clipboard when no text is provided", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + const screen = await render(CopyButton, {}); + await screen.getByRole("button").click(); + + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 40e0408e7..17e7b377c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import version from "vite-plugin-package-version"; import { defineConfig } from "vite"; import devtoolsJson from "vite-plugin-devtools-json"; +import { playwright } from "@vitest/browser-playwright"; import * as dotenv from "dotenv"; @@ -68,6 +69,23 @@ export default defineConfig(({ mode }) => { exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], }, }, + { + extends: "./vite.config.ts", + test: { + name: "client", + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + // Vitest's default browser API port (63315) can fall inside Windows + // Hyper-V excluded TCP port ranges; pin below the ephemeral range. + api: { port: 5180 }, + }, + include: ["src/**/*.svelte.{test,spec}.{js,ts}"], + setupFiles: ["./vitest-setup-client.ts"], + }, + }, ], }, }; diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts new file mode 100644 index 000000000..bafa1c885 --- /dev/null +++ b/vitest-setup-client.ts @@ -0,0 +1,27 @@ +import { vi } from "vitest"; + +// Shared stubs for SvelteKit runtime modules used by components under test. +// Values are minimal but realistic; individual tests can override with their +// own vi.mock(...) when they need different page data. +vi.mock("$app/state", () => ({ + page: { + data: { + siteStatusColors: { UP: "#22c55e", DOWN: "#ef4444", DEGRADED: "#eab308", MAINTENANCE: "#3b82f6" }, + dateAndTimeFormat: { dateOnly: "yyyy-MM-dd", timeOnly: "HH:mm" }, + nowAtTz: 1768478400, // 2026-01-15T12:00:00Z + }, + url: new URL("http://localhost/"), + params: {}, + route: { id: null }, + status: 200, + error: null, + form: null, + state: {}, + }, +})); + +vi.mock("$app/paths", () => ({ + base: "", + assets: "", + resolve: (path: string) => path, +})); From 2dc46ab0073a81323b65420b5e7a473216e80774 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 23:02:11 +0200 Subject: [PATCH 06/34] test: add StatusBarCalendar browser-mode component test --- .../StatusBarCalendar.svelte.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/lib/components/StatusBarCalendar.svelte.test.ts diff --git a/src/lib/components/StatusBarCalendar.svelte.test.ts b/src/lib/components/StatusBarCalendar.svelte.test.ts new file mode 100644 index 000000000..5540f66ba --- /dev/null +++ b/src/lib/components/StatusBarCalendar.svelte.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { render } from "vitest-browser-svelte"; +import StatusBarCalendar from "./StatusBarCalendar.svelte"; +import type { TimestampStatusCount } from "$lib/server/types/db"; + +// Noon UTC keeps the rendered calendar date stable for any machine timezone +// with an offset within ±11 hours (the timezone store defaults to browser TZ). +const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z + +const day = (overrides: Partial): TimestampStatusCount => ({ + ts: NOON_UTC, + countOfUp: 0, + countOfDown: 0, + countOfDegraded: 0, + countOfMaintenance: 0, + avgLatency: 0, + maxLatency: 0, + minLatency: 0, + ...overrides, +}); + +describe("StatusBarCalendar", () => { + it("labels the canvas with the number of days", async () => { + const data = [day({ countOfUp: 10 }), day({ countOfUp: 10 }), day({ countOfUp: 10 })]; + const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true }); + + await expect.element(screen.getByLabelText("Status calendar showing 3-day uptime data")).toBeInTheDocument(); + }); + + it("shows status summary, date, and latency in the tooltip of the hovered day", async () => { + // Three bars: hovering the canvas center lands on the middle bar (index 1), + // which is 90% down => "Major System Outage" (>= 75% threshold). + const data = [ + day({ countOfUp: 100 }), + day({ countOfDown: 90, countOfUp: 10, avgLatency: 250 }), + day({ countOfUp: 90, countOfDegraded: 10 }), + ]; + const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true }); + + await screen.getByLabelText("Status calendar showing 3-day uptime data").hover(); + + await expect.element(screen.getByText("Major System Outage")).toBeInTheDocument(); + // page.data.dateAndTimeFormat.dateOnly is mocked as "yyyy-MM-dd" in vitest-setup-client.ts + await expect.element(screen.getByText("2026-01-15", { exact: false })).toBeInTheDocument(); + await expect.element(screen.getByText("250ms")).toBeInTheDocument(); + }); + + it("reports a partial outage when downtime is below the 75% threshold", async () => { + const data = [day({ countOfUp: 100 }), day({ countOfDown: 20, countOfUp: 80 }), day({ countOfUp: 100 })]; + const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true }); + + await screen.getByLabelText("Status calendar showing 3-day uptime data").hover(); + + await expect.element(screen.getByText("Partial System Outage")).toBeInTheDocument(); + }); +}); From ae1586cde7a386ddee501e643ef67e10bf8d02f7 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Mon, 13 Jul 2026 23:31:44 +0200 Subject: [PATCH 07/34] test: pin test timezone to UTC, document test commands --- CLAUDE.md | 6 ++++++ package.json | 6 +++--- src/lib/components/StatusBarCalendar.svelte.test.ts | 4 ++-- src/lib/server/tool.test.ts | 6 +++--- vite.config.ts | 5 +++-- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a5edd3698..7486ca37f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,14 @@ npm run check # Svelte + TypeScript type checking npm run prettify # Format all files with Prettier npm run migrate # Run database migrations via Knex npm run seed # Run database seeds (migrations run automatically first) +npm test # Run all tests (server unit + browser component projects) +npm run test:server # Server-side unit tests only (Node) +npm run test:client # Component tests only (headless Chromium via Playwright) +npm run test:watch # Watch mode ``` +Component tests need a one-time `npx playwright install chromium`. + ## Architecture ### Dual Process Model diff --git a/package.json b/package.json index ccc0e2067..a4366688f 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,10 @@ "schedule": "vite-node src/lib/server/startup.ts", "seed": "npx knex seed:run", "start": "node build/main.js", - "test": "vitest run", + "test": "cross-env TZ=UTC vitest run", "test:client": "vitest run --project=client", - "test:server": "vitest run --project=server", - "test:watch": "vitest" + "test:server": "cross-env TZ=UTC vitest run --project=server", + "test:watch": "cross-env TZ=UTC vitest" }, "devDependencies": { "@internationalized/date": "^3.10.0", diff --git a/src/lib/components/StatusBarCalendar.svelte.test.ts b/src/lib/components/StatusBarCalendar.svelte.test.ts index 5540f66ba..47fc548f8 100644 --- a/src/lib/components/StatusBarCalendar.svelte.test.ts +++ b/src/lib/components/StatusBarCalendar.svelte.test.ts @@ -3,8 +3,8 @@ import { render } from "vitest-browser-svelte"; import StatusBarCalendar from "./StatusBarCalendar.svelte"; import type { TimestampStatusCount } from "$lib/server/types/db"; -// Noon UTC keeps the rendered calendar date stable for any machine timezone -// with an offset within ±11 hours (the timezone store defaults to browser TZ). +// The browser context is pinned to UTC (see vite.config.ts), so the timezone store +// (which defaults to browser TZ) is deterministic; noon UTC keeps fixtures safe too. const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z const day = (overrides: Partial): TimestampStatusCount => ({ diff --git a/src/lib/server/tool.test.ts b/src/lib/server/tool.test.ts index c574949bf..6540ea45d 100644 --- a/src/lib/server/tool.test.ts +++ b/src/lib/server/tool.test.ts @@ -32,9 +32,9 @@ import { } from "./tool"; import type { TimestampStatusCount } from "./types/db"; -// All fixed timestamps are at 12:00 UTC so that local-date-based functions -// (GetDayStartTimestampUTC mixes local date parts with Date.UTC) produce the -// same result on any machine with a UTC offset between -11 and +11 hours. +// The npm scripts pin TZ=UTC, so local-date-based functions (GetDayStartTimestampUTC +// mixes local date parts with Date.UTC) are deterministic here. Fixtures still use +// noon UTC so the tests stay safe even if vitest is run directly without the pin. const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z const DAY_START = 1768435200; // 2026-01-15T00:00:00Z diff --git a/vite.config.ts b/vite.config.ts index 17e7b377c..9bdb25e09 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import version from "vite-plugin-package-version"; import { defineConfig } from "vite"; import devtoolsJson from "vite-plugin-devtools-json"; import { playwright } from "@vitest/browser-playwright"; +import { configDefaults } from "vitest/config"; import * as dotenv from "dotenv"; @@ -66,7 +67,7 @@ export default defineConfig(({ mode }) => { name: "server", environment: "node", include: ["src/**/*.{test,spec}.{js,ts}"], - exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], + exclude: [...configDefaults.exclude, "src/**/*.svelte.{test,spec}.{js,ts}"], }, }, { @@ -76,7 +77,7 @@ export default defineConfig(({ mode }) => { browser: { enabled: true, headless: true, - provider: playwright(), + provider: playwright({ contextOptions: { timezoneId: "UTC" } }), instances: [{ browser: "chromium" }], // Vitest's default browser API port (63315) can fall inside Windows // Hyper-V excluded TCP port ranges; pin below the ephemeral range. From a7b5c2c171c615562c9a36ed1126513edbfdfe86 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 05:59:27 +0200 Subject: [PATCH 08/34] ci: add test workflow with Playwright browser caching --- .github/workflows/test.yml | 42 ++++++++++++++++++++++++++++++++++++++ vite.config.ts | 5 +++++ 2 files changed, 47 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..c85e147bb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -p "require('./package-lock.json').packages['node_modules/playwright'].version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Type check + run: npm run check + + - name: Run tests + run: npm test diff --git a/vite.config.ts b/vite.config.ts index 9bdb25e09..24d8a7176 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -72,6 +72,11 @@ export default defineConfig(({ mode }) => { }, { extends: "./vite.config.ts", + // Pre-bundle the component-test dependency graph: on a cold Vite cache + // (every CI runner) a mid-run dep-optimization reload can flake the suite. + optimizeDeps: { + include: ["layerchart", "mode-watcher", "bits-ui", "d3-scale", "d3-shape"], + }, test: { name: "client", browser: { From c40b41a8b9d02adaefde0be72fd2b216565491f7 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 06:12:42 +0200 Subject: [PATCH 09/34] ci: disable credential persistence, read Playwright version from installed CLI --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c85e147bb..3069e1989 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4.2.2 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@v4 @@ -24,7 +26,7 @@ jobs: - name: Get Playwright version id: playwright-version - run: echo "version=$(node -p "require('./package-lock.json').packages['node_modules/playwright'].version")" >> "$GITHUB_OUTPUT" + run: echo "version=$(npx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers uses: actions/cache@v4 From 3fc1d750fd0e8cf77a1b42e69caa74083b3d54dd Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 06:21:40 +0200 Subject: [PATCH 10/34] test: use extends: true for Vitest projects, guard clipboard spy target --- src/lib/components/CopyButton.svelte.test.ts | 13 ++++++++++++- vite.config.ts | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/components/CopyButton.svelte.test.ts b/src/lib/components/CopyButton.svelte.test.ts index 7ceb0a8e1..fde21f68a 100644 --- a/src/lib/components/CopyButton.svelte.test.ts +++ b/src/lib/components/CopyButton.svelte.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-svelte"; import CopyButton from "./CopyButton.svelte"; @@ -6,6 +6,17 @@ import CopyButton from "./CopyButton.svelte"; // grant clipboard-read permission, and the component's contract is "calls // navigator.clipboard.writeText and flips to the copied state". describe("CopyButton", () => { + beforeAll(() => { + // navigator.clipboard only exists in secure contexts; provide a minimal + // stand-in when absent so the spies below always have a target. + if (!("clipboard" in navigator)) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: async () => {} }, + configurable: true, + }); + } + }); + beforeEach(() => { vi.restoreAllMocks(); }); diff --git a/vite.config.ts b/vite.config.ts index 24d8a7176..6f556618a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,7 +62,7 @@ export default defineConfig(({ mode }) => { test: { projects: [ { - extends: "./vite.config.ts", + extends: true, test: { name: "server", environment: "node", @@ -71,7 +71,7 @@ export default defineConfig(({ mode }) => { }, }, { - extends: "./vite.config.ts", + extends: true, // Pre-bundle the component-test dependency graph: on a cold Vite cache // (every CI runner) a mid-run dep-optimization reload can flake the suite. optimizeDeps: { From 4366705035fd70c540b1c3234d9c587f88d3d493 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 14:59:07 +0200 Subject: [PATCH 11/34] feat: add FormatValue formatter with per-monitor value display config --- migrations/20250111153517_init.ts | 1 + src/lib/clientTools.test.ts | 89 +++++++++++++++++++ src/lib/clientTools.ts | 58 +++++++++--- src/lib/server/types/db.ts | 6 ++ src/lib/server/types/monitor.ts | 1 + .../v4/monitors/[monitor_tag]/data/+server.ts | 2 + .../[monitor_tag]/data/[timestamp]/+server.ts | 2 + 7 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 src/lib/clientTools.test.ts diff --git a/migrations/20250111153517_init.ts b/migrations/20250111153517_init.ts index 50a04bbc3..95f9f96a4 100644 --- a/migrations/20250111153517_init.ts +++ b/migrations/20250111153517_init.ts @@ -6,6 +6,7 @@ export async function up(knex: Knex): Promise { table.string("monitor_tag", 255).notNullable(); table.integer("timestamp").notNullable(); table.text("status"); + // TODO(next-major): rename column latency -> value. Stores any numeric metric, not just milliseconds. table.float("latency", 8, 2); table.text("type"); table.primary(["monitor_tag", "timestamp"]); diff --git a/src/lib/clientTools.test.ts b/src/lib/clientTools.test.ts new file mode 100644 index 000000000..e0fc076b7 --- /dev/null +++ b/src/lib/clientTools.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { FormatValue, IsCustomUnit, ParseLatency } from "./clientTools"; + +describe("FormatValue - legacy latency path (no config or unit 'ms')", () => { + it("is byte-identical to the old ParseLatency behavior", () => { + expect(FormatValue(0)).toBe(""); + expect(FormatValue(null)).toBe(""); + expect(FormatValue(undefined)).toBe(""); + expect(FormatValue(42)).toBe("42ms"); + expect(FormatValue(999.4)).toBe("999ms"); + expect(FormatValue(999.6)).toBe("1000ms"); + expect(FormatValue(1000)).toBe("1.00s"); + expect(FormatValue(59999)).toBe("60.00s"); + expect(FormatValue(60000)).toBe("1.00m"); + expect(FormatValue(3599999)).toBe("60.00m"); + expect(FormatValue(3600000)).toBe("1.00h"); + }); + + it("treats an explicit unit 'ms' as the legacy path", () => { + expect(FormatValue(0, { unit: "ms" })).toBe(""); + expect(FormatValue(1500, { unit: "ms" })).toBe("1.50s"); + // decimals is ignored on the legacy path + expect(FormatValue(42, { unit: "ms", decimals: 2 })).toBe("42ms"); + }); + + it("keeps ParseLatency working as a deprecated alias", () => { + expect(ParseLatency(42)).toBe("42ms"); + expect(ParseLatency(0)).toBe(""); + }); +}); + +describe("FormatValue - custom units", () => { + it("appends the unit with a space and no auto-scaling", () => { + expect(FormatValue(42, { unit: "items" })).toBe("42 items"); + expect(FormatValue(60000, { unit: "items" })).toBe("60000 items"); + }); + + it("renders 0 as a valid value", () => { + expect(FormatValue(0, { unit: "items" })).toBe("0 items"); + }); + + it("joins '%' and empty unit without a space", () => { + expect(FormatValue(97.3, { unit: "%" })).toBe("97.3%"); + expect(FormatValue(42, { unit: "" })).toBe("42"); + }); + + it("defaults to up to 2 decimals with trailing zeros trimmed", () => { + expect(FormatValue(3.456, { unit: "items" })).toBe("3.46 items"); + expect(FormatValue(3.5, { unit: "items" })).toBe("3.5 items"); + expect(FormatValue(3.0, { unit: "items" })).toBe("3 items"); + }); + + it("uses exact decimals when set", () => { + expect(FormatValue(42, { unit: "items", decimals: 2 })).toBe("42.00 items"); + expect(FormatValue(3.456, { unit: "items", decimals: 0 })).toBe("3 items"); + }); + + it("clamps out-of-range decimals and treats invalid decimals as unset", () => { + expect(FormatValue(3.45678, { unit: "items", decimals: 9 })).toBe("3.4568 items"); // clamped to 4 + expect(FormatValue(3.456, { unit: "items", decimals: -1 })).toBe("3 items"); // clamped to 0 + expect(FormatValue(3.456, { unit: "items", decimals: Number.NaN })).toBe("3.46 items"); // invalid -> auto + }); + + it("does not use thousands grouping and uses '.' as decimal separator", () => { + expect(FormatValue(1234567.5, { unit: "items" })).toBe("1234567.5 items"); + }); + + it("returns empty string for null/undefined/NaN", () => { + expect(FormatValue(null, { unit: "items" })).toBe(""); + expect(FormatValue(undefined, { unit: "items" })).toBe(""); + expect(FormatValue(Number.NaN, { unit: "items" })).toBe(""); + }); +}); + +describe("IsCustomUnit", () => { + it("is false for absent config, absent unit, or 'ms'", () => { + expect(IsCustomUnit(undefined)).toBe(false); + expect(IsCustomUnit(null)).toBe(false); + expect(IsCustomUnit({})).toBe(false); + expect(IsCustomUnit({ name: "Queue length" })).toBe(false); + expect(IsCustomUnit({ unit: "ms" })).toBe(false); + }); + + it("is true for any present non-'ms' unit including empty string", () => { + expect(IsCustomUnit({ unit: "items" })).toBe(true); + expect(IsCustomUnit({ unit: "%" })).toBe(true); + expect(IsCustomUnit({ unit: "" })).toBe(true); + }); +}); diff --git a/src/lib/clientTools.ts b/src/lib/clientTools.ts index 08d0eabb7..c1077f064 100644 --- a/src/lib/clientTools.ts +++ b/src/lib/clientTools.ts @@ -1,19 +1,53 @@ -import type { TimestampStatusCount } from "$lib/server/types/db"; +import type { MonitorValueDisplay, TimestampStatusCount } from "$lib/server/types/db"; import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants"; -function ParseLatency(latencyMs: number): string { - if (!!!latencyMs) { +function IsCustomUnit(display?: MonitorValueDisplay | null): boolean { + return typeof display?.unit === "string" && display.unit !== "ms"; +} + +function clampDecimals(d: unknown): number | undefined { + if (typeof d !== "number" || !Number.isFinite(d)) return undefined; + return Math.min(4, Math.max(0, Math.round(d))); +} + +function FormatValue(value: number | null | undefined, display?: MonitorValueDisplay | null): string { + if (!IsCustomUnit(display)) { + // Legacy latency path - must stay byte-identical to the historical ParseLatency. + const latencyMs = value as number; + if (!!!latencyMs) { + return ""; + } + if (latencyMs < 1000) { + return `${Math.round(latencyMs)}ms`; + } else if (latencyMs < 60000) { + return `${(latencyMs / 1000).toFixed(2)}s`; + } else if (latencyMs < 3600000) { + return `${(latencyMs / 60000).toFixed(2)}m`; + } else { + return `${(latencyMs / 3600000).toFixed(2)}h`; + } + } + if (value === null || value === undefined || Number.isNaN(value)) { return ""; } - if (latencyMs < 1000) { - return `${Math.round(latencyMs)}ms`; - } else if (latencyMs < 60000) { - return `${(latencyMs / 1000).toFixed(2)}s`; - } else if (latencyMs < 3600000) { - return `${(latencyMs / 60000).toFixed(2)}m`; - } else { - return `${(latencyMs / 3600000).toFixed(2)}h`; + const decimals = clampDecimals(display?.decimals); + // Locale pinned so strings formatted on the server (badges, monitor-bar) match the client. + const formatted = new Intl.NumberFormat("en-US", { + useGrouping: false, + ...(decimals === undefined + ? { maximumFractionDigits: 2 } // "auto": up to 2 fraction digits, trailing zeros trimmed + : { minimumFractionDigits: decimals, maximumFractionDigits: decimals }), + }).format(value); + const unit = display?.unit ?? ""; + if (unit === "" || unit === "%") { + return `${formatted}${unit}`; } + return `${formatted} ${unit}`; +} + +/** @deprecated Use FormatValue. TODO(next-major): rename latency -> value and remove this alias. */ +function ParseLatency(latencyMs: number): string { + return FormatValue(latencyMs); } function siteDataExtractFromDb(data: Record, obj: Record): Record { @@ -405,6 +439,8 @@ export { GetStatusSummary, GetStatusColor, GetStatusBgColor, + FormatValue, + IsCustomUnit, ParseLatency, GetInitials, }; diff --git a/src/lib/server/types/db.ts b/src/lib/server/types/db.ts index 8cac40cb8..a18c7c703 100644 --- a/src/lib/server/types/db.ts +++ b/src/lib/server/types/db.ts @@ -91,6 +91,11 @@ export interface MonitorSharingOptions { showShareBadgeMonitor: boolean; showShareEmbedMonitor: boolean; } +export interface MonitorValueDisplay { + name?: string; + unit?: string; + decimals?: number; +} export interface MonitorSettings { uptime_formula_numerator?: string; uptime_formula_denominator?: string; @@ -99,6 +104,7 @@ export interface MonitorSettings { mobile: number; }; sharing_options?: MonitorSharingOptions; + value_display?: MonitorValueDisplay; } export interface TimestampStatusCount { diff --git a/src/lib/server/types/monitor.ts b/src/lib/server/types/monitor.ts index aa14210de..6f07562c3 100644 --- a/src/lib/server/types/monitor.ts +++ b/src/lib/server/types/monitor.ts @@ -5,6 +5,7 @@ import type { TcpHost, TcpMonitorTypeData } from "$lib/types/tcp.js"; export interface MonitoringResult { status: string; + // TODO(next-major): rename latency -> value (generic metric); kept for backwards compatibility. latency: number; type: string; error_message?: string; diff --git a/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/+server.ts b/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/+server.ts index 639f29046..9503fed5b 100644 --- a/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/+server.ts +++ b/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/+server.ts @@ -13,6 +13,8 @@ import { GetMinuteStartTimestampUTC } from "$lib/server/tool"; import { SetLastMonitoringValue } from "$lib/server/cache/setGet"; import alertingQueue from "$lib/server/queues/alertingQueue"; +// TODO(next-major): accept/return "value" alongside deprecated "latency". + export const GET: RequestHandler = async ({ locals, url }) => { // Monitor is validated by middleware and available in locals const monitor = locals.monitor!; diff --git a/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/[timestamp]/+server.ts b/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/[timestamp]/+server.ts index 5342d7692..9b802c81f 100644 --- a/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/[timestamp]/+server.ts +++ b/src/routes/(api)/api/v4/monitors/[monitor_tag]/data/[timestamp]/+server.ts @@ -12,6 +12,8 @@ import { GetMinuteStartTimestampUTC } from "$lib/server/tool"; import { SetLastMonitoringValue } from "$lib/server/cache/setGet"; import alertingQueue from "$lib/server/queues/alertingQueue"; +// TODO(next-major): accept/return "value" alongside deprecated "latency". + export const GET: RequestHandler = async ({ params, locals }) => { // Monitor is validated by middleware and available in locals const monitor = locals.monitor!; From 6d80939939c87e0f2bd493c011f12f50dec995a6 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 15:10:49 +0200 Subject: [PATCH 12/34] feat: thread value display config through server-side formatting --- src/lib/server/api-server/monitor-bar/get.ts | 3 +- .../server/api-server/monitor-bar/shared.ts | 5 +++- .../api-server/monitor-day-latency/post.ts | 14 +++++---- .../server/controllers/monitorsController.ts | 1 + src/lib/server/tool.test.ts | 29 +++++++++++++++++++ src/lib/server/tool.ts | 21 +++++++++----- 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/lib/server/api-server/monitor-bar/get.ts b/src/lib/server/api-server/monitor-bar/get.ts index 37d258d67..6c6c65db9 100644 --- a/src/lib/server/api-server/monitor-bar/get.ts +++ b/src/lib/server/api-server/monitor-bar/get.ts @@ -3,7 +3,7 @@ import type { APIServerRequest } from "$lib/server/types/api-server"; import db from "$lib/server/db/db"; import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool"; import type { StatusType } from "$lib/global-constants"; -import type { TimestampStatusCount } from "$lib/server/types/db"; +import type { MonitorValueDisplay, TimestampStatusCount } from "$lib/server/types/db"; import { buildMonitorBarResponse } from "./shared"; const DEFAULT_DAYS = 90; @@ -26,6 +26,7 @@ export interface MonitorBarResponse { toTimeStamp: number; maxLatency: string; minLatency: string; + value_display?: MonitorValueDisplay | null; } /** diff --git a/src/lib/server/api-server/monitor-bar/shared.ts b/src/lib/server/api-server/monitor-bar/shared.ts index 4e5bac644..c3f5e278a 100644 --- a/src/lib/server/api-server/monitor-bar/shared.ts +++ b/src/lib/server/api-server/monitor-bar/shared.ts @@ -1,12 +1,13 @@ import db from "$lib/server/db/db"; import GC, { type StatusType } from "$lib/global-constants"; -import type { MonitorRecord, TimestampStatusCount } from "$lib/server/types/db"; +import type { MonitorRecord, MonitorValueDisplay, TimestampStatusCount } from "$lib/server/types/db"; import { UptimeCalculator } from "$lib/server/tool"; import type { MonitorBarResponse } from "./get"; interface ParsedMonitorSettings { uptime_formula_numerator?: string; uptime_formula_denominator?: string; + value_display?: MonitorValueDisplay; } const parseMonitorSettings = (value: string | null): ParsedMonitorSettings => { @@ -83,6 +84,7 @@ export const buildMonitorBarResponseFromRawData = ( rawUptimeData, monitorSettings.uptime_formula_numerator, monitorSettings.uptime_formula_denominator, + monitorSettings.value_display, ); return { @@ -97,5 +99,6 @@ export const buildMonitorBarResponseFromRawData = ( avgLatency: uptimeCalculationResult.avgLatency, maxLatency: uptimeCalculationResult.maxLatency, minLatency: uptimeCalculationResult.minLatency, + value_display: monitorSettings.value_display ?? null, }; }; diff --git a/src/lib/server/api-server/monitor-day-latency/post.ts b/src/lib/server/api-server/monitor-day-latency/post.ts index 3d78efbf3..b1b31a2cd 100644 --- a/src/lib/server/api-server/monitor-day-latency/post.ts +++ b/src/lib/server/api-server/monitor-day-latency/post.ts @@ -3,7 +3,7 @@ import type { APIServerRequest } from "$lib/server/types/api-server"; import db from "$lib/server/db/db"; import { BeginningOfDay, GetMinuteStartNowTimestampUTC, GetMinuteStartTimestampUTC } from "$lib/server/tool"; import { GetMonitorsParsed } from "../../controllers/monitorsController"; -import { ParseLatency } from "$lib/clientTools"; +import { FormatValue, IsCustomUnit } from "$lib/clientTools"; interface DayDetailRequest { tag: string; @@ -116,6 +116,8 @@ export default async function post(req: APIServerRequest): Promise { return error(404, { message: "Monitor not found" }); } const monitor = monitors[0]; + const valueDisplay = monitor.monitor_settings_json?.value_display; + const customUnit = IsCustomUnit(valueDisplay); // Get raw monitoring data for the day const rawData = await db.getMonitoringData(monitor.tag, startOfDayTodayAtTz, nowAtTz); @@ -128,7 +130,9 @@ export default async function post(req: APIServerRequest): Promise { const validData: MinuteData[] = []; for (const d of rawData) { const currentLatency = d.latency || 0; - if (currentLatency > 0) { + // Custom units: 0 is a real reading; only NULL rows are "no data". + const include = customUnit ? d.latency !== null && d.latency !== undefined : currentLatency > 0; + if (include) { validData.push({ timestamp: d.timestamp, latency: currentLatency, @@ -165,8 +169,8 @@ export default async function post(req: APIServerRequest): Promise { return json({ minutes: minuteData, - avgLatency: ParseLatency(totalCount > 0 ? totalLatencySum / totalCount : 0), - maxLatency: ParseLatency(maxLatency), - minLatency: ParseLatency(minLatency), + avgLatency: FormatValue(totalCount > 0 ? totalLatencySum / totalCount : 0, valueDisplay), + maxLatency: FormatValue(maxLatency, valueDisplay), + minLatency: FormatValue(minLatency, valueDisplay), }); } diff --git a/src/lib/server/controllers/monitorsController.ts b/src/lib/server/controllers/monitorsController.ts index 65dd6de6a..382d9d8ab 100644 --- a/src/lib/server/controllers/monitorsController.ts +++ b/src/lib/server/controllers/monitorsController.ts @@ -659,6 +659,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi stats, m.monitor_settings_json?.uptime_formula_numerator, m.monitor_settings_json?.uptime_formula_denominator, + m.monitor_settings_json?.value_display, ); } diff --git a/src/lib/server/tool.test.ts b/src/lib/server/tool.test.ts index 6540ea45d..e1b6b2941 100644 --- a/src/lib/server/tool.test.ts +++ b/src/lib/server/tool.test.ts @@ -218,6 +218,35 @@ describe("UptimeCalculator", () => { expect(result.uptime).toBe("100"); expect(result.avgLatency).toBe("100ms"); // (0 + 100) / 1 entry with latency > 0 }); + + it("formats latency strings with a custom unit", () => { + const data = [day({ countOfUp: 10, avgLatency: 42, maxLatency: 50, minLatency: 30 })]; + const result = UptimeCalculator(data, undefined, undefined, { unit: "items" }); + expect(result.avgLatency).toBe("42 items"); + expect(result.maxLatency).toBe("50 items"); + expect(result.minLatency).toBe("30 items"); + }); + + it("counts zero-average days as data for custom units", () => { + // Day with monitoring activity (countOfUp > 0) but an average value of 0 (empty queue). + const data = [ + day({ countOfUp: 10, avgLatency: 0, maxLatency: 0, minLatency: 0 }), + day({ countOfUp: 10, avgLatency: 10, maxLatency: 10, minLatency: 10 }), + ]; + const result = UptimeCalculator(data, undefined, undefined, { unit: "items" }); + expect(result.avgLatency).toBe("5 items"); // (0 + 10) / 2 days with data + expect(result.minLatency).toBe("0 items"); + expect(result.maxLatency).toBe("10 items"); + }); + + it("still hides zero-average days on the legacy ms path", () => { + const data = [ + day({ countOfUp: 10, avgLatency: 0 }), + day({ countOfUp: 10, avgLatency: 10, maxLatency: 10, minLatency: 10 }), + ]; + const result = UptimeCalculator(data); + expect(result.avgLatency).toBe("10ms"); // zero day excluded from the average + }); }); describe("ValidateIpAddress", () => { diff --git a/src/lib/server/tool.ts b/src/lib/server/tool.ts index d63ff8f86..c69f282a8 100644 --- a/src/lib/server/tool.ts +++ b/src/lib/server/tool.ts @@ -2,9 +2,10 @@ import { AllRecordTypes } from "../clientTools.js"; import knexOb from "../../../knexfile.js"; import crypto from "crypto"; import GC from "../global-constants.js"; -import { ParseLatency } from "$lib/clientTools.js"; +import { FormatValue, IsCustomUnit, ParseLatency } from "$lib/clientTools.js"; import dotenv from "dotenv"; import type { TimestampStatusCount, UptimeCalculatorResult } from "./db/dbimpl.js"; +import type { MonitorValueDisplay } from "$lib/server/types/db.js"; dotenv.config(); const IsValidURL = function (url: string): boolean { return /^(http|https):\/\/[^ "]+$/.test(url); @@ -474,6 +475,7 @@ function UptimeCalculator( data: TimestampStatusCount[], numeratorStr?: string, denominatorStr?: string, + valueDisplay?: MonitorValueDisplay, ): UptimeCalculatorResult { let up = 0; let degraded = 0; @@ -483,6 +485,7 @@ function UptimeCalculator( let latencyCount = 0; let maxLatency = -Infinity; let minLatency = Infinity; + const customUnit = IsCustomUnit(valueDisplay); for (let i = 0; i < data.length; i++) { const element = data[i]; @@ -492,14 +495,18 @@ function UptimeCalculator( maintenance += element.countOfMaintenance; latencySum += element.avgLatency; - if (element.avgLatency > 0) { + // Custom units treat 0 as a real reading; a day "has data" when any checks ran. + const dayHasData = + element.countOfUp + element.countOfDown + element.countOfDegraded + element.countOfMaintenance > 0; + + if (customUnit ? dayHasData : element.avgLatency > 0) { latencyCount += 1; } - if (!!element.maxLatency && element.maxLatency > maxLatency) { + if ((customUnit ? dayHasData : !!element.maxLatency) && element.maxLatency > maxLatency) { maxLatency = element.maxLatency; } - if (!!element.minLatency && element.minLatency < minLatency) { + if ((customUnit ? dayHasData : !!element.minLatency) && element.minLatency < minLatency) { minLatency = element.minLatency; } } @@ -529,9 +536,9 @@ function UptimeCalculator( const denominator = SafeEvaluateExpression(denominatorExpr); return { uptime: ParseUptime(numerator, denominator), - avgLatency: latencyCount > 0 ? ParseLatency(latencySum / latencyCount) : "", - maxLatency: latencyCount > 0 && maxLatency !== -Infinity ? ParseLatency(maxLatency) : "", - minLatency: latencyCount > 0 && minLatency !== Infinity ? ParseLatency(minLatency) : "", + avgLatency: latencyCount > 0 ? FormatValue(latencySum / latencyCount, valueDisplay) : "", + maxLatency: latencyCount > 0 && maxLatency !== -Infinity ? FormatValue(maxLatency, valueDisplay) : "", + minLatency: latencyCount > 0 && minLatency !== Infinity ? FormatValue(minLatency, valueDisplay) : "", }; } From f4af55d479e67d22aae5acfe2a29fd275ce78fef Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 15:23:15 +0200 Subject: [PATCH 13/34] fix: gate day-latency no-data as empty, format last-latency with value_display --- src/lib/server/api-server/monitor-day-latency/post.ts | 6 +++--- src/lib/server/tool.ts | 2 +- src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/server/api-server/monitor-day-latency/post.ts b/src/lib/server/api-server/monitor-day-latency/post.ts index b1b31a2cd..4a821a30b 100644 --- a/src/lib/server/api-server/monitor-day-latency/post.ts +++ b/src/lib/server/api-server/monitor-day-latency/post.ts @@ -169,8 +169,8 @@ export default async function post(req: APIServerRequest): Promise { return json({ minutes: minuteData, - avgLatency: FormatValue(totalCount > 0 ? totalLatencySum / totalCount : 0, valueDisplay), - maxLatency: FormatValue(maxLatency, valueDisplay), - minLatency: FormatValue(minLatency, valueDisplay), + avgLatency: totalCount > 0 ? FormatValue(totalLatencySum / totalCount, valueDisplay) : "", + maxLatency: totalCount > 0 ? FormatValue(maxLatency, valueDisplay) : "", + minLatency: totalCount > 0 ? FormatValue(minLatency, valueDisplay) : "", }); } diff --git a/src/lib/server/tool.ts b/src/lib/server/tool.ts index c69f282a8..617ad93b6 100644 --- a/src/lib/server/tool.ts +++ b/src/lib/server/tool.ts @@ -2,7 +2,7 @@ import { AllRecordTypes } from "../clientTools.js"; import knexOb from "../../../knexfile.js"; import crypto from "crypto"; import GC from "../global-constants.js"; -import { FormatValue, IsCustomUnit, ParseLatency } from "$lib/clientTools.js"; +import { FormatValue, IsCustomUnit } from "$lib/clientTools.js"; import dotenv from "dotenv"; import type { TimestampStatusCount, UptimeCalculatorResult } from "./db/dbimpl.js"; import type { MonitorValueDisplay } from "$lib/server/types/db.js"; diff --git a/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts b/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts index 7ea9d26f5..c4b8ecb5a 100644 --- a/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts +++ b/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts @@ -11,7 +11,7 @@ import { import type { TimestampStatusCount } from "$lib/server/types/db"; import { GetNowTimestampUTC, UptimeCalculator } from "$lib/server/tool"; import GC from "$lib/global-constants.js"; -import { GetStatusColor, GetStatusSummary, ParseLatency } from "$lib/clientTools"; +import { FormatValue, GetStatusColor, GetStatusSummary } from "$lib/clientTools"; import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController"; import type { GroupMonitorTypeData } from "$lib/server/types/monitor"; @@ -106,7 +106,7 @@ export const load: PageServerLoad = async ({ params, parent }) => { monitorLastStatus: GetStatusSummary(item), textClass: GetStatusColor(item), monitorLastStatusTimestamp: item.ts, - monitorLastLatency: ParseLatency(item.avgLatency), + monitorLastLatency: FormatValue(item.avgLatency, monitor.monitor_settings_json?.value_display), ongoingIncidents, ongoingMaintenances, upcomingMaintenances, From 9c39b95ac8c5621d58804515d79271c51e6daf13 Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 15:26:47 +0200 Subject: [PATCH 14/34] fix: last-latency shows blank for missing readings on custom-unit monitors --- src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts b/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts index c4b8ecb5a..946746f13 100644 --- a/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts +++ b/src/routes/(kener)/monitors/[monitor_tag]/+page.server.ts @@ -106,7 +106,7 @@ export const load: PageServerLoad = async ({ params, parent }) => { monitorLastStatus: GetStatusSummary(item), textClass: GetStatusColor(item), monitorLastStatusTimestamp: item.ts, - monitorLastLatency: FormatValue(item.avgLatency, monitor.monitor_settings_json?.value_display), + monitorLastLatency: FormatValue(lastStatus?.latency ?? null, monitor.monitor_settings_json?.value_display), ongoingIncidents, ongoingMaintenances, upcomingMaintenances, From 4738e3bf1fc0a444b8f5c9ee1bba6fefb060421a Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 15:30:47 +0200 Subject: [PATCH 15/34] feat: interpolate i18n fallback keys, add value-display label keys --- src/lib/locales/en.json | 7 +++++++ src/lib/stores/i18n.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/stores/i18n.ts | 17 ++++++++++------- 3 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/lib/stores/i18n.test.ts diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index ad44c3a4b..25efb6d4f 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -1,10 +1,14 @@ { "name": "English", "mappings": { + "%latency %metric %name": "%latency %metric %name", "%latency %metric latency": "%latency %metric latency", + "%name Over Time": "%name Over Time", + "%name Trend": "%name Trend", "Affected Monitors (%count)": "Affected Monitors (%count)", "All Systems Operational": "All Systems Operational", "Average Latency": "Average Latency", + "Avg %name": "Avg %name", "Avg Latency": "Avg Latency", "Back": "Back", "Badges": "Badges", @@ -64,8 +68,10 @@ "Major System Outage": "Major System Outage", "Manage Site": "Manage Site", "Manage your notification preferences.": "Manage your notification preferences.", + "Max %name": "Max %name", "Max Latency": "Max Latency", "Maximum Latency": "Max Latency", + "Min %name": "Min %name", "Min Latency": "Min Latency", "Minimum Latency": "Min Latency", "Minute-by-minute status data for this day": "Minute-by-minute status data for this day", @@ -107,6 +113,7 @@ "Script": "Script", "Select Language": "Select Language", "Select latency metric to display": "Select latency metric to display", + "Select metric to display": "Select metric to display", "Select Range": "Select Range", "Sending...": "Sending...", "Standard": "Standard", diff --git a/src/lib/stores/i18n.test.ts b/src/lib/stores/i18n.test.ts new file mode 100644 index 000000000..6d82fbc52 --- /dev/null +++ b/src/lib/stores/i18n.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { get } from "svelte/store"; +import { t } from "./i18n"; + +describe("t placeholder interpolation", () => { + it("replaces placeholders in translated keys", () => { + // Default store state has no translations loaded; this exercises the fallback path below. + const translate = get(t); + // Silence console.warn for this test as it intentionally exercises the missing-translation path + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(translate("Hello %name", { name: "World" })).toBe("Hello World"); + warnSpy.mockRestore(); + }); + + it("replaces placeholders even when the key has no translation (fallback)", () => { + const translate = get(t); + // Silence console.warn for this test as it intentionally exercises the missing-translation path + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(translate("Avg %name", { name: "Queue length" })).toBe("Avg Queue length"); + warnSpy.mockRestore(); + }); + + it("leaves unknown placeholders untouched", () => { + const translate = get(t); + // Silence console.warn for this test as it intentionally exercises the missing-translation path + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(translate("Avg %name", {})).toBe("Avg %name"); + warnSpy.mockRestore(); + }); +}); diff --git a/src/lib/stores/i18n.ts b/src/lib/stores/i18n.ts index b8b64282b..e1f5a5e64 100644 --- a/src/lib/stores/i18n.ts +++ b/src/lib/stores/i18n.ts @@ -177,19 +177,22 @@ export const t = derived(i18n, ($i18n) => { try { let str = $i18n.translations[key]; - // Replace placeholders in the string using the args object - if (str && typeof str === "string") { + //warn if missing translation + if (!str) { + console.warn(`Missing translation for key: "${key}"`); + } + + // Fall back to the key itself, then replace placeholders either way so + // interpolated keys (e.g. "Avg %name") degrade gracefully in locales + // that don't carry them yet. + str = str || key; + if (typeof str === "string") { str = str.replace(/%\w+/g, (placeholder) => { const argKey = placeholder.slice(1); // Remove the `%` to get the key return args[argKey] !== undefined ? args[argKey] : placeholder; }); } - //warn if missing translation - if (!str) { - console.warn(`Missing translation for key: "${key}"`); - } - return str || key; } catch (e) { return key; From b160cecb078d68141a435d50ba172c6a842d1efa Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Tue, 14 Jul 2026 15:44:43 +0200 Subject: [PATCH 16/34] feat: render custom metric units on status page components --- src/lib/components/LatencyTrendChart.svelte | 10 ++-- src/lib/components/MonitorBar.svelte | 8 ++- src/lib/components/MonitorDayDetail.svelte | 32 ++++++---- src/lib/components/MonitorOverview.svelte | 60 +++++++++++++------ src/lib/components/StatusBarCalendar.svelte | 27 +++++++-- .../StatusBarCalendar.svelte.test.ts | 36 +++++++++++ 6 files changed, 131 insertions(+), 42 deletions(-) diff --git a/src/lib/components/LatencyTrendChart.svelte b/src/lib/components/LatencyTrendChart.svelte index 49b99d045..a48de5f4a 100644 --- a/src/lib/components/LatencyTrendChart.svelte +++ b/src/lib/components/LatencyTrendChart.svelte @@ -4,9 +4,10 @@ import { scaleTime } from "d3-scale"; import * as Chart from "$lib/components/ui/chart/index.js"; import { t } from "$lib/stores/i18n"; - import { ParseLatency } from "$lib/clientTools"; + import { FormatValue, IsCustomUnit } from "$lib/clientTools"; import { formatDate } from "$lib/stores/datetime"; import { page } from "$app/state"; + import type { MonitorValueDisplay } from "$lib/server/types/db"; interface ChartPoint { date: Date; @@ -18,9 +19,10 @@ label?: string; height?: number; class?: string; + valueDisplay?: MonitorValueDisplay | null; } - let { data, label = $t("Avg Latency"), height = 128, class: className = "" }: Props = $props(); + let { data, label = $t("Avg Latency"), height = 128, class: className = "", valueDisplay = null }: Props = $props(); // Chart config let chartConfig = $derived({ @@ -31,7 +33,7 @@ } satisfies Chart.ChartConfig); // Filter out zero values - let chartData = $derived(data.filter((d) => d.value > 0)); + let chartData = $derived(IsCustomUnit(valueDisplay) ? data : data.filter((d) => d.value > 0));
@@ -89,7 +91,7 @@ >
- {ParseLatency(Number(value))} + {FormatValue(Number(value), valueDisplay)}
diff --git a/src/lib/components/MonitorBar.svelte b/src/lib/components/MonitorBar.svelte index 332bd2809..8906aef1f 100644 --- a/src/lib/components/MonitorBar.svelte +++ b/src/lib/components/MonitorBar.svelte @@ -133,7 +133,13 @@ {#if !compact}
- +

{$formatDate(new Date(data.fromTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)} diff --git a/src/lib/components/MonitorDayDetail.svelte b/src/lib/components/MonitorDayDetail.svelte index cdc1606ef..72a177443 100644 --- a/src/lib/components/MonitorDayDetail.svelte +++ b/src/lib/components/MonitorDayDetail.svelte @@ -16,10 +16,14 @@ import MaintenanceItem from "$lib/components/MaintenanceItem.svelte"; import MinuteGrid from "$lib/components/MinuteGrid.svelte"; import clientResolver from "$lib/client/resolver.js"; - import { ParseLatency } from "$lib/clientTools"; + import { FormatValue, IsCustomUnit } from "$lib/clientTools"; import * as Chart from "$lib/components/ui/chart/index.js"; - import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db"; + import type { + IncidentForMonitorListWithComments, + MaintenanceEventsMonitorList, + MonitorValueDisplay + } from "$lib/server/types/db"; import { formatDate } from "$lib/stores/datetime"; import trackEvent from "$lib/beacon"; @@ -46,9 +50,13 @@ timestamp: number; status: string; } | null; + valueDisplay?: MonitorValueDisplay | null; } - let { open = $bindable(), monitorTag, selectedDay }: Props = $props(); + let { open = $bindable(), monitorTag, selectedDay, valueDisplay = null }: Props = $props(); + + const customUnit = $derived(IsCustomUnit(valueDisplay)); + const customName = $derived(valueDisplay?.name?.trim() || ""); let loading = $state(false); let latencyLoading = $state(false); @@ -61,18 +69,18 @@ let dayIncidentsData = $state([]); let dayMaintenancesData = $state([]); // Chart config for latency - const chartConfig = { + const chartConfig = $derived({ latency: { - label: "Latency", + label: customName || "Latency", color: "var(--chart-1)" } - } satisfies Chart.ChartConfig; + } satisfies Chart.ChartConfig); // Transform latency data for chart let chartData = $derived.by(() => { if (!dayLatencyData?.minutes) return []; return dayLatencyData.minutes - .filter((d) => d.latency > 0) + .filter((d) => (customUnit ? d.latency !== null && d.latency !== undefined : d.latency > 0)) .map((d) => ({ date: new Date(d.timestamp * 1000), latency: d.latency @@ -226,7 +234,7 @@ >{$t("Status")} {$t("Latency")}{customName || $t("Latency")} {$t("Incidents")}

-

{$t("Latency Over Time")}

+

{customName ? $t("%name Over Time", { name: customName }) : $t("Latency Over Time")}

@@ -280,7 +288,7 @@ {dayLatencyData.avgLatency} -

{$t("Average Latency")}

+

{customName ? $t("Avg %name", { name: customName }) : $t("Average Latency")}

@@ -300,7 +308,7 @@ series={[ { key: "latency", - label: $t("Latency"), + label: customName || $t("Latency"), color: "var(--color-latency)" } ]} @@ -343,7 +351,7 @@
- {ParseLatency(Math.round(Number(value)))} + {FormatValue(customUnit ? Number(value) : Math.round(Number(value)), valueDisplay)}
diff --git a/src/lib/components/MonitorOverview.svelte b/src/lib/components/MonitorOverview.svelte index c6ee111f5..41aa3c5f8 100644 --- a/src/lib/components/MonitorOverview.svelte +++ b/src/lib/components/MonitorOverview.svelte @@ -18,6 +18,8 @@ import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js"; import GroupMonitorPopover from "$lib/components/GroupMonitorPopover.svelte"; import { page } from "$app/state"; + import { IsCustomUnit } from "$lib/clientTools"; + import type { MonitorValueDisplay } from "$lib/server/types/db"; interface Props { monitorTag: string; @@ -68,20 +70,34 @@ // Data for calendar/chart comes directly from API let displayData = $derived(overviewData?.uptimeData ?? []); + let valueDisplay = $derived(overviewData?.value_display ?? null); + let customUnit = $derived(IsCustomUnit(valueDisplay)); + let customName = $derived(valueDisplay?.name?.trim() || ""); + // Latency metric label map - const metricLabels: Record = { - average: $t("Avg Latency"), - maximum: $t("Max Latency"), - minimum: $t("Min Latency") - }; + let metricLabels = $derived>( + customName + ? { + average: $t("Avg %name", { name: customName }), + maximum: $t("Max %name", { name: customName }), + minimum: $t("Min %name", { name: customName }) + } + : { + average: $t("Avg Latency"), + maximum: $t("Max Latency"), + minimum: $t("Min Latency") + } + ); // Transform uptimeData into chart-ready points based on selected metric let latencyChartData = $derived.by(() => { if (!displayData) return []; - return displayData.map((d) => ({ - date: new Date(d.ts * 1000), - value: latencyMetric === "maximum" ? d.maxLatency : latencyMetric === "minimum" ? d.minLatency : d.avgLatency - })); + return displayData + .filter((d) => !customUnit || d.countOfUp + d.countOfDown + d.countOfDegraded + d.countOfMaintenance > 0) + .map((d) => ({ + date: new Date(d.ts * 1000), + value: latencyMetric === "maximum" ? d.maxLatency : latencyMetric === "minimum" ? d.minLatency : d.avgLatency + })); }); let latencyChartLabel = $derived(metricLabels[latencyMetric] ?? metricLabels.average); @@ -199,7 +215,7 @@
- +
@@ -228,12 +244,12 @@

- {$t("Latency Trend")} + {customName ? $t("%name Trend", { name: customName }) : $t("Latency Trend")} {latencyChartLabel}

- {$t("Select latency metric to display")} + {customName ? $t("Select metric to display") : $t("Select latency metric to display")}

- {$t("Avg Latency")} - {$t("Max Latency")} - {$t("Min Latency")} + {metricLabels.average} + {metricLabels.maximum} + {metricLabels.minimum} @@ -257,19 +273,25 @@

{displayMinLatency}

-

{$t("Minimum Latency")}

+

+ {customName ? $t("Min %name", { name: customName }) : $t("Minimum Latency")} +

{displayAvgLatency}

-

{$t("Average Latency")}

+

+ {customName ? $t("Avg %name", { name: customName }) : $t("Average Latency")} +

{displayMaxLatency}

-

{$t("Maximum Latency")}

+

+ {customName ? $t("Max %name", { name: customName }) : $t("Maximum Latency")} +

- +
{/if} diff --git a/src/lib/components/StatusBarCalendar.svelte b/src/lib/components/StatusBarCalendar.svelte index b6fca57a8..5616bf01b 100644 --- a/src/lib/components/StatusBarCalendar.svelte +++ b/src/lib/components/StatusBarCalendar.svelte @@ -2,9 +2,9 @@ import { onMount } from "svelte"; import { mode } from "mode-watcher"; import { page } from "$app/state"; - import { GetStatusSummary, ParseLatency } from "$lib/clientTools"; + import { GetStatusSummary, FormatValue, IsCustomUnit } from "$lib/clientTools"; import MonitorDayDetail from "$lib/components/MonitorDayDetail.svelte"; - import type { TimestampStatusCount } from "$lib/server/types/db"; + import type { MonitorValueDisplay, TimestampStatusCount } from "$lib/server/types/db"; import { t } from "$lib/stores/i18n"; import { formatDate } from "$lib/stores/datetime"; import trackEvent from "$lib/beacon"; @@ -16,9 +16,24 @@ radius?: number; class?: string; disableClick?: boolean; + valueDisplay?: MonitorValueDisplay | null; } - let { data, monitorTag, barHeight = 40, radius = 8, class: className = "", disableClick = false }: Props = $props(); + let { + data, + monitorTag, + barHeight = 40, + radius = 8, + class: className = "", + disableClick = false, + valueDisplay = null + }: Props = $props(); + + const customUnit = $derived(IsCustomUnit(valueDisplay)); + + function dayHasData(d: TimestampStatusCount): boolean { + return d.countOfUp + d.countOfDown + d.countOfDegraded + d.countOfMaintenance > 0; + } // Canvas state let canvas = $state(null); @@ -376,16 +391,16 @@ {$t(GetStatusSummary(hoveredBar.data))} @ {$formatDate(hoveredBar.data.ts, page.data.dateAndTimeFormat.dateOnly)} - {#if hoveredBar.data.avgLatency > 0} + {#if customUnit ? dayHasData(hoveredBar.data) : hoveredBar.data.avgLatency > 0} | - {ParseLatency(hoveredBar.data.avgLatency)} + {FormatValue(hoveredBar.data.avgLatency, valueDisplay)} {/if}
{/if}
- +