From ce3e3e38061fdbab87028d3650c53ceb750d1bec Mon Sep 17 00:00:00 2001 From: Alberti's MacMini Date: Thu, 19 Mar 2026 04:23:17 +0100 Subject: [PATCH] Fix dashboard date/time display for Windows timezone IDs (#717) Convert Windows timezone IDs (e.g. "Eastern Standard Time") to IANA format (e.g. "America/New_York") before sending to the frontend, so Intl.DateTimeFormat can correctly render dates in the configured scheduler timezone. Also add defensive fallback in the frontend formatDate() for invalid timezone IDs, and Windows timezone ID pattern matching in getDateFormatRegion() for correct US/EU date format selection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Endpoints/DashboardEndpoints.cs | 21 +- .../wwwroot/package-lock.json | 419 +++++++++++++++++- src/TickerQ.Dashboard/wwwroot/package.json | 2 + .../src/__tests__/dateTimeParser.test.ts | 155 +++++++ .../wwwroot/src/utilities/dateTimeParser.ts | 38 +- .../wwwroot/vitest.config.ts | 11 + tests/TickerQ.Tests/DashboardTimeZoneTests.cs | 94 ++++ 7 files changed, 724 insertions(+), 16 deletions(-) create mode 100644 src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts create mode 100644 src/TickerQ.Dashboard/wwwroot/vitest.config.ts create mode 100644 tests/TickerQ.Tests/DashboardTimeZoneTests.cs 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); + } +}