diff --git a/LaDOSE.Src/LaDOSE.WebApp/README.md b/LaDOSE.Src/LaDOSE.WebApp/README.md index 75a7b43..85a748c 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/README.md +++ b/LaDOSE.Src/LaDOSE.WebApp/README.md @@ -46,7 +46,7 @@ DTO field surfaces as a TypeScript error rather than a runtime 404. | `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO | | `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) | | `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes | -| `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` | +| `src/lib/api/users.ts` | `login` against `/Users/auth` — the one unauthenticated call | | `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 | | `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session | | `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` | @@ -223,7 +223,8 @@ otherwise only reachable through the database. Lists every account with its roles, creates accounts, and deletes them. `POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be -created. It is now `POST /Users/AddUser` and requires the **Admin** role, so before this +created. That route is gone; account creation is now `POST /Users/AddUser` and requires +the **Admin** role, so before this page is reachable at all, one account has to be promoted directly in the database: ```bash @@ -266,5 +267,11 @@ How roles work: ```bash npm run check # svelte-check (types + template diagnostics) +npm run lint # eslint, including type-aware rules +npm run test # vitest over the pure modules in $lib npm run build # static build into ./build ``` + +`npm run test` covers `$lib/statistics`, `$lib/tournaments`, `$lib/games/draft` and the +small shared helpers beside them (`csv`, `events`, `format`). Those modules are pure by +design, so they run under plain Node with no API and no database. diff --git a/LaDOSE.Src/LaDOSE.WebApp/eslint.config.js b/LaDOSE.Src/LaDOSE.WebApp/eslint.config.js new file mode 100644 index 0000000..2ccaead --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/eslint.config.js @@ -0,0 +1,81 @@ +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; +import ts from 'typescript-eslint'; + +/** + * Lint only. Formatting is deliberately not enforced here — there is no Prettier in + * this project, so any stylistic rule would fight the existing hand-kept style. + * + * Type-aware linting is on (`projectService`), which is what makes + * `no-floating-promises` able to see that an `async` handler's promise is dropped. + */ +export default ts.config( + js.configs.recommended, + ...ts.configs.recommendedTypeChecked, + ...svelte.configs.recommended, + { + languageOptions: { + globals: { ...globals.browser }, + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + tsconfigRootDir: import.meta.dirname + } + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts'], + languageOptions: { + parserOptions: { + parser: ts.parser + } + } + }, + { + rules: { + // The app ships no logging of its own; `hooks.client.ts` is the one exception + // and opts in explicitly below. + 'no-console': 'error', + /* + * Off: this rule wants every href and goto() wrapped in `resolve()`, which + * matters only for an app served under a base path. This one is served from + * the root (see nginx.conf) and sets no `base`, so it would be 14 wrappers + * buying nothing. Revisit if the app ever moves under a sub-path. + */ + 'svelte/no-navigation-without-resolve': 'off', + /* + * The regex placeholders on the event pickers need a literal `{` in an + * attribute, which in Svelte can only be written as a mustache. + */ + 'svelte/no-useless-mustaches': ['error', { ignoreStringEscape: true }], + // This is the rule that catches an `async` function used directly as an + // event handler, where a rejection becomes an unhandled rejection. + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ] + } + }, + { + files: ['src/hooks.client.ts'], + rules: { 'no-console': 'off' } + }, + { + // This file and the Vite config are build tooling, outside the app's tsconfig + // project, so type-aware rules cannot resolve them. + files: ['eslint.config.js', 'vite.config.ts'], + ...ts.configs.disableTypeChecked + }, + { + // Generated from openapi.json by `npm run api:types`; not ours to lint. + ignores: [ + 'src/lib/api/schema.d.ts', + 'build/', + '.svelte-kit/', + 'node_modules/', + 'static/config.js' + ] + } +); diff --git a/LaDOSE.Src/LaDOSE.WebApp/package-lock.json b/LaDOSE.Src/LaDOSE.WebApp/package-lock.json index b0f957a..7d43bfe 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/package-lock.json +++ b/LaDOSE.Src/LaDOSE.WebApp/package-lock.json @@ -8,17 +8,23 @@ "name": "ladose.webapp", "version": "0.0.1", "devDependencies": { + "@eslint/js": "^10.0.1", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.3", + "eslint": "^10.8.0", + "eslint-plugin-svelte": "^3.22.0", + "globals": "^17.9.0", "openapi-typescript": "^7.13.0", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "vite": "^8.0.16" + "typescript-eslint": "^8.66.0", + "vite": "^8.0.16", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -46,6 +52,239 @@ "node": ">=6.9.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1088,6 +1327,17 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "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/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1095,6 +1345,20 @@ "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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1102,6 +1366,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1109,6 +1380,388 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "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/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/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1122,6 +1775,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1132,6 +1795,30 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -1159,6 +1846,16 @@ "node": ">= 0.4" } }, + "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/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1186,6 +1883,16 @@ "balanced-match": "^1.0.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/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -1226,6 +1933,13 @@ "dev": true, "license": "MIT" }, + "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.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1236,6 +1950,34 @@ "node": ">= 0.6" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1254,6 +1996,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1295,6 +2044,203 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.22.0.tgz", + "integrity": "sha512-O3qn0NePTWta+1o25dIThqeEP/hEQ3VxDK2LVO8SQ5wG9umLMvulK+m1yQ4JGOb2Pkl8IB0G1lpRV/HXDXSLTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1302,6 +2248,37 @@ "dev": true, "license": "MIT" }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/esrap": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", @@ -1320,6 +2297,59 @@ } } }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1327,6 +2357,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1345,6 +2389,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1360,6 +2455,32 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1381,6 +2502,26 @@ "node": ">= 14" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -1394,6 +2535,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1404,6 +2568,13 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1454,6 +2625,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1461,6 +2639,23 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1471,6 +2666,27 @@ "node": ">=6" } }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1744,6 +2960,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -1751,6 +2977,22 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1820,6 +3062,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -1855,6 +3104,56 @@ "typescript": "^5.x" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -1873,6 +3172,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1932,6 +3258,124 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2002,6 +3446,19 @@ "node": ">=6" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", @@ -2009,6 +3466,36 @@ "dev": true, "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -2034,6 +3521,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "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/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -2100,6 +3601,85 @@ "typescript": "^5.0.0 || ^6.0.0" } }, + "node_modules/svelte-eslint-parser": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", + "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.34.1" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2121,6 +3701,23 @@ "url": "https://opencollective.com/webpack" } }, + "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.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2138,6 +3735,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -2148,6 +3755,32 @@ "node": ">=6" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -2175,6 +3808,40 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", @@ -2182,6 +3849,13 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -2280,6 +3954,149 @@ } } }, + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "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", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", @@ -2297,6 +4114,19 @@ "node": ">=12" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/LaDOSE.Src/LaDOSE.WebApp/package.json b/LaDOSE.Src/LaDOSE.WebApp/package.json index 247f457..ed4f22e 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/package.json +++ b/LaDOSE.Src/LaDOSE.WebApp/package.json @@ -12,19 +12,30 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "api:fetch": "curl -fsS ${LADOSE_API_URL:-http://localhost:5000}/openapi/v1.json -o openapi.json", "api:types": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts", - "api:sync": "npm run api:fetch && npm run api:types" + "api:sync": "npm run api:fetch && npm run api:types", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.1", + "@eslint/js": "^10.0.1", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.3", + "eslint": "^10.8.0", + "eslint-plugin-svelte": "^3.22.0", + "globals": "^17.9.0", "openapi-typescript": "^7.13.0", "svelte": "^5.56.1", "svelte-check": "^4.6.0", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "vite": "^8.0.16" + "typescript-eslint": "^8.66.0", + "vite": "^8.0.16", + "vitest": "^4.1.10" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" } } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts b/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts index da08e6d..ebcb67c 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/app.d.ts @@ -8,6 +8,24 @@ declare global { // interface PageState {} // interface Platform {} } + + /** + * Build-time configuration Vite inlines into the bundle. Declared so the reads in + * `$lib/api/client` are typed rather than `any` — see `resolveBaseUrl` for how + * this relates to the runtime `window.__LADOSE_CONFIG__` tier. + */ + interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + } + + interface ImportMeta { + readonly env: ImportMetaEnv; + } + + /** Shape of the object `static/config.js` defines, rewritten at container start. */ + interface Window { + __LADOSE_CONFIG__?: { apiBaseUrl?: string }; + } } export {}; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/app.html b/LaDOSE.Src/LaDOSE.WebApp/src/app.html index 5e201f0..95dc1e6 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/app.html +++ b/LaDOSE.Src/LaDOSE.WebApp/src/app.html @@ -12,8 +12,11 @@ --> + %sveltekit.head%
%sveltekit.body%
diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/hooks.client.ts b/LaDOSE.Src/LaDOSE.WebApp/src/hooks.client.ts new file mode 100644 index 0000000..3380915 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/hooks.client.ts @@ -0,0 +1,19 @@ +import type { HandleClientError } from '@sveltejs/kit'; + +/** + * Last resort for errors no page caught — an uncaught error in an `$effect`, or a + * failed `load`. Without this they surface nowhere at all: the app logs nothing + * anywhere else, so a blank panel is the only symptom a user could report. + * + * There is no telemetry backend to ship these to, so the console is the whole + * story. It is the one place in the app where `console.error` is intentional. + */ +export const handleError: HandleClientError = ({ error, status, message }) => { + if (status !== 404) console.error('[LaDOSE]', error); + + // What `+error.svelte` renders. Deliberately generic: `error` can carry API + // internals, and the pages already surface anything the user can act on. + return { + message: status === 404 ? message : 'An unexpected error occurred.' + }; +}; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts index 1bec6ca..eac1b9f 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/admin-users.ts @@ -1,4 +1,3 @@ -import { session } from '$lib/stores/session.svelte'; import { apiRequest, buildPath, type RequestOptions } from './client'; import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers'; @@ -8,13 +7,9 @@ import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers'; * * `login` and the session live in `users.ts`; this module is only the admin screen. */ -function authed(options: RequestOptions): RequestOptions { - return { ...options, token: options.token ?? session.token }; -} - /** GET /Users — every account, ordered by username. Never includes password material. */ export function listUsers(options: RequestOptions = {}): Promise { - return apiRequest('/Users', authed(options)); + return apiRequest('/Users', options); } /** @@ -22,7 +17,7 @@ export function listUsers(options: RequestOptions = {}): Promise { - return apiRequest('/Users/Roles', authed(options)); + return apiRequest('/Users/Roles', options); } /** @@ -35,7 +30,7 @@ export function addUser( options: RequestOptions = {} ): Promise { return apiRequest('/Users/AddUser', { - ...authed(options), + ...options, method: 'POST', body: user }); @@ -47,7 +42,7 @@ export function addUser( */ export async function deleteUser(id: number, options: RequestOptions = {}): Promise { await apiRequest(buildPath('/Users/{id}', { id }), { - ...authed(options), + ...options, method: 'DELETE' }); } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts index 69810f9..cab6700 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/client.ts @@ -1,12 +1,6 @@ +import { session } from '$lib/stores/session.svelte'; import type { ApiPath } from './schema-helpers'; -/** Shape of the object `static/config.js` defines, rewritten at container start. */ -declare global { - interface Window { - __LADOSE_CONFIG__?: { apiBaseUrl?: string }; - } -} - /** * Base URL of LaDOSE.Api, resolved in this order: * @@ -24,7 +18,11 @@ function resolveBaseUrl(): string { const runtime = typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined; - const configured = runtime?.trim() || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'; + // Vite replaces this with a string literal at build time, or leaves it undefined + // when the variable was not set (see ImportMetaEnv in app.d.ts). + const baked = import.meta.env.VITE_API_BASE_URL; + + const configured = runtime?.trim() || baked || 'http://localhost:5000'; return configured.replace(/\/$/, ''); } @@ -53,7 +51,14 @@ function extractMessage(body: unknown, status: number): string { export interface RequestOptions { method?: 'GET' | 'POST' | 'DELETE'; body?: unknown; - /** JWT from `POST /Users/auth`; sent as `Authorization: Bearer ...`. */ + /** + * JWT sent as `Authorization: Bearer ...`. + * + * Left out, the session store's token is used — almost every endpoint on + * LaDOSE.Api is `[Authorize]`, so authenticated is the useful default. Pass + * `null` to send the request unauthenticated (`POST /Users/auth` is the only + * caller that needs to), or a string to override the stored token. + */ token?: string | null; fetch?: typeof globalThis.fetch; signal?: AbortSignal; @@ -62,13 +67,16 @@ export interface RequestOptions { /** * Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI * types, so a typo or a route removed on the server is a compile error. - * Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`. + * Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`, whose + * branded return type is the only other thing accepted here — widening this to + * `string` would silently re-admit routes the server no longer serves. */ export async function apiRequest( - path: ApiPath | (string & {}), + path: ApiPath | BuiltPath, options: RequestOptions = {} ): Promise { - const { method = 'GET', body, token, fetch: fetchImpl = globalThis.fetch, signal } = options; + const { method = 'GET', body, fetch: fetchImpl = globalThis.fetch, signal } = options; + const token = options.token === undefined ? session.token : options.token; const headers: Record = { Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; @@ -91,7 +99,7 @@ export async function apiRequest( } const isJson = response.headers.get('content-type')?.includes('json') ?? false; - const payload = isJson ? await response.json().catch(() => null) : null; + const payload: unknown = isJson ? await response.json().catch(() => null) : null; if (!response.ok) { throw new ApiError(response.status, extractMessage(payload, response.status)); @@ -100,14 +108,20 @@ export async function apiRequest( return payload as TResponse; } +/** + * A path already filled in by `buildPath`. Branded so `apiRequest` can accept it + * without accepting `string`, which would defeat the `ApiPath` constraint. + */ +export type BuiltPath = string & { readonly __apiPath: unique symbol }; + /** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */ export function buildPath( template: ApiPath, params: Record -): string { +): BuiltPath { return template.replace(/\{(\w+)\}/g, (_, key: string) => { const value = params[key]; if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`); return encodeURIComponent(String(value)); - }); + }) as BuiltPath; } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts index ea99cf4..8d87172 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/errors.ts @@ -19,3 +19,20 @@ export function toErrorMessage(cause: unknown, fallback: string): string | null // ApiError already carries the API's own `message`, or "unreachable" for status 0. return cause instanceof ApiError ? cause.message : fallback; } + +/** + * Binds `toErrorMessage` to a page's error state, so every page reports failures + * the same way: + * + * const report = errorReporter((message) => (error = message)); + * ... + * catch (cause) { report(cause, 'Could not load the accounts.'); } + * + * Pages used to split between a hand-copied `report` wrapper and calling + * `toErrorMessage` inline — two conventions for one behaviour. + */ +export function errorReporter( + set: (message: string | null) => void +): (cause: unknown, fallback: string) => void { + return (cause, fallback) => set(toErrorMessage(cause, fallback)); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts index d577e99..32905f6 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/games.ts @@ -1,15 +1,10 @@ -import { session } from '$lib/stores/session.svelte'; +/** GameController is `[Authorize]`; `apiRequest` supplies the session JWT. */ import { apiRequest, buildPath, type RequestOptions } from './client'; import type { GameDTO } from './schema-helpers'; -/** GameController is `[Authorize]`; reuse the session JWT unless one is passed in. */ -function authed(options: RequestOptions): RequestOptions { - return { ...options, token: options.token ?? session.token }; -} - /** GET /api/Game — every game, in database order (sort by `order` for display). */ export function listGames(options: RequestOptions = {}): Promise { - return apiRequest('/api/Game', authed(options)); + return apiRequest('/api/Game', options); } /** @@ -18,7 +13,7 @@ export function listGames(options: RequestOptions = {}): Promise { * Returns the saved game with its assigned id. */ export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise { - return apiRequest('/api/Game', { ...authed(options), method: 'POST', body: game }); + return apiRequest('/api/Game', { ...options, method: 'POST', body: game }); } /** @@ -27,7 +22,7 @@ export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise { await apiRequest(buildPath('/api/Game/{id}', { id }), { - ...authed(options), + ...options, method: 'DELETE' }); } @@ -41,5 +36,5 @@ export function searchSmashGames( name: string, options: RequestOptions = {} ): Promise { - return apiRequest(buildPath('/api/Game/smash/{name}', { name }), authed(options)); + return apiRequest(buildPath('/api/Game/smash/{name}', { name }), options); } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts index 4f910ed..840f996 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts @@ -1,4 +1,4 @@ -import { session } from '$lib/stores/session.svelte'; +/** SheetsController is `[Authorize]`, like the rest of the API. */ import { apiRequest, type RequestOptions } from './client'; import type { SheetExportRequestDTO, @@ -6,18 +6,13 @@ import type { SheetsConfigDTO } from './schema-helpers'; -/** SheetsController is `[Authorize]`, like the rest of the API. */ -function authed(options: RequestOptions): RequestOptions { - return { ...options, token: options.token ?? session.token }; -} - /** * GET /api/Sheets/Config — whether the export is usable and which spreadsheet it points * at. The target is server configuration (it is reset each year), so the UI reads it * rather than offering it as an input. Carries no credentials. */ export function getSheetsConfig(options: RequestOptions = {}): Promise { - return apiRequest('/api/Sheets/Config', authed(options)); + return apiRequest('/api/Sheets/Config', options); } /** @@ -32,7 +27,7 @@ export function exportToSheets( options: RequestOptions = {} ): Promise { return apiRequest('/api/Sheets/Export', { - ...authed(options), + ...options, method: 'POST', body: request }); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts index 6d1c31e..8807bc2 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts @@ -1,12 +1,7 @@ -import { session } from '$lib/stores/session.svelte'; +/** StatisticsController is `[Authorize]`, like the tournament endpoints. */ import { apiRequest, buildPath, type RequestOptions } from './client'; import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers'; -/** StatisticsController is `[Authorize]`, like the tournament endpoints. */ -function authed(options: RequestOptions): RequestOptions { - return { ...options, token: options.token ?? session.token }; -} - /** * POST /api/Statistics/Matches — set-level statistics (win/loss, games, head to * head) for the given events, read from the `set` rows an import persisted. @@ -22,7 +17,7 @@ export function getMatchStats( options: RequestOptions = {} ): Promise { return apiRequest('/api/Statistics/Matches', { - ...authed(options), + ...options, method: 'POST', body: eventIds }); @@ -35,7 +30,7 @@ export function getMatchStats( * whose game is known — the same filter `getVersus` applies. */ export function listVersusPlayers(options: RequestOptions = {}): Promise { - return apiRequest('/api/Statistics/Players', authed(options)); + return apiRequest('/api/Statistics/Players', options); } /** @@ -56,5 +51,5 @@ export function getVersus( playerAId, playerBId }); - return apiRequest(path, authed(options)); + return apiRequest(path, options); } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts index 15067b0..d841997 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/tournaments.ts @@ -1,22 +1,17 @@ -import { session } from '$lib/stores/session.svelte'; +/** + * TournamentController and EventController are both `[Authorize]`. `apiRequest` + * defaults the JWT to the session store's, so nothing here passes one explicitly. + */ import { apiRequest, buildPath, type RequestOptions } from './client'; import type { EventDTO, TournamentsResultDTO } from './schema-helpers'; -/** - * TournamentController and EventController are both `[Authorize]`, so every call - * here carries the JWT held by the session store unless one is passed explicitly. - */ -function authed(options: RequestOptions): RequestOptions { - return { ...options, token: options.token ?? session.token }; -} - /** * GET /api/Event — every imported event, newest first (the controller orders by * `Date` descending). A start.gg tournament becomes one Event holding one * Tournament per bracket. */ export function listEvents(options: RequestOptions = {}): Promise { - return apiRequest('/api/Event', authed(options)); + return apiRequest('/api/Event', options); } /** @@ -32,7 +27,7 @@ export function importSmashTournament( const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', { tournamentSlug: slug }); - return apiRequest(path, authed(options)); + return apiRequest(path, options); } /** @@ -46,7 +41,7 @@ export function getResults( options: RequestOptions = {} ): Promise { return apiRequest('/api/Tournament/GetResults', { - ...authed(options), + ...options, method: 'POST', body: eventIds }); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts index 359d139..0ddd056 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/users.ts @@ -8,7 +8,11 @@ import type { ApplicationUserDTO, AuthenticatedUser, LoginRequest } from './sche export async function login(credentials: LoginRequest): Promise { const user = await apiRequest('/Users/auth', { method: 'POST', - body: credentials + body: credentials, + // The only unauthenticated endpoint: opt out of the session token that + // `apiRequest` would otherwise attach, so signing in as a second user while + // a stale session is still in memory sends only the credentials. + token: null }); // The generated DTO marks every field optional because the C# properties are @@ -19,8 +23,3 @@ export async function login(credentials: LoginRequest): Promise { - await apiRequest('/Users/register', { method: 'POST', body: credentials }); -} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/auth/guard.svelte.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/auth/guard.svelte.ts new file mode 100644 index 0000000..ad0745e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/auth/guard.svelte.ts @@ -0,0 +1,74 @@ +import { goto } from '$app/navigation'; +import { session } from '$lib/stores/session.svelte'; + +/** + * The client-side route guard, in one place. + * + * Every protected page used to carry its own copy of this effect plus a `started` + * latch — five verbatim copies, which meant a new protected route shipped unguarded + * whenever someone forgot to paste it. + * + * This is presentation only. The API authorises every request independently and + * re-reads the caller's roles from the database, so a guard here never decides + * access; it only avoids rendering a shell that can produce nothing but 401s + * and 403s. + */ + +export interface Guard { + /** + * Whether the page may render. Gate the page's `
` on this: `goto` is + * asynchronous, so without it an unauthenticated deep link paints the whole + * page for a frame before the redirect lands. + */ + readonly ready: boolean; +} + +interface GuardOptions { + /** Also require the Admin role, sending non-admins to the dashboard. */ + admin?: boolean; + /** Run once, the first time the guard admits the user. For the initial fetch. */ + onReady?: () => void; +} + +function guard(options: GuardOptions = {}): Guard { + const { admin = false, onReady } = options; + + let ready = $state(false); + let started = false; + + $effect(() => { + if (!session.isLoggedIn) { + ready = false; + void goto('/login', { replaceState: true }); + return; + } + + if (admin && !session.isAdmin) { + ready = false; + void goto('/', { replaceState: true }); + return; + } + + ready = true; + if (!started) { + started = true; + onReady?.(); + } + }); + + return { + get ready() { + return ready; + } + }; +} + +/** Requires a signed-in user. Call once, at the top level of a page component. */ +export function requireSession(onReady?: () => void): Guard { + return guard({ onReady }); +} + +/** Requires a signed-in user holding the Admin role. */ +export function requireAdmin(onReady?: () => void): Guard { + return guard({ admin: true, onReady }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/csv.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/csv.test.ts new file mode 100644 index 0000000..30fa650 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/csv.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { quote, toCsv } from './csv'; + +describe('quote', () => { + it('wraps every field, including numbers', () => { + expect(quote('Bob')).toBe('"Bob"'); + expect(quote(12)).toBe('"12"'); + expect(quote('')).toBe('""'); + }); + + it('doubles inner quotes rather than escaping them', () => { + expect(quote('He said "hi"')).toBe('"He said ""hi"""'); + }); + + it('leaves separators alone — quoting is what makes them safe', () => { + expect(quote('Smash; Melee')).toBe('"Smash; Melee"'); + expect(quote('line\nbreak')).toBe('"line\nbreak"'); + }); +}); + +describe('toCsv', () => { + it('emits CRLF between rows and a trailing CRLF', () => { + expect(toCsv(['A', 'B'], [['1', '2']])).toBe('"A";"B"\r\n"1";"2"\r\n'); + }); + + it('writes a header-only document when there are no rows', () => { + expect(toCsv(['A'], [])).toBe('"A"\r\n'); + }); + + it('is semicolon separated, which is what Excel expects here', () => { + expect(toCsv(['A', 'B'], []).includes(';')).toBe(true); + expect(toCsv(['A', 'B'], []).includes(',')).toBe(false); + }); +}); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/csv.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/csv.ts new file mode 100644 index 0000000..15df71f --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/csv.ts @@ -0,0 +1,26 @@ +/** + * The one CSV dialect this app writes. + * + * Excel is picky: semicolon separated, every field quoted, inner quotes doubled, + * CRLF line endings and a trailing newline. `buildCsv` (tournaments) and + * `standingsCsv` (statistics) both used to carry their own copy of this — they + * were character-identical, and a comment in one pointed at the other rather than + * sharing it. + * + * Pure: exercisable under plain Node. + */ + +const SEPARATOR = ';'; +const NEWLINE = '\r\n'; + +/** One field, quoted, with inner quotes doubled. */ +export function quote(value: string | number): string { + return `"${String(value).replaceAll('"', '""')}"`; +} + +/** A full document: header row, then one row per record, CRLF-terminated throughout. */ +export function toCsv(header: readonly (string | number)[], rows: readonly (string | number)[][]): string { + const lines = [header.map(quote).join(SEPARATOR)]; + for (const row of rows) lines.push(row.map(quote).join(SEPARATOR)); + return lines.join(NEWLINE) + NEWLINE; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/download.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/download.ts new file mode 100644 index 0000000..3b1bfdd --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/download.ts @@ -0,0 +1,33 @@ +/** + * Client-side file download. + * + * The rankings and tournaments pages each carried their own copy of this. Both + * clicked an anchor that was never in the document and revoked the object URL on + * the very next line — browsers that hand the blob to a download manager + * asynchronously can produce a silently empty file. This appends the anchor, + * clicks it, removes it, and defers the revoke to the next task. + */ + +/** Triggers a download of `content` as `filename`. Browser only. */ +export function downloadText(filename: string, content: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.append(link); + link.click(); + link.remove(); + + // Revoking synchronously can cut the download off before the browser has read + // the blob; one task later is enough and still frees the memory. + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +/** `downloadText` with the CSV content type the exports use. */ +export function downloadCsv(filename: string, content: string): void { + downloadText(filename, content, 'text/csv;charset=utf-8'); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/events.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/events.test.ts new file mode 100644 index 0000000..0bf567e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/events.test.ts @@ -0,0 +1,94 @@ +import type { EventDTO } from '$lib/api/schema-helpers'; +import { describe, expect, it } from 'vitest'; +import { compareByDate, eventIds, identified, matchingEventIds, toggleId } from './events'; + +function event(partial: Partial): EventDTO { + return { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...partial }; +} + +describe('identified / eventIds', () => { + it('drops events the API gave no id', () => { + const events = [event({ id: 1 }), event({ id: undefined }), event({ id: 3 })]; + expect(eventIds(events)).toEqual([1, 3]); + expect(identified(events)).toHaveLength(2); + }); + + it('keeps id 0, which is a real id and not "missing"', () => { + expect(eventIds([event({ id: 0 })])).toEqual([0]); + }); +}); + +describe('toggleId', () => { + it('adds then removes', () => { + expect(toggleId([], 3)).toEqual([3]); + expect(toggleId([1, 3], 3)).toEqual([1]); + }); + + it('ignores undefined instead of pushing a hole into the selection', () => { + expect(toggleId([1], undefined)).toEqual([1]); + }); + + it('does not mutate the input', () => { + const selected = [1]; + toggleId(selected, 2); + expect(selected).toEqual([1]); + }); +}); + +describe('matchingEventIds', () => { + const events = [ + event({ id: 1, name: 'Ranking #1301' }), + event({ id: 2, name: 'Ranking #1302' }), + event({ id: 3, name: 'Tournoi de Noël' }), + event({ id: 4, name: undefined }) + ]; + + it('selects by regular expression', () => { + expect(matchingEventIds(events, String.raw`Ranking #13\d{2}`).ids).toEqual([1, 2]); + }); + + it('is case sensitive', () => { + expect(matchingEventIds(events, 'ranking').ids).toEqual([]); + }); + + it('never matches an event with no name', () => { + expect(matchingEventIds(events, '').ids).toEqual([]); + expect(matchingEventIds(events, '.*').ids).toEqual([1, 2, 3]); + }); + + it('reports an invalid pattern instead of throwing', () => { + const match = matchingEventIds(events, 'Ranking #13('); + expect(match.ids).toEqual([]); + expect(match.error).toContain('not a valid regular expression'); + }); + + it('treats a blank pattern as no request at all', () => { + expect(matchingEventIds(events, ' ')).toEqual({ ids: [], error: null }); + }); +}); + +describe('compareByDate', () => { + const older = '2026-01-01T00:00:00'; + const newer = '2026-06-01T00:00:00'; + + it('orders ascending by default and descending on request', () => { + expect(compareByDate(older, newer, 'asc')).toBeLessThan(0); + expect(compareByDate(older, newer, 'desc')).toBeGreaterThan(0); + }); + + it('ties on equal dates, so callers can add their own tiebreak', () => { + expect(compareByDate(older, older, 'asc')).toBe(0); + }); + + it('sorts undated last in both directions — they cannot claim to be latest', () => { + for (const direction of ['asc', 'desc'] as const) { + expect(compareByDate(older, null, direction)).toBeLessThan(0); + expect(compareByDate(null, older, direction)).toBeGreaterThan(0); + } + }); + + it('treats an unparseable date as undated', () => { + expect(compareByDate(older, 'not a date', 'asc')).toBeLessThan(0); + expect(compareByDate(null, undefined, 'asc')).toBe(0); + }); +}); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/events.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/events.ts new file mode 100644 index 0000000..298e969 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/events.ts @@ -0,0 +1,92 @@ +/** + * Event-selection helpers shared by the tournaments and rankings pages, which both + * present the same "tick events, or select all / recent / by pattern" scope picker. + * + * `EventDTO.id` is optional because the generated DTO mirrors nullable C# reference + * types, so every consumer used to repeat `.filter(e => e.id !== undefined)` followed + * by an `as number` cast. `eventIds` narrows properly and removes the cast. + * + * Pure: exercisable under plain Node. + */ + +import type { EventDTO } from '$lib/api/schema-helpers'; + +/** An event the API gave an id, so it can be requested. */ +export type IdentifiedEvent = EventDTO & { id: number }; + +export function hasId(event: EventDTO): event is IdentifiedEvent { + return event.id !== undefined; +} + +/** The requestable events of a list, in order. */ +export function identified(events: EventDTO[]): IdentifiedEvent[] { + return events.filter(hasId); +} + +/** Just the ids — what the scoring endpoints take. */ +export function eventIds(events: EventDTO[]): number[] { + return identified(events).map((event) => event.id); +} + +/** Adds or removes an id, ignoring the undefined case. Returns a new array. */ +export function toggleId(selected: number[], id: number | undefined): number[] { + if (id === undefined) return selected; + return selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id]; +} + +export interface PatternMatch { + /** Ids of the events whose name matched. Empty when the pattern was invalid. */ + ids: number[]; + /** Set when the pattern would not compile — show it instead of the match count. */ + error: string | null; +} + +/** + * Selects events by regular expression on their name, used for things like + * `Ranking #13\d{2}`. Case-sensitive, and events with no name never match. + * + * An invalid pattern is a user typo, not a bug, so it comes back as a message + * rather than throwing. A blank pattern selects nothing and reports nothing — + * callers treat it as "no request made" and leave the selection alone. + */ +export function matchingEventIds(events: EventDTO[], pattern: string): PatternMatch { + const value = pattern.trim(); + if (!value) return { ids: [], error: null }; + + let regex: RegExp; + try { + regex = new RegExp(value); + } catch { + return { ids: [], error: `"${value}" is not a valid regular expression.` }; + } + + return { + ids: identified(events) + .filter((event) => event.name && regex.test(event.name)) + .map((event) => event.id), + error: null + }; +} + +/** + * Compares events by date. Events the API gave no date for cannot claim to be the + * latest, so they always sort last regardless of direction. + * + * `'desc'` is newest first (which event names a spreadsheet tab); `'asc'` is oldest + * first (which way a chart reads). + */ +export function compareByDate( + a: string | null | undefined, + b: string | null | undefined, + direction: 'asc' | 'desc' = 'asc' +): number { + const aTime = a ? Date.parse(a) : NaN; + const bTime = b ? Date.parse(b) : NaN; + const aOk = Number.isFinite(aTime); + const bOk = Number.isFinite(bTime); + + if (aOk && bOk) return direction === 'asc' ? aTime - bTime : bTime - aTime; + if (aOk) return -1; + if (bOk) return 1; + return 0; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/format.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/format.test.ts new file mode 100644 index 0000000..961fa1e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/format.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { fullName, percent } from './format'; + +describe('percent', () => { + it('keeps one decimal below 100', () => { + expect(percent(0)).toBe('0.0%'); + expect(percent(66.666)).toBe('66.7%'); + expect(percent(99.9)).toBe('99.9%'); + }); + + it('collapses to a bare 100% from 99.95 up, so the column never widens', () => { + expect(percent(99.95)).toBe('100%'); + expect(percent(100)).toBe('100%'); + }); +}); + +describe('fullName', () => { + it('joins both names', () => { + expect(fullName('Ada', 'Lovelace')).toBe('Ada Lovelace'); + }); + + it('uses whichever half is present', () => { + expect(fullName('Ada', null)).toBe('Ada'); + expect(fullName(undefined, 'Lovelace')).toBe('Lovelace'); + }); + + it('falls back when there is no name at all', () => { + expect(fullName(null, null)).toBe(''); + expect(fullName(null, undefined, 'ada')).toBe('ada'); + expect(fullName('', '', 'ada')).toBe('ada'); + }); +}); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/format.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/format.ts new file mode 100644 index 0000000..3087e76 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/format.ts @@ -0,0 +1,24 @@ +/** + * Small display helpers that were duplicated across routes. + * + * Pure: exercisable under plain Node. + */ + +/** + * A percentage already on a 0..100 scale, rendered for a narrow table column: + * one decimal below 100, and a bare "100%" from 99.95 up so the column never + * widens to "100.0%". + */ +export function percent(value: number): string { + return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`; +} + +/** First and last name if either is set, otherwise the fallback (usually the username). */ +export function fullName( + firstName: string | null | undefined, + lastName: string | null | undefined, + fallback = '' +): string { + const full = [firstName, lastName].filter(Boolean).join(' ').trim(); + return full.length > 0 ? full : fallback; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.test.ts new file mode 100644 index 0000000..3f39f62 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.test.ts @@ -0,0 +1,93 @@ +import type { GameDTO } from '$lib/api/schema-helpers'; +import { describe, expect, it } from 'vitest'; +import { blankDraft, isDirty, nextOrder, toDraft, toDto } from './draft'; + +describe('toDraft / toDto', () => { + it('replaces every null with a value the inputs can bind to', () => { + const draft = toDraft({ id: 3, name: null, longName: null, order: undefined, smashId: undefined }); + expect(draft).toEqual({ + id: 3, + name: '', + longName: '', + order: 0, + imgUrl: '', + wordPressTag: '', + wordPressTagOs: '', + smashId: null + }); + }); + + it('stores blank and whitespace-only text as NULL, not as an empty string', () => { + const dto = toDto({ ...blankDraft, name: 'SF6', longName: ' ', imgUrl: '' }); + expect(dto.longName).toBeNull(); + expect(dto.imgUrl).toBeNull(); + expect(dto.name).toBe('SF6'); + }); + + it('trims text on the way out', () => { + expect(toDto({ ...blankDraft, name: ' SF6 ' }).name).toBe('SF6'); + }); + + it('round-trips a full game', () => { + const game: GameDTO = { + id: 4, + name: 'SF6', + longName: 'Street Fighter 6', + order: 2, + imgUrl: 'https://example.test/sf6.png', + wordPressTag: 'sf6', + wordPressTagOs: 'sf6-os', + smashId: 43868 + }; + expect(toDto(toDraft(game))).toEqual(game); + }); +}); + +describe('isDirty', () => { + /* + * The editor used to compare `JSON.stringify` output, which is key-order + * dependent: an identical draft built with its keys in another order read as + * unsaved changes. + */ + it('is false for equal drafts regardless of key order', () => { + const a = { ...blankDraft, name: 'SF6', order: 2 }; + const reordered = { + order: 2, + smashId: null, + wordPressTagOs: '', + wordPressTag: '', + imgUrl: '', + longName: '', + name: 'SF6', + id: 0 + }; + expect(JSON.stringify(a)).not.toBe(JSON.stringify(reordered)); + expect(isDirty(a, reordered)).toBe(false); + }); + + it('detects a change in any field', () => { + const base = { ...blankDraft, name: 'SF6' }; + expect(isDirty(base, { ...base, name: 'SFV' })).toBe(true); + expect(isDirty(base, { ...base, order: 9 })).toBe(true); + expect(isDirty(base, { ...base, smashId: 1 })).toBe(true); + expect(isDirty(base, { ...base })).toBe(false); + }); + + it('distinguishes null from 0 for the nullable numbers', () => { + expect(isDirty({ ...blankDraft, smashId: null }, { ...blankDraft, smashId: 0 })).toBe(true); + }); +}); + +describe('nextOrder', () => { + it('sorts a new game after the current last one', () => { + expect(nextOrder([{ order: 1 }, { order: 5 }, { order: 3 }])).toBe(6); + }); + + it('starts at 1 for an empty catalogue', () => { + expect(nextOrder([])).toBe(1); + }); + + it('treats a missing order as 0', () => { + expect(nextOrder([{ order: undefined }])).toBe(1); + }); +}); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts index 35a25ef..b112b16 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/games/draft.ts @@ -63,3 +63,23 @@ export function toDto(draft: Draft): GameDTO { export function nextOrder(games: GameDTO[]): number { return games.reduce((max, game) => Math.max(max, game.order ?? 0), 0) + 1; } + +/** + * Whether the editor holds unsaved changes. + * + * Field by field rather than by comparing `JSON.stringify` output, which is + * key-order dependent: it would report a clean draft as dirty the moment + * `blankDraft` and `toDraft` listed their keys in a different order. + */ +export function isDirty(a: Draft, b: Draft): boolean { + return ( + a.id !== b.id || + a.name !== b.name || + a.longName !== b.longName || + a.order !== b.order || + a.imgUrl !== b.imgUrl || + a.wordPressTag !== b.wordPressTag || + a.wordPressTagOs !== b.wordPressTagOs || + a.smashId !== b.smashId + ); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/index.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/index.ts deleted file mode 100644 index bd9eb15..0000000 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Re-export the API surface so pages can `import { login, session } from '$lib'`. -export { API_BASE_URL, ApiError, apiRequest, buildPath } from './api/client'; -export { login, register } from './api/users'; -export { toErrorMessage } from './api/errors'; -export { addUser, deleteUser, listRoles, listUsers } from './api/admin-users'; -export { deleteGame, listGames, saveGame, searchSmashGames } from './api/games'; -export { getResults, importSmashTournament, listEvents } from './api/tournaments'; -export { getMatchStats } from './api/statistics'; -export { aggregate, formatMonth, standingsCsv } from './statistics/aggregate'; -export type { - Aggregate, - EventAttendance, - EventResult, - GameSummary, - PlayerStanding, - Totals -} from './statistics/aggregate'; -export { loadEventResults } from './statistics/load'; -export type { FailedEvent, LoadOptions, LoadOutcome } from './statistics/load'; -export { blankDraft, nextOrder, toDraft, toDto } from './games/draft'; -export type { Draft } from './games/draft'; -export { session } from './stores/session.svelte'; -export { - buildCsv, - buildHtml, - buildRanking, - playedGames, - resultsForGame -} from './tournaments/results'; -export type { RankingRow, RankingTable } from './tournaments/results'; -export type * from './api/schema-helpers'; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.test.ts new file mode 100644 index 0000000..b1e0d7b --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.test.ts @@ -0,0 +1,206 @@ +import type { EventDTO, ResultDTO } from '$lib/api/schema-helpers'; +import { describe, expect, it } from 'vitest'; +import { aggregate, formatMonth, standingsCsv, type EventResult } from './aggregate'; + +/** The sentinel ExternalProviderService emits for a player it could not place. */ +const UNRANKED = 999; + +const sf6 = { id: 1, name: 'SF6', longName: 'Street Fighter 6' }; +const ssbu = { id: 2, name: 'SSBU', longName: 'Smash Ultimate' }; + +function row(partial: Partial): ResultDTO { + return { gameId: 1, player: 'Bob', rank: 1, point: 10, tournamentUrl: 'SF6', ...partial }; +} + +function scored(event: Partial, results: ResultDTO[]): EventResult { + return { + event: { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...event }, + result: { games: [sf6, ssbu], results } + }; +} + +describe('aggregate — standings', () => { + it('sums points across events and counts distinct events and games', () => { + const stats = aggregate([ + scored({ id: 1 }, [row({ player: 'Bob', point: 10 })]), + scored({ id: 2 }, [row({ player: 'Bob', point: 7, gameId: 2, tournamentUrl: 'SSBU' })]) + ]); + + expect(stats.standings).toHaveLength(1); + expect(stats.standings[0]).toMatchObject({ + player: 'Bob', + points: 17, + entries: 2, + events: 2, + games: 2 + }); + }); + + it('merges names that differ only in case or padding, keeping the first spelling', () => { + const stats = aggregate([ + scored({}, [row({ player: 'Bob' }), row({ player: ' BOB ', rank: 2, point: 7 })]) + ]); + expect(stats.standings).toHaveLength(1); + expect(stats.standings[0]).toMatchObject({ player: 'Bob', points: 17, entries: 2 }); + }); + + it('counts podium places and their total', () => { + const stats = aggregate([ + scored({}, [ + row({ player: 'Bob', rank: 1 }), + row({ player: 'Bob', rank: 2 }), + row({ player: 'Bob', rank: 3 }), + row({ player: 'Bob', rank: 4 }) + ]) + ]); + expect(stats.standings[0]).toMatchObject({ + firsts: 1, + seconds: 1, + thirds: 1, + podiums: 3, + entries: 4 + }); + }); + + /* + * 999 is a "we could not place this player" bucket, not a placement. Reading it + * as one would report a best rank of 999 for anyone who only ever participated. + */ + it('never reads the 999 sentinel as a placement', () => { + const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]); + expect(stats.standings[0].bestRank).toBeNull(); + expect(stats.standings[0].podiums).toBe(0); + }); + + it('keeps the best real rank when a player also has unranked entries', () => { + const stats = aggregate([ + scored({}, [row({ player: 'Bob', rank: UNRANKED }), row({ player: 'Bob', rank: 4 })]) + ]); + expect(stats.standings[0].bestRank).toBe(4); + }); + + it('sorts by points, then firsts, then podiums, then name', () => { + const stats = aggregate([ + scored({}, [ + row({ player: 'Amy', point: 10, rank: 4 }), + row({ player: 'Zoe', point: 10, rank: 1 }), + row({ player: 'Cid', point: 20, rank: 4 }) + ]) + ]); + // Cid leads on points; Zoe beats Amy on firsts at equal points. + expect(stats.standings.map((s) => s.player)).toEqual(['Cid', 'Zoe', 'Amy']); + }); + + it('ignores rows with no player name', () => { + const stats = aggregate([scored({}, [row({ player: ' ' }), row({ player: 'Bob' })])]); + expect(stats.standings.map((s) => s.player)).toEqual(['Bob']); + }); +}); + +describe('aggregate — games', () => { + it('counts entries, distinct players and brackets per game', () => { + const stats = aggregate([ + scored({}, [ + row({ gameId: 1, player: 'Bob', tournamentUrl: 'SF6 A' }), + row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 A' }), + row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 B' }) + ]) + ]); + + const game = stats.games.find((g) => g.gameId === 1); + expect(game).toMatchObject({ name: 'SF6', entries: 3, players: 2, brackets: 2 }); + expect(game?.averageField).toBeCloseTo(1.5); + }); + + it('scopes brackets to their event, since bracket names repeat monthly', () => { + const stats = aggregate([ + scored({ id: 1 }, [row({ tournamentUrl: 'SF6' })]), + scored({ id: 2 }, [row({ tournamentUrl: 'SF6' })]) + ]); + expect(stats.games[0].brackets).toBe(2); + }); + + it('reports the highest scoring player of each game', () => { + const stats = aggregate([ + scored({}, [ + row({ gameId: 1, player: 'Bob', point: 5 }), + row({ gameId: 1, player: 'Amy', point: 12 }) + ]) + ]); + expect(stats.games[0]).toMatchObject({ topPlayer: 'Amy', topPoints: 12 }); + }); + + it('falls back to a placeholder name for a game the payload never described', () => { + const stats = aggregate([ + { + event: { id: 1, name: 'Ranking' }, + result: { games: [], results: [row({ gameId: 42 })] } + } + ]); + expect(stats.games[0]).toMatchObject({ gameId: 42, name: '#42' }); + }); +}); + +describe('aggregate — attendance and totals', () => { + it('orders attendance oldest first, so a chart reads left to right', () => { + const stats = aggregate([ + scored({ id: 2, name: 'June', date: '2026-06-01T00:00:00' }, [row({})]), + scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})]) + ]); + expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'June']); + }); + + it('places undated events last, keeping their incoming order', () => { + const stats = aggregate([ + scored({ id: 3, name: 'Undated A', date: undefined }, [row({})]), + scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})]), + scored({ id: 4, name: 'Undated B', date: undefined }, [row({})]) + ]); + expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'Undated A', 'Undated B']); + }); + + it('totals events, entries, distinct players and points across the scope', () => { + const stats = aggregate([ + scored({ id: 1 }, [row({ player: 'Bob', point: 10 }), row({ player: 'Amy', point: 7 })]), + scored({ id: 2 }, [row({ player: 'Bob', point: 3 })]) + ]); + expect(stats.totals).toMatchObject({ events: 2, entries: 3, players: 2, points: 20 }); + }); + + it('returns an empty aggregate for an empty scope', () => { + const stats = aggregate([]); + expect(stats.standings).toEqual([]); + expect(stats.games).toEqual([]); + expect(stats.attendance).toEqual([]); + expect(stats.totals).toMatchObject({ events: 0, entries: 0, players: 0, points: 0 }); + }); +}); + +describe('standingsCsv', () => { + it('writes the header and one quoted row per player', () => { + const stats = aggregate([scored({}, [row({ player: 'Bob', point: 10, rank: 1 })])]); + const lines = standingsCsv(stats.standings).split('\r\n'); + + expect(lines[0]).toBe( + '"Player";"Points";"Entries";"Events";"Games";"1st";"2nd";"3rd";"Podiums";"Best rank"' + ); + expect(lines[1]).toBe('"Bob";"10";"1";"1";"1";"1";"0";"0";"1";"1"'); + expect(lines[2]).toBe(''); + }); + + it('renders an absent best rank as an empty field rather than "null"', () => { + const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]); + expect(standingsCsv(stats.standings)).toContain(';""\r\n'); + }); +}); + +describe('formatMonth', () => { + it('is empty for a missing or unparseable date', () => { + expect(formatMonth(null)).toBe(''); + expect(formatMonth('not a date')).toBe(''); + }); + + it('renders a month and year', () => { + expect(formatMonth('2026-08-05T00:00:00')).toMatch(/2026/); + }); +}); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts index 226db3c..a978acb 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/aggregate.ts @@ -1,4 +1,6 @@ import type { EventDTO, GameDTO, TournamentsResultDTO } from '$lib/api/schema-helpers'; +import { toCsv } from '$lib/csv'; +import { compareByDate } from '$lib/events'; /** * Aggregation for the Statistiques page. Pure: it takes results already fetched @@ -267,16 +269,10 @@ function buildGameSummaries(games: Map): GameSummary[] function sortByDate(attendance: EventAttendance[]): EventAttendance[] { return attendance .map((entry, index) => ({ entry, index })) - .sort((a, b) => { - const aTime = a.entry.date ? Date.parse(a.entry.date) : NaN; - const bTime = b.entry.date ? Date.parse(b.entry.date) : NaN; - const aOk = Number.isFinite(aTime); - const bOk = Number.isFinite(bTime); - if (aOk && bOk) return aTime - bTime || a.index - b.index; - if (aOk) return -1; - if (bOk) return 1; - return a.index - b.index; - }) + .sort( + (a, b) => + compareByDate(a.entry.date, b.entry.date, 'asc') || a.index - b.index + ) .map(({ entry }) => entry); } @@ -288,9 +284,8 @@ export function formatMonth(date: string | null): string { return new Date(parsed).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); } -/** Semicolon-separated, quoted — same dialect as the tournaments CSV export. */ +/** Semicolon-separated, quoted — the dialect `$lib/csv` defines. */ export function standingsCsv(standings: PlayerStanding[]): string { - const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`; const header = [ 'Player', 'Points', @@ -303,26 +298,20 @@ export function standingsCsv(standings: PlayerStanding[]): string { 'Podiums', 'Best rank' ]; - const lines = [header.map(quote).join(';')]; - for (const row of standings) { - lines.push( - [ - row.player, - row.points, - row.entries, - row.events, - row.games, - row.firsts, - row.seconds, - row.thirds, - row.podiums, - row.bestRank ?? '' - ] - .map(quote) - .join(';') - ); - } - - return lines.join('\r\n') + '\r\n'; + return toCsv( + header, + standings.map((row) => [ + row.player, + row.points, + row.entries, + row.events, + row.games, + row.firsts, + row.seconds, + row.thirds, + row.podiums, + row.bestRank ?? '' + ]) + ); } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/load.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/load.test.ts new file mode 100644 index 0000000..5a0dc48 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/statistics/load.test.ts @@ -0,0 +1,122 @@ +import { ApiError } from '$lib/api/client'; +import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/* + * `loadEventResults` exists to contain the blast radius of one bad event, so what + * matters here is the failure behaviour: a broken event must not take the others + * with it, while an abort or a 401 must stop everything. + */ + +// Typed so the assertions below read the call arguments without falling back to `any`. +const getResults = vi.hoisted(() => + vi.fn<(ids: number[], options?: { signal?: AbortSignal }) => Promise>() +); +vi.mock('$lib/api/tournaments', () => ({ getResults })); + +const { loadEventResults } = await import('./load'); + +function events(count: number): EventDTO[] { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + name: `Event ${i + 1}`, + date: undefined + })); +} + +beforeEach(() => { + getResults.mockReset(); +}); + +describe('loadEventResults', () => { + it('requests one event at a time and returns them all', async () => { + getResults.mockImplementation((ids: number[]) => + Promise.resolve({ results: [{ player: `P${ids[0]}` }] }) + ); + + const outcome = await loadEventResults(events(3)); + + expect(outcome.loaded).toHaveLength(3); + expect(outcome.failed).toEqual([]); + // One id per call — batching is what this module exists to avoid. + expect(getResults.mock.calls.map((c) => c[0])).toEqual([[1], [2], [3]]); + }); + + it('skips events with no id rather than requesting undefined', async () => { + getResults.mockResolvedValue({ results: [] }); + await loadEventResults([{ id: 1, name: 'A' }, { name: 'B' }]); + expect(getResults).toHaveBeenCalledTimes(1); + }); + + it('isolates a failing event and reports it, keeping the rest', async () => { + getResults.mockImplementation((ids: number[]) => + ids[0] === 2 + ? Promise.reject(new ApiError(500, 'Bracket has no rank-1 row')) + : Promise.resolve({ results: [] }) + ); + + const outcome = await loadEventResults(events(3)); + + expect(outcome.loaded).toHaveLength(2); + expect(outcome.failed).toHaveLength(1); + expect(outcome.failed[0].event.id).toBe(2); + expect(outcome.failed[0].message).toBe('Bracket has no rank-1 row'); + }); + + it('stops everything on a 401 — retrying N times would just burn requests', async () => { + getResults.mockRejectedValue(new ApiError(401, 'Unauthorized')); + await expect(loadEventResults(events(3))).rejects.toThrow(ApiError); + }); + + it('propagates an abort rather than recording it as a failed event', async () => { + getResults.mockRejectedValue(new DOMException('Aborted', 'AbortError')); + await expect(loadEventResults(events(2))).rejects.toThrow(DOMException); + }); + + it('makes no request at all when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + const outcome = await loadEventResults(events(3), { signal: controller.signal }); + + expect(getResults).not.toHaveBeenCalled(); + expect(outcome.loaded).toEqual([]); + }); + + it('reports progress once per settled event, failures included', async () => { + getResults.mockImplementation((ids: number[]) => + ids[0] === 2 ? Promise.reject(new ApiError(500, 'boom')) : Promise.resolve({ results: [] }) + ); + + const seen: number[] = []; + await loadEventResults(events(3), { onProgress: (done, total) => { + expect(total).toBe(3); + seen.push(done); + } }); + + expect(seen).toEqual([1, 2, 3]); + }); + + it('never runs more requests at once than the concurrency allows', async () => { + let inFlight = 0; + let peak = 0; + getResults.mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await Promise.resolve(); + inFlight--; + return { results: [] }; + }); + + await loadEventResults(events(10), { concurrency: 3 }); + + expect(peak).toBeLessThanOrEqual(3); + expect(getResults).toHaveBeenCalledTimes(10); + }); + + it('handles an empty scope without spawning workers', async () => { + const outcome = await loadEventResults([]); + expect(outcome).toEqual({ loaded: [], failed: [] }); + expect(getResults).not.toHaveBeenCalled(); + }); +}); diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts index c7cfd83..7aac5c1 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/stores/session.svelte.ts @@ -3,27 +3,46 @@ import type { AuthenticatedUser } from '$lib/api/schema-helpers'; const STORAGE_KEY = 'ladose.session'; -/** Restores the session written by a previous visit, discarding it if the JWT expired. */ +/** + * Restores the session written by a previous visit, discarding it if the JWT expired. + * + * Every localStorage access sits inside the try: reading the property at all throws + * in Safari private mode and with cookies blocked, and this runs in the `#user` + * field initialiser, so an escaping error would fail module init and blank the app. + */ function restore(): AuthenticatedUser | null { if (!browser) return null; - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const user = JSON.parse(raw) as AuthenticatedUser; if (!user?.token || !user.username) return null; + // `roles` is optional in the DTO but `isAdmin` calls `.some` on it. A + // hand-edited entry could hold a string, so drop anything that is neither + // absent nor an array instead of throwing at render time. + if (user.roles != null && !Array.isArray(user.roles)) return null; if (isExpired(user)) { - localStorage.removeItem(STORAGE_KEY); + forget(); return null; } return user; } catch { - localStorage.removeItem(STORAGE_KEY); + forget(); return null; } } +/** Best-effort removal: storage being unavailable is not worth failing a sign-out over. */ +function forget(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* localStorage throws in Safari private mode and with cookies blocked */ + } +} + /** The API issues short-lived tokens (16 min), so treat a lapsed one as logged out. */ function isExpired(user: AuthenticatedUser): boolean { if (!user.expire) return false; @@ -72,14 +91,24 @@ class Session { return full.length > 0 ? full : this.#user.username; } + /** + * The in-memory session is the source of truth; localStorage only survives a + * refresh. Persisting is therefore best-effort — a storage failure must not + * surface as a failed sign-in when authentication actually succeeded. + */ start(user: AuthenticatedUser): void { this.#user = user; - if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); + if (!browser) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); + } catch { + /* localStorage throws in Safari private mode and with cookies blocked */ + } } clear(): void { this.#user = null; - if (browser) localStorage.removeItem(STORAGE_KEY); + if (browser) forget(); } } diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/results.test.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/results.test.ts new file mode 100644 index 0000000..1d4ef85 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/results.test.ts @@ -0,0 +1,149 @@ +import type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers'; +import { describe, expect, it } from 'vitest'; +import { buildCsv, buildHtml, buildRanking, playedGames, rankingHeader, resultsForGame } from './results'; + +const sf6: GameDTO = { id: 1, name: 'SF6', longName: 'Street Fighter 6', order: 1 }; +const ssbu: GameDTO = { id: 2, name: 'SSBU', longName: 'Smash Ultimate', order: 2 }; + +function result(partial: Partial): ResultDTO { + return { gameId: 1, player: 'Bob', rank: 1, point: 10, ...partial }; +} + +function payload(partial: Partial = {}): TournamentsResultDTO { + return { + games: [sf6, ssbu], + participents: [{ name: 'Bob' }, { name: 'Alice' }], + results: [ + result({ gameId: 1, player: 'Bob', rank: 1, point: 10 }), + result({ gameId: 1, player: 'Alice', rank: 2, point: 7 }), + result({ gameId: 2, player: 'Alice', rank: 1, point: 10 }) + ], + ...partial + }; +} + +describe('playedGames', () => { + it('returns only games that have results, in Game.Order', () => { + const games = playedGames(payload({ games: [ssbu, sf6] })); + expect(games.map((g) => g.name)).toEqual(['SF6', 'SSBU']); + }); + + it('drops a game nobody entered', () => { + const only = playedGames(payload({ results: [result({ gameId: 1 })] })); + expect(only.map((g) => g.id)).toEqual([1]); + }); + + it('handles a null payload', () => { + expect(playedGames(null)).toEqual([]); + }); +}); + +describe('buildRanking', () => { + it('sums points per game and sorts by total, highest first', () => { + const table = buildRanking(payload()); + expect(table.rows.map((r) => [r.player, r.total])).toEqual([ + ['Alice', 17], + ['Bob', 10] + ]); + }); + + it('keeps points index-aligned with games — the whole grid depends on it', () => { + const table = buildRanking(payload()); + expect(table.games.map((g) => g.name)).toEqual(['SF6', 'SSBU']); + + const alice = table.rows.find((r) => r.player === 'Alice'); + expect(alice?.points).toEqual([7, 10]); + expect(alice?.points).toHaveLength(table.games.length); + }); + + it('merges spellings that differ only in case', () => { + const table = buildRanking( + payload({ + participents: [{ name: 'Bob' }, { name: 'BOB' }], + results: [ + result({ player: 'Bob', point: 10 }), + result({ player: 'BOB', point: 5, rank: 2 }) + ] + }) + ); + expect(table.rows).toHaveLength(1); + expect(table.rows[0]).toMatchObject({ player: 'Bob', total: 15 }); + }); + + it('breaks a tie on total by player name', () => { + const table = buildRanking( + payload({ + participents: [{ name: 'Zoe' }, { name: 'Amy' }], + results: [result({ player: 'Zoe' }), result({ player: 'Amy' })] + }) + ); + expect(table.rows.map((r) => r.player)).toEqual(['Amy', 'Zoe']); + }); + + it('ignores unnamed participants', () => { + const table = buildRanking(payload({ participents: [{ name: ' ' }, { name: 'Bob' }] })); + expect(table.rows.map((r) => r.player)).toEqual(['Bob']); + }); +}); + +describe('resultsForGame', () => { + it('returns one game, best rank first', () => { + const rows = resultsForGame(payload(), 1); + expect(rows.map((r) => r.player)).toEqual(['Bob', 'Alice']); + }); + + it('is empty for no selection', () => { + expect(resultsForGame(payload(), null)).toEqual([]); + }); +}); + +describe('buildCsv', () => { + it('uses the same header as the spreadsheet tab', () => { + const table = buildRanking(payload()); + expect(rankingHeader(table)).toEqual(['Players', 'SF6', 'SSBU', 'Total']); + expect(buildCsv(table).split('\r\n')[0]).toBe('"Players";"SF6";"SSBU";"Total"'); + }); + + it('writes one row per player with a trailing newline', () => { + expect(buildCsv(buildRanking(payload()))).toBe( + '"Players";"SF6";"SSBU";"Total"\r\n"Alice";"7";"10";"17"\r\n"Bob";"10";"0";"10"\r\n' + ); + }); +}); + +describe('buildHtml', () => { + it('escapes interpolated values — this feeds an {@html} block', () => { + const html = buildHtml( + payload({ + games: [{ ...sf6, longName: 'Fight & Win' }], + results: [result({ gameId: 1, player: '' })] + }) + ); + expect(html).not.toContain(' @@ -122,6 +107,7 @@
+ {#if guard.ready}

Users

@@ -168,7 +154,7 @@ (you) {/if} - {fullName(user) || '—'} + {displayName(user) || '—'} {#if user.roles?.length} {#each user.roles as role (role)} @@ -290,4 +276,5 @@ + {/if}

diff --git a/LaDOSE.Src/LaDOSE.WebApp/vite.config.ts b/LaDOSE.Src/LaDOSE.WebApp/vite.config.ts index 40a8e78..7a1d634 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/vite.config.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/vite.config.ts @@ -1,7 +1,7 @@ import adapter from '@sveltejs/adapter-static'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [ @@ -17,5 +17,13 @@ export default defineConfig({ // `fallback` hands every unknown path to the client-side router. adapter: adapter({ fallback: 'index.html' }) }) - ] + ], + + test: { + // The pure modules only — `$lib/statistics`, `$lib/tournaments` and the small + // helpers beside them. Everything under `ui/` or `routes/` needs a component + // harness, which this project deliberately does not have yet. + include: ['src/**/*.test.ts'], + environment: 'node' + } });