diff --git a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs index 80e8426b..5399fb8b 100644 --- a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs +++ b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs @@ -262,7 +262,7 @@ private static IResult GetOptions( IdleWorkerTimeOut = schedulerOptions.IdleWorkerTimeOut, CurrentMachine = schedulerOptions.NodeIdentifier, LastHostExceptionMessage = executionContext.LastHostExceptionMessage, - SchedulerTimeZone = schedulerOptions.SchedulerTimeZone?.Id + SchedulerTimeZone = ToIanaTimeZoneId(schedulerOptions.SchedulerTimeZone) }, dashboardOptions.DashboardJsonOptions); } @@ -737,5 +737,24 @@ private static async Task GetMachineJobs( return Results.Json(machineJobs.Select(x => new TupleResponse { Item1 = x.Item1, Item2 = x.Item2 }).ToArray(), dashboardOptions.DashboardJsonOptions); } + internal static string? ToIanaTimeZoneId(TimeZoneInfo? timeZone) + { + if (timeZone == null) + return null; + + var id = timeZone.Id; + + // Already an IANA id (contains '/') + if (id.Contains('/') || id == "UTC") + return id; + + // Convert Windows timezone id to IANA + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId)) + return ianaId; + + // Fallback: return the original id + return id; + } + #endregion } diff --git a/src/TickerQ.Dashboard/wwwroot/package-lock.json b/src/TickerQ.Dashboard/wwwroot/package-lock.json index ee09bc79..8ba61822 100644 --- a/src/TickerQ.Dashboard/wwwroot/package-lock.json +++ b/src/TickerQ.Dashboard/wwwroot/package-lock.json @@ -43,6 +43,7 @@ "vite": "^6.0.5", "vite-plugin-dts": "^4.5.0", "vite-plugin-vue-devtools": "^7.6.8", + "vitest": "^4.1.0", "vue-tsc": "^2.2.0" } }, @@ -1251,9 +1252,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2018,6 +2019,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", @@ -2252,6 +2260,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2492,6 +2518,92 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.11", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz", @@ -3002,6 +3114,16 @@ "dev": true, "license": "Python-2.0" }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3206,6 +3328,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", @@ -3639,6 +3771,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", @@ -4040,6 +4179,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4930,12 +5079,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/memorystream": { @@ -5294,6 +5443,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/open": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", @@ -6050,6 +6210,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/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6114,6 +6281,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -6412,6 +6593,81 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "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.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "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", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6855,6 +7111,138 @@ "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "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.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/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/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", @@ -7114,6 +7502,23 @@ "node": ">= 8" } }, + "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/src/TickerQ.Dashboard/wwwroot/package.json b/src/TickerQ.Dashboard/wwwroot/package.json index 68ba44d0..b811af8e 100644 --- a/src/TickerQ.Dashboard/wwwroot/package.json +++ b/src/TickerQ.Dashboard/wwwroot/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build", + "test": "vitest run", "lint": "eslint . --fix", "format": "prettier --write src/" }, @@ -48,6 +49,7 @@ "vite": "^6.0.5", "vite-plugin-dts": "^4.5.0", "vite-plugin-vue-devtools": "^7.6.8", + "vitest": "^4.1.0", "vue-tsc": "^2.2.0" } } diff --git a/src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts b/src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts new file mode 100644 index 00000000..ee3dc071 --- /dev/null +++ b/src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { getDateFormatRegion, buildDatePart, formatDate, formatTime } from '../utilities/dateTimeParser'; + +describe('getDateFormatRegion', () => { + it('returns "us" for America/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('America/New_York')).toBe('us'); + expect(getDateFormatRegion('America/Chicago')).toBe('us'); + expect(getDateFormatRegion('America/Los_Angeles')).toBe('us'); + }); + + it('returns "us" for US/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('US/Eastern')).toBe('us'); + }); + + it('returns "eu" for Europe/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('Europe/London')).toBe('eu'); + expect(getDateFormatRegion('Europe/Berlin')).toBe('eu'); + }); + + it('returns "eu" for Africa/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('Africa/Cairo')).toBe('eu'); + }); + + it('returns "iso" for undefined or empty timezone', () => { + expect(getDateFormatRegion(undefined)).toBe('iso'); + expect(getDateFormatRegion('')).toBe('iso'); + }); + + it('returns "iso" for unrecognized timezone', () => { + expect(getDateFormatRegion('Asia/Tokyo')).toBe('iso'); + expect(getDateFormatRegion('UTC')).toBe('iso'); + }); + + // Windows timezone ID fallback handling + it('returns "us" for Windows American timezone IDs', () => { + expect(getDateFormatRegion('Eastern Standard Time')).toBe('us'); + expect(getDateFormatRegion('Central Standard Time')).toBe('us'); + expect(getDateFormatRegion('Mountain Standard Time')).toBe('us'); + expect(getDateFormatRegion('Pacific Standard Time')).toBe('us'); + expect(getDateFormatRegion('Alaskan Standard Time')).toBe('us'); + expect(getDateFormatRegion('Hawaiian Standard Time')).toBe('us'); + }); + + it('returns "eu" for Windows European timezone IDs', () => { + expect(getDateFormatRegion('W. Europe Standard Time')).toBe('eu'); + expect(getDateFormatRegion('Central European Standard Time')).toBe('eu'); + expect(getDateFormatRegion('E. Europe Standard Time')).toBe('eu'); + expect(getDateFormatRegion('GMT Standard Time')).toBe('eu'); + expect(getDateFormatRegion('Greenwich Standard Time')).toBe('eu'); + }); +}); + +describe('buildDatePart', () => { + it('builds US format MM/DD/YYYY', () => { + expect(buildDatePart('us', '2026', '03', '18')).toBe('03/18/2026'); + }); + + it('builds EU format DD/MM/YYYY', () => { + expect(buildDatePart('eu', '2026', '03', '18')).toBe('18/03/2026'); + }); + + it('builds ISO format YYYY-MM-DD', () => { + expect(buildDatePart('iso', '2026', '03', '18')).toBe('2026-03-18'); + }); +}); + +describe('formatDate', () => { + it('returns empty string for falsy input', () => { + expect(formatDate('')).toBe(''); + expect(formatDate(null as unknown as string)).toBe(''); + }); + + it('formats a UTC date string with IANA timezone', () => { + const result = formatDate('2026-03-18T04:00:00Z', true, 'America/New_York'); + // Should display as US format (MM/DD/YYYY) in Eastern time + // 04:00 UTC = 00:00 EST (March 18) or 23:00 EST (March 17) depending on DST + expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}$/); + }); + + it('formats a UTC date string with Europe timezone', () => { + const result = formatDate('2026-03-18T12:00:00Z', true, 'Europe/London'); + // Should display as EU format (DD/MM/YYYY) + expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}$/); + }); + + it('formats date without time when includeTime is false', () => { + const result = formatDate('2026-03-18T12:00:00Z', false, 'America/New_York'); + expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + expect(result).not.toContain(':'); + }); + + it('appends Z to strings without offset for UTC interpretation', () => { + // A datetime without Z should be treated as UTC + const withZ = formatDate('2026-03-18T12:00:00Z', false, 'UTC'); + const withoutZ = formatDate('2026-03-18T12:00:00', false, 'UTC'); + expect(withZ).toBe(withoutZ); + }); + + it('gracefully handles invalid timezone by falling back to browser local', () => { + // Windows timezone IDs are not valid for Intl.DateTimeFormat + // Should not throw, should still produce a formatted date + const result = formatDate('2026-03-18T12:00:00Z', true, 'Eastern Standard Time'); + expect(result).toBeTruthy(); + // The fallback uses 'us' region detection for Windows American IDs + // but the Intl formatter falls back to no timezone, producing a valid date + expect(result).toMatch(/\d{4}.*\d{2}.*\d{2}/); + }); + + it('handles date boundary correctly for EST timezone', () => { + // 2026-03-18T03:00:00Z = March 17 at 10pm EST (EST = UTC-5) + // In March 2026, DST is active (EDT = UTC-4), so this is March 17 at 11pm EDT + const result = formatDate('2026-03-18T03:00:00Z', false, 'America/New_York'); + // Should show March 17, not March 18 + expect(result).toBe('03/17/2026'); + }); + + it('handles date with space separator instead of T', () => { + const result = formatDate('2026-03-18 12:00:00', false, 'UTC'); + expect(result).toMatch(/2026/); + }); +}); + +describe('formatTime', () => { + it('formats milliseconds', () => { + expect(formatTime(500, true)).toBe('500ms'); + }); + + it('formats seconds', () => { + expect(formatTime(45)).toBe('45s'); + }); + + it('formats minutes and seconds', () => { + expect(formatTime(90)).toBe('1m 30s'); + }); + + it('formats hours and minutes', () => { + expect(formatTime(3660)).toBe('1h 1m'); + }); + + it('formats days and hours', () => { + expect(formatTime(90000)).toBe('1d 1h'); + }); + + it('formats exact minutes without seconds', () => { + expect(formatTime(120)).toBe('2m'); + }); + + it('formats exact hours without minutes', () => { + expect(formatTime(7200)).toBe('2h'); + }); + + it('formats exact days without hours', () => { + expect(formatTime(86400)).toBe('1d'); + }); +}); diff --git a/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts b/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts index d903df60..31b39c9e 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts @@ -5,6 +5,10 @@ type DateFormatRegion = 'eu' | 'us' | 'iso'; const europeanZonePrefixes = ['Europe/', 'Africa/']; const americanZonePrefixes = ['America/', 'US/']; +// Fallback patterns for Windows timezone IDs that didn't get converted to IANA on the backend +const windowsEuropeanPatterns = ['W. Europe', 'Central Europe', 'E. Europe', 'GMT ', 'Greenwich']; +const windowsAmericanPatterns = ['Eastern', 'Central Standard', 'Mountain', 'Pacific', 'Alaskan', 'Hawaiian', 'US ']; + export function getDateFormatRegion(timeZone?: string): DateFormatRegion { if (!timeZone) return 'iso'; @@ -16,6 +20,16 @@ export function getDateFormatRegion(timeZone?: string): DateFormatRegion { return 'us'; } + // Handle Windows timezone IDs as fallback (check European first to avoid + // "Central European" matching the American "Central Standard" pattern) + if (windowsEuropeanPatterns.some(p => timeZone.startsWith(p))) { + return 'eu'; + } + + if (windowsAmericanPatterns.some(p => timeZone.startsWith(p))) { + return 'us'; + } + return 'iso'; } @@ -44,25 +58,33 @@ export function formatDate( const dateObj = new Date(iso); - const options: Intl.DateTimeFormatOptions = { + const baseOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', - ...(timeZone ? { timeZone } : {}), }; if (includeTime) { - options.hour = '2-digit'; - options.minute = '2-digit'; - options.second = '2-digit'; - options.hour12 = false; + baseOptions.hour = '2-digit'; + baseOptions.minute = '2-digit'; + baseOptions.second = '2-digit'; + baseOptions.hour12 = false; + } + + // Try with the provided timezone; fall back to no timezone if it's invalid (e.g. Windows timezone ID) + let formatter: Intl.DateTimeFormat; + let effectiveTimeZone = timeZone; + try { + formatter = new Intl.DateTimeFormat('en-CA', { ...baseOptions, ...(timeZone ? { timeZone } : {}) }); + } catch { + effectiveTimeZone = undefined; + formatter = new Intl.DateTimeFormat('en-CA', baseOptions); } - const formatter = new Intl.DateTimeFormat('en-CA', options); const parts = formatter.formatToParts(dateObj); const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''; - const region = getDateFormatRegion(timeZone); + const region = getDateFormatRegion(effectiveTimeZone); const datePart = buildDatePart(region, get('year'), get('month'), get('day')); if (!includeTime) { diff --git a/src/TickerQ.Dashboard/wwwroot/vitest.config.ts b/src/TickerQ.Dashboard/wwwroot/vitest.config.ts new file mode 100644 index 00000000..d4793df2 --- /dev/null +++ b/src/TickerQ.Dashboard/wwwroot/vitest.config.ts @@ -0,0 +1,11 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + test: { + include: ['src/**/__tests__/**/*.test.ts'], + }, +}) diff --git a/tests/TickerQ.Tests/DashboardTimeZoneTests.cs b/tests/TickerQ.Tests/DashboardTimeZoneTests.cs new file mode 100644 index 00000000..cb6c8f99 --- /dev/null +++ b/tests/TickerQ.Tests/DashboardTimeZoneTests.cs @@ -0,0 +1,94 @@ +using System; +using TickerQ.Dashboard.Endpoints; + +namespace TickerQ.Tests; + +/// +/// Tests for ToIanaTimeZoneId — ensures the dashboard API always returns +/// IANA timezone identifiers regardless of the host OS. +/// +public class DashboardTimeZoneTests +{ + [Fact] + public void ToIanaTimeZoneId_NullTimeZone_ReturnsNull() + { + var result = DashboardEndpoints.ToIanaTimeZoneId(null); + Assert.Null(result); + } + + [Fact] + public void ToIanaTimeZoneId_UtcTimeZone_ReturnsUtc() + { + var result = DashboardEndpoints.ToIanaTimeZoneId(TimeZoneInfo.Utc); + Assert.Equal("UTC", result); + } + + [Fact] + public void ToIanaTimeZoneId_IanaId_ReturnedUnchanged() + { + // On Linux/macOS, FindSystemTimeZoneById with IANA ID returns IANA ID directly. + // On Windows with .NET 6+, it also supports IANA IDs. + var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + // Should contain '/' indicating IANA format + Assert.Contains("/", result); + Assert.Equal("America/New_York", result); + } + + [Fact] + public void ToIanaTimeZoneId_WindowsId_ConvertedToIana() + { + // "Eastern Standard Time" is the Windows ID for US Eastern + var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + // Should be converted to an IANA ID (America/New_York on most systems) + Assert.NotNull(result); + Assert.NotEqual("Eastern Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_PacificStandardTime_ConvertedToIana() + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + Assert.NotNull(result); + Assert.NotEqual("Pacific Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_CentralEuropeanStandardTime_ConvertedToIana() + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + Assert.NotNull(result); + Assert.NotEqual("Central European Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_TokyoStandardTime_ConvertedToIana() + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + Assert.NotNull(result); + Assert.NotEqual("Tokyo Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_LocalTimeZone_ReturnsNonNull() + { + // Regardless of host OS, local timezone should produce a non-null result + var result = DashboardEndpoints.ToIanaTimeZoneId(TimeZoneInfo.Local); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } +}