diff --git a/.gitignore b/.gitignore index 5ef6a52..a84cded 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.gstack/ diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..22c2e1b --- /dev/null +++ b/TODOS.md @@ -0,0 +1,29 @@ +# TODOs + +## Garmin API Approval Track + +**What:** Track official Garmin Connect Developer Program access, licensing, and brand/compliance requirements. + +**Why:** The full AI workspace will support official Garmin sync plus manual/FIT import fallback. Garmin approval can block production access, so this needs to run as a visible business/engineering dependency rather than a hidden implementation assumption. + +**Pros:** Avoids surprise launch blockers and keeps product claims aligned with Garmin's rules. + +**Cons:** Not pure coding work; approval may require waiting on Garmin or changing the provider strategy. + +**Context:** /plan-eng-review decision 2A chose official Garmin + manual/FIT fallback. The fallback keeps development moving, but production Garmin sync still depends on approved access. + +**Depends on / blocked by:** Garmin developer account/application and any commercial license requirements. + +## Garmin/FIT Import Privacy Controls + +**What:** Add explicit user-facing privacy controls for which Garmin/FIT data classes can be imported and used by AI. + +**Why:** The full AI workspace will store raw payloads, normalized summaries, and compact AI snapshots. Garmin/FIT data may include sensitive sleep, stress, heart-rate, recovery, and activity signals, so users need visible control over what is imported and what the AI can use. + +**Pros:** Improves trust, makes AI data use explainable, and forces clean partial-data handling. + +**Cons:** Adds settings complexity and more tests for missing or disabled data classes. + +**Context:** /plan-eng-review decision 3A chose raw archive + normalized summaries + AI snapshots. That architecture needs a clear privacy boundary so the AI workspace does not feel like it is silently using every health signal. + +**Depends on / blocked by:** Final provider data schema and AI snapshot schema. diff --git a/jest.config.ts b/jest.config.ts index e0d5efa..79c40ab 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,10 +3,11 @@ import type { Config } from 'jest' const config: Config = { preset: 'ts-jest', testEnvironment: 'node', + setupFilesAfterEnv: ['/jest.setup.ts'], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, - testMatch: ['**/__tests__/**/*.test.ts'], + testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx'], } export default config diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/package-lock.json b/package-lock.json index 73fc291..20de9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,16 @@ "dependencies": { "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.105.1", + "@types/qrcode": "^1.5.6", "next": "16.2.4", + "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/node": "^20", "@types/react": "^19", @@ -23,12 +27,20 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "jest": "^30.3.0", + "jest-environment-jsdom": "^30.4.1", "jest-environment-node": "^30.3.0", "tailwindcss": "^4", "ts-jest": "^29.4.9", "typescript": "^5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -42,6 +54,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -484,6 +517,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -539,6 +582,123 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1525,6 +1685,221 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", @@ -2078,9 +2453,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2466,20 +2841,157 @@ "tailwindcss": "4.2.4" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "node_modules/@testing-library/dom/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", @@ -2567,6 +3079,18 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2590,6 +3114,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2607,6 +3140,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2618,6 +3152,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3240,6 +3781,16 @@ "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", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3290,7 +3841,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3817,7 +4367,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3999,7 +4548,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4012,7 +4560,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -4057,6 +4604,27 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4071,6 +4639,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4143,6 +4725,22 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4211,6 +4809,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4231,6 +4839,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4244,6 +4858,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4307,6 +4928,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5253,7 +5887,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5596,6 +6229,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5603,6 +6249,34 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5622,6 +6296,19 @@ "node": ">=20.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5679,6 +6366,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5908,7 +6605,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6010,6 +6706,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6505,80 +7208,290 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", - "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0" + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", - "picomatch": "^4.0.3", - "walker": "^1.0.8" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" } }, - "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-leak-detector": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", - "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "node_modules/jest-environment-jsdom/node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.3.0" + "@types/node": "*", + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", @@ -6972,6 +7885,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7417,6 +8371,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7524,6 +8488,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7749,6 +8723,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7970,7 +8951,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8015,11 +8995,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "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" @@ -8174,6 +9166,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8297,6 +9298,182 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8348,6 +9525,36 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8396,12 +9603,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -8480,6 +9692,13 @@ "node": ">=0.10.0" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8559,6 +9778,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8575,6 +9814,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9159,6 +10404,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -9221,6 +10479,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -9344,6 +10609,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9364,6 +10649,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9771,6 +11082,19 @@ "node": ">=10.12.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -9781,6 +11105,54 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9864,6 +11236,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -10040,6 +11418,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 593f641..a8b5560 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,16 @@ "dependencies": { "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.105.1", + "@types/qrcode": "^1.5.6", "next": "16.2.4", + "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/node": "^20", "@types/react": "^19", @@ -26,6 +30,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "jest": "^30.3.0", + "jest-environment-jsdom": "^30.4.1", "jest-environment-node": "^30.3.0", "tailwindcss": "^4", "ts-jest": "^29.4.9", diff --git a/public/avatars/cycling.png b/public/avatars/cycling.png new file mode 100644 index 0000000..ca05198 Binary files /dev/null and b/public/avatars/cycling.png differ diff --git a/public/avatars/finish.png b/public/avatars/finish.png new file mode 100644 index 0000000..340bbaf Binary files /dev/null and b/public/avatars/finish.png differ diff --git a/public/avatars/hiker.png b/public/avatars/hiker.png new file mode 100644 index 0000000..e0fc25f Binary files /dev/null and b/public/avatars/hiker.png differ diff --git a/public/avatars/medal.png b/public/avatars/medal.png new file mode 100644 index 0000000..64eda77 Binary files /dev/null and b/public/avatars/medal.png differ diff --git a/public/avatars/mountain.png b/public/avatars/mountain.png new file mode 100644 index 0000000..d3739e6 Binary files /dev/null and b/public/avatars/mountain.png differ diff --git a/public/avatars/runner.png b/public/avatars/runner.png new file mode 100644 index 0000000..aebcfd9 Binary files /dev/null and b/public/avatars/runner.png differ diff --git a/public/avatars/swimming.png b/public/avatars/swimming.png new file mode 100644 index 0000000..6cbe505 Binary files /dev/null and b/public/avatars/swimming.png differ diff --git a/public/avatars/triathlon.png b/public/avatars/triathlon.png new file mode 100644 index 0000000..be1fb3b Binary files /dev/null and b/public/avatars/triathlon.png differ diff --git a/src/__tests__/AIWorkspaceView.test.tsx b/src/__tests__/AIWorkspaceView.test.tsx new file mode 100644 index 0000000..cf28858 --- /dev/null +++ b/src/__tests__/AIWorkspaceView.test.tsx @@ -0,0 +1,263 @@ +/** + * @jest-environment jsdom + */ +import { fireEvent, render, screen } from '@testing-library/react' +import { AIWorkspaceView } from '@/components/AIWorkspaceView' +import { buildEmptyAIWorkspaceData } from '@/lib/aiWorkspace' +import type { AIWorkspaceData } from '@/lib/types' + +jest.mock('@/lib/aiWorkspaceActions', () => ({ + saveAIWorkspaceLayout: jest.fn(), +})) + +test('renders an empty Strava-first AI workspace without requiring imported data', () => { + render() + + expect(screen.getByRole('heading', { name: 'Race OS' })).toBeInTheDocument() + expect(screen.getByText('No Strava data yet')).toBeInTheDocument() + expect(screen.getByText('No race calendar yet')).toBeInTheDocument() + expect(screen.getByText('Waiting for training snapshot')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Connect Strava' })).toHaveAttribute('href', '/api/strava/connect') + expect(screen.getByTestId('race-os-board')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Calendar$/ })).toHaveAttribute('aria-pressed', 'true') +}) + +test('shows a migration-safe message when layout save is attempted before AI tables exist', () => { + render() + + expect(screen.getByText('AI schema pending. Layout saves after migration.')).toBeInTheDocument() +}) + +test('switching display presets updates the saved layout fields', () => { + const { container } = render() + const activePreset = container.querySelector('input[name="active_preset"]') as HTMLInputElement + const widgetOrder = container.querySelector('input[name="widget_order"]') as HTMLInputElement + const panelLayout = container.querySelector('input[name="panel_layout"]') as HTMLInputElement + + expect(activePreset.value).toBe('calendar') + expect(widgetOrder.value.startsWith('race_calendar')).toBe(true) + expect(panelLayout.value).toContain('medal_goals') + + fireEvent.click(screen.getByRole('button', { name: /^Insights$/ })) + + expect(activePreset.value).toBe('insights') + expect(widgetOrder.value.startsWith('insights,data_sources')).toBe(true) + expect(panelLayout.value).toContain('ai_coach') +}) + +test('users can add dynamic Race OS panels from the template tray', () => { + const { container } = render() + const panelLayout = container.querySelector('input[name="panel_layout"]') as HTMLInputElement + + fireEvent.click(screen.getByRole('button', { name: '+ Panel' })) + fireEvent.click(screen.getByRole('button', { name: /AI Coach Agent/ })) + + expect(screen.getAllByText('AI Coach').length).toBeGreaterThan(0) + expect(panelLayout.value).toContain('ai_coach') +}) + +test('moving a Race OS panel repacks neighboring panels into open grid slots', () => { + const { container } = render() + const panelLayout = container.querySelector('input[name="panel_layout"]') as HTMLInputElement + + fireEvent.click(screen.getByRole('button', { name: 'Move Race Calendar right' })) + + const layout = JSON.parse(panelLayout.value) as Array<{ + id: string + x: number + y: number + w: number + h: number + }> + const movedPanel = layout.find((panel) => panel.id === 'race-calendar') + + expect(movedPanel?.x).toBe(1) + expect(hasOverlappingPanels(layout)).toBe(false) +}) + +test('resizing a Race OS panel tracks fractional grid movement while dragging', () => { + const restorePointerCapture = mockPointerCapture() + const rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 1200, + height: 760, + top: 0, + right: 1200, + bottom: 760, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) + const { container } = render() + const panelLayout = container.querySelector('input[name="panel_layout"]') as HTMLInputElement + + fireEvent( + screen.getByRole('button', { name: 'Resize Race Calendar' }), + new MouseEvent('pointerdown', { bubbles: true, clientX: 0, clientY: 0 }), + ) + fireEvent(window, new MouseEvent('pointermove', { bubbles: true, clientX: 51, clientY: 0 })) + + const layout = JSON.parse(panelLayout.value) as Array<{ kind: string; w: number }> + const resizedPanel = layout.find((panel) => panel.kind === 'race_calendar') + + expect(resizedPanel?.w).toBeGreaterThan(7) + expect(resizedPanel?.w).toBeLessThan(8) + + fireEvent(window, new MouseEvent('pointerup', { bubbles: true })) + rectSpy.mockRestore() + restorePointerCapture() +}) + +test('shows live Strava activity signal in the AI coach panel', () => { + render() + + expect(screen.getByText('Strava read is live')).toBeInTheDocument() + expect(screen.getByText('Morning Tempo')).toBeInTheDocument() + expect(screen.getAllByText('12.0km').length).toBeGreaterThan(0) + expect(screen.getByText(/achievement-heavy sessions/i)).toBeInTheDocument() +}) + +test('shows Discover races marked as attending in the Race Calendar panel', () => { + render() + + expect(screen.getByText('Standard Chartered Singapore Marathon')).toBeInTheDocument() + expect(screen.getByText('i\'m in')).toBeInTheDocument() + expect(screen.getByText(/Singapore, SG/)).toBeInTheDocument() +}) + +test('renders imported activity signal and calendar proposals', () => { + const data: AIWorkspaceData = { + ...buildEmptyAIWorkspaceData('user-1'), + providerConnections: [ + { + id: 'conn-1', + user_id: 'user-1', + provider: 'strava', + status: 'connected', + last_sync_at: '2026-05-10T08:00:00.000Z', + }, + ], + activitySummaries: [ + { + id: 'activity-1', + user_id: 'user-1', + provider: 'strava', + activity_date: '2026-05-10', + title: 'Long Run', + sport_type: 'running', + duration_seconds: 7200, + distance_meters: 21097, + }, + ], + snapshots: [ + { + id: 'snapshot-1', + user_id: 'user-1', + period_start: '2026-05-01', + period_end: '2026-05-14', + status: 'ready', + snapshot: { + focus_blocks: ['Aerobic base'], + guardrails: ['Keep easy days easy'], + }, + generated_at: '2026-05-15T00:00:00.000Z', + }, + ], + planProposals: [ + { + id: 'proposal-1', + user_id: 'user-1', + status: 'draft', + title: 'Gold Coast Marathon Build', + rationale: 'Mileage is stable.', + plan: {}, + safety_flags: [], + created_at: '2026-05-15T00:00:00.000Z', + }, + ], + } + + render() + + expect(screen.getByText('STRAVA')).toBeInTheDocument() + expect(screen.getByText('Long Run')).toBeInTheDocument() + expect(screen.getByText('21.1km')).toBeInTheDocument() + expect(screen.getByText('Aerobic base')).toBeInTheDocument() + expect(screen.getAllByText('Gold Coast Marathon Build').length).toBeGreaterThan(0) +}) + +function hasOverlappingPanels(panels: Array<{ x: number; y: number; w: number; h: number }>) { + return panels.some((panel, index) => panels.slice(index + 1).some((other) => ( + panel.x < other.x + other.w + && panel.x + panel.w > other.x + && panel.y < other.y + other.h + && panel.y + panel.h > other.y + ))) +} + +function mockPointerCapture() { + const original = HTMLElement.prototype.setPointerCapture + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: jest.fn(), + }) + return () => { + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: original, + }) + } +} diff --git a/src/__tests__/DiscoverPage.test.tsx b/src/__tests__/DiscoverPage.test.tsx new file mode 100644 index 0000000..aebc573 --- /dev/null +++ b/src/__tests__/DiscoverPage.test.tsx @@ -0,0 +1,161 @@ +/** + * @jest-environment jsdom + */ +import { render, screen } from '@testing-library/react' +import DiscoverPage from '@/app/discover/page' +import { getOptionalUser } from '@/lib/auth' +import { db } from '@/lib/db' +import type { DiscoverRace } from '@/lib/types' +import type { ReactElement } from 'react' + +jest.mock('@/lib/auth', () => ({ + getOptionalUser: jest.fn(), +})) + +jest.mock('@/lib/db', () => ({ + db: { + discover: { + findAll: jest.fn(), + getAttendingIds: jest.fn(), + }, + races: { + findByUser: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/discover', () => ({ + attendRace: jest.fn(), + unattendRace: jest.fn(), +})) + +const mockGetOptionalUser = getOptionalUser as jest.Mock +const mockFindAll = db.discover.findAll as jest.Mock +const mockGetAttendingIds = db.discover.getAttendingIds as jest.Mock +const mockFindUserRaces = db.races.findByUser as jest.Mock + +type DiscoverPageProps = { + searchParams: Promise<{ category?: string }> +} + +const makeRace = (overrides: Partial = {}): DiscoverRace => ({ + id: 'race-1', + name: 'Berlin Marathon', + date: '2099-09-26', + location_city: 'Berlin', + location_country: 'DE', + sport_type: 'running', + distance_category: 'full', + registration_url: 'https://example.com/berlin', + created_at: '2026-01-01', + ...overrides, +}) + +beforeEach(() => { + jest.clearAllMocks() + mockGetOptionalUser.mockResolvedValue(null) + mockGetAttendingIds.mockResolvedValue(new Set()) + mockFindUserRaces.mockResolvedValue([]) +}) + +async function renderDiscover(searchParams: { category?: string } = {}) { + const Page = DiscoverPage as unknown as (props: DiscoverPageProps) => Promise + render(await Page({ searchParams: Promise.resolve(searchParams) })) +} + +test('visitor can filter Discover to marathon races', async () => { + mockFindAll.mockResolvedValue([ + makeRace(), + makeRace({ + id: 'race-2', + name: 'Singapore Triathlon', + sport_type: 'triathlon', + distance_category: 'olympic', + registration_url: 'https://example.com/singapore-tri', + }), + ]) + + await renderDiscover({ category: 'marathon' }) + + expect(screen.getByText('Berlin Marathon')).toBeInTheDocument() + expect(screen.queryByText('Singapore Triathlon')).not.toBeInTheDocument() +}) + +test('visitor can navigate Discover categories from tabs', async () => { + mockFindAll.mockResolvedValue([makeRace()]) + + await renderDiscover({ category: 'marathon' }) + + expect(screen.getByRole('link', { name: 'All' })).toHaveAttribute('href', '/discover') + expect(screen.getByRole('link', { name: 'Marathon' })).toHaveAttribute('href', '/discover?category=marathon') + expect(screen.getByRole('link', { name: 'Marathon' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('link', { name: 'Triathlon' })).toHaveAttribute('href', '/discover?category=triathlon') + expect(screen.getByRole('link', { name: 'Trail' })).toHaveAttribute('href', '/discover?category=trail') +}) + +test('visitor can filter Discover to triathlon races', async () => { + mockFindAll.mockResolvedValue([ + makeRace(), + makeRace({ + id: 'race-2', + name: 'Singapore Triathlon', + sport_type: 'triathlon', + distance_category: 'olympic', + registration_url: 'https://example.com/singapore-tri', + }), + ]) + + await renderDiscover({ category: 'triathlon' }) + + expect(screen.getByText('Singapore Triathlon')).toBeInTheDocument() + expect(screen.queryByText('Berlin Marathon')).not.toBeInTheDocument() +}) + +test('visitor can filter Discover to trail races', async () => { + mockFindAll.mockResolvedValue([ + makeRace(), + makeRace({ + id: 'race-3', + name: 'Bali Trail Ultra', + sport_type: 'running', + distance_category: 'ultra', + description: 'Mountain trail race', + registration_url: 'https://example.com/bali-trail', + }), + ]) + + await renderDiscover({ category: 'trail' }) + + expect(screen.getByText('Bali Trail Ultra')).toBeInTheDocument() + expect(screen.queryByText('Berlin Marathon')).not.toBeInTheDocument() +}) + +test('visitor sees scraped races when the database has not been seeded yet', async () => { + mockFindAll.mockResolvedValue([]) + + await renderDiscover() + + expect(screen.getByText('TCS Sydney Marathon')).toBeInTheDocument() + expect(screen.getByText('DATEV Challenge Roth')).toBeInTheDocument() + expect(screen.getByText('HOKA UTMB Mont-Blanc')).toBeInTheDocument() +}) + +test('visitor still sees scraped races when Supabase reads fail', async () => { + mockGetOptionalUser.mockRejectedValue(new Error('supabase unavailable')) + mockFindAll.mockRejectedValue(new Error('supabase unavailable')) + + await renderDiscover() + + expect(screen.getByText('TCS Sydney Marathon')).toBeInTheDocument() + expect(screen.queryByText('No upcoming races listed yet')).not.toBeInTheDocument() +}) + +test('logged-in users can mark seed-only races as attending', async () => { + mockGetOptionalUser.mockResolvedValue({ id: 'user-1' }) + mockFindAll.mockResolvedValue([]) + + await renderDiscover() + + expect(screen.getByText('TCS Sydney Marathon')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: "I'M IN" }).length).toBeGreaterThan(0) +}) diff --git a/src/__tests__/PassportView.test.tsx b/src/__tests__/PassportView.test.tsx new file mode 100644 index 0000000..bbee400 --- /dev/null +++ b/src/__tests__/PassportView.test.tsx @@ -0,0 +1,106 @@ +/** + * @jest-environment jsdom + */ +import { render, screen } from '@testing-library/react' +import { buildPassportSummary } from '@/lib/passport' +import { PassportView } from '@/components/PassportView' +import type { Badge, EventBadgeCollectible, Profile, Race } from '@/lib/types' + +const profile: Profile = { + id: 'user-1', + username: 'pawan', + display_name: 'Pawan Patil', + avatar_key: 'tri-kit', + created_at: '2026-01-01', +} + +const race: Race = { + id: 'race-1', + name: 'Singapore Marathon', + date: '2025-12-01', + location_country: 'SG', + location_city: 'Singapore', + sport_type: 'running', + distance_category: 'full', + finish_time: '03:30:00', +} + +const badge: Badge = { + key: 'first_finish', + name: 'First Finish', + category: 'milestone', + earned_at: '2025-12-01', +} + +const eventBadge: EventBadgeCollectible = { + id: 'claim-1', + event_badge_id: 'event-badge-1', + badge_name: 'Finish Village Participant', + description: 'Awarded for event participation.', + artwork_key: 'event-stamp', + claimed_at: '2026-05-01T10:00:00.000Z', + event_name: 'Marina Bay 10K', + event_date: '2026-05-01', + location_city: 'Singapore', + location_country: 'SG', + organizer_name: 'Marka Events', +} + +test('renders a private empty passport with starter prompts', () => { + const summary = buildPassportSummary({ profile, races: [], badges: [], visibility: 'private' }) + + render() + + expect(screen.getByRole('heading', { name: 'Passport' })).toBeInTheDocument() + expect(screen.getByText('Start your passport')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Log Race/ })).toHaveAttribute('href', '/races/new') + expect(screen.queryByText('Built with Marka')).not.toBeInTheDocument() +}) + +test('renders a public passport without private calls to action', () => { + const summary = buildPassportSummary({ + profile, + races: [race], + badges: [badge], + visibility: 'public', + }) + + render() + + expect(screen.getByRole('heading', { name: 'PAWAN PATIL.' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Pawan Patil avatar' })).toBeInTheDocument() + expect(screen.getAllByText('Singapore Marathon')).toHaveLength(2) + expect(screen.getAllByText('03:30:00')).toHaveLength(2) + expect(screen.getByText('Built with Marka')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /View Public Passport/ })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Log Race/ })).not.toBeInTheDocument() +}) + +test('renders a fallback location when a race has no city or country', () => { + const summary = buildPassportSummary({ + profile, + races: [{ ...race, location_city: '', location_country: '' }], + badges: [], + visibility: 'public', + }) + + render() + + expect(screen.getByText(/Location TBD/)).toBeInTheDocument() +}) + +test('renders organizer participation collectibles as a separate passport section', () => { + const summary = buildPassportSummary({ + profile, + races: [], + badges: [], + event_badges: [eventBadge], + visibility: 'private', + }) + + render() + + expect(screen.getByText(/Event Collectibles/)).toBeInTheDocument() + expect(screen.getByText('Finish Village Participant')).toBeInTheDocument() + expect(screen.getByText('Marka Events')).toBeInTheDocument() +}) diff --git a/src/__tests__/ProfileAvatarPicker.test.tsx b/src/__tests__/ProfileAvatarPicker.test.tsx new file mode 100644 index 0000000..a1dda25 --- /dev/null +++ b/src/__tests__/ProfileAvatarPicker.test.tsx @@ -0,0 +1,73 @@ +/** + * @jest-environment jsdom + */ +import { fireEvent, render, screen } from '@testing-library/react' +import { ProfileAvatarPicker } from '@/components/ProfileAvatarPicker' + +test('avatar choices stay hidden until the current avatar is clicked', () => { + render() + + expect(screen.queryByRole('region', { name: 'Choose avatar' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Change/ })) + + expect(screen.getByRole('region', { name: 'Choose avatar' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Choose Mountain/ })).toBeInTheDocument() +}) + +test('selecting an avatar updates the submitted avatar key', () => { + const { container } = render() + + fireEvent.click(screen.getByRole('button', { name: /Change/ })) + fireEvent.click(screen.getByRole('button', { name: /Choose Hiker/ })) + + expect(container.querySelector('input[name="avatar_key"]')?.value).toBe('tri-kit') + expect(screen.queryByRole('region', { name: 'Choose avatar' })).not.toBeInTheDocument() +}) + +test('header avatar stays compact while submitting with the profile form', () => { + const { container } = render( + , + ) + + const input = container.querySelector('input[name="avatar_key"]') + + expect(input?.value).toBe('trail-summit') + expect(input?.getAttribute('form')).toBe('profile-form') + + fireEvent.click(screen.getByRole('button', { name: /Change avatar/ })) + + expect(screen.getByRole('region', { name: 'Choose avatar' })).toBeInTheDocument() +}) + +test('avatar panel closes when clicking outside the picker', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /Change/ })) + expect(screen.getByRole('region', { name: 'Choose avatar' })).toBeInTheDocument() + + fireEvent.mouseDown(document.body) + + expect(screen.queryByRole('region', { name: 'Choose avatar' })).not.toBeInTheDocument() +}) + +test('header avatar panel keeps choices visual-only without visible labels', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /Change avatar/ })) + + expect(screen.getByRole('button', { name: /Choose Mountain/ })).toBeInTheDocument() + expect(screen.queryByText('Mountain')).not.toBeInTheDocument() +}) diff --git a/src/__tests__/TopBar.test.tsx b/src/__tests__/TopBar.test.tsx new file mode 100644 index 0000000..140e309 --- /dev/null +++ b/src/__tests__/TopBar.test.tsx @@ -0,0 +1,22 @@ +/** + * @jest-environment jsdom + */ +import { render, screen } from '@testing-library/react' +import TopBar from '@/components/TopBar' + +jest.mock('next/navigation', () => ({ + usePathname: () => '/', +})) + +test('hides organizer navigation for users without an organizer assignment', () => { + render() + + expect(screen.queryByRole('link', { name: /Organizer/ })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /Profile/ })).toBeInTheDocument() +}) + +test('shows organizer navigation when admin has assigned an organizer record', () => { + render() + + expect(screen.getByRole('link', { name: /Organizer/ })).toHaveAttribute('href', '/organizer') +}) diff --git a/src/__tests__/aiWorkspace.test.ts b/src/__tests__/aiWorkspace.test.ts new file mode 100644 index 0000000..4716577 --- /dev/null +++ b/src/__tests__/aiWorkspace.test.ts @@ -0,0 +1,216 @@ +import { + AI_WORKSPACE_PRESETS, + buildEmptyAIWorkspaceData, + defaultAIWorkspaceLayout, + enrichAIWorkspaceWithStravaSignal, + normalizeAIWorkspaceLayout, + summarizeAIWorkspace, +} from '@/lib/aiWorkspace' +import type { AIWorkspaceData } from '@/lib/types' + +test('default AI workspace starts in calendar mode with all widgets present', () => { + const layout = defaultAIWorkspaceLayout('user-1') + + expect(layout).toEqual({ + user_id: 'user-1', + active_preset: 'calendar', + widget_order: AI_WORKSPACE_PRESETS.calendar.widgetOrder, + panel_layout: AI_WORKSPACE_PRESETS.calendar.panelLayout, + }) +}) + +test('normalizes saved layouts by removing unknown and duplicate widgets', () => { + const layout = normalizeAIWorkspaceLayout('user-1', { + active_preset: 'workout', + widget_order: ['insights', 'unknown', 'insights', 'data_sources'] as never, + }) + + expect(layout.active_preset).toBe('workout') + expect(layout.widget_order).toEqual([ + 'insights', + 'data_sources', + 'workout_routine', + 'race_calendar', + ]) + expect(layout.panel_layout.map((panel) => panel.kind)).toEqual([ + 'workout_routine', + 'insights', + 'race_calendar', + 'recovery_flags', + 'data_sources', + ]) +}) + +test('normalizes custom panel layouts for the Race OS board', () => { + const layout = normalizeAIWorkspaceLayout('user-1', { + active_preset: 'calendar', + panel_layout: [ + { id: 'coach', kind: 'ai_coach', x: -2, y: 1.3, w: 20, h: 2 }, + { id: 'broken', kind: 'not-real', x: 0, y: 0, w: 4, h: 4 }, + ] as never, + }) + + expect(layout.panel_layout[0]).toEqual({ + id: 'coach', + kind: 'ai_coach', + x: 0, + y: 1, + w: 12, + h: 3, + }) + expect(layout.panel_layout.some((panel) => panel.kind === 'race_calendar')).toBe(true) +}) + +test('normalizes saved Race OS panel sizes without forcing whole grid blocks', () => { + const layout = normalizeAIWorkspaceLayout('user-1', { + active_preset: 'calendar', + panel_layout: [ + { id: 'race', kind: 'race_calendar', x: 0, y: 0, w: 7.42, h: 4.35 }, + ], + }) + + expect(layout.panel_layout[0]).toMatchObject({ + id: 'race', + w: 7.42, + h: 4.35, + }) +}) + +test('empty AI workspace data is safe for users before Strava import exists', () => { + const data = buildEmptyAIWorkspaceData('user-1') + const summary = summarizeAIWorkspace(data) + + expect(summary.hasProviderData).toBe(false) + expect(summary.latestSnapshotStatus).toBe('queued') + expect(summary.connectedProviderCount).toBe(0) + expect(summary.activityCount).toBe(0) +}) + +test('summarizes connected providers, ready activities, proposals, and calendar items', () => { + const data: AIWorkspaceData = { + ...buildEmptyAIWorkspaceData('user-1'), + providerConnections: [ + { + id: 'conn-1', + user_id: 'user-1', + provider: 'strava', + status: 'connected', + }, + { + id: 'conn-2', + user_id: 'user-1', + provider: 'strava', + status: 'paused', + }, + ], + rawImports: [ + { + id: 'raw-1', + user_id: 'user-1', + provider: 'strava', + import_type: 'oauth_sync', + status: 'processing', + imported_at: '2026-05-01T00:00:00.000Z', + }, + ], + activitySummaries: [ + { + id: 'activity-1', + user_id: 'user-1', + provider: 'strava', + activity_date: '2026-05-01', + title: 'Tempo Run', + sport_type: 'running', + }, + ], + snapshots: [ + { + id: 'snapshot-1', + user_id: 'user-1', + period_start: '2026-04-01', + period_end: '2026-04-30', + status: 'ready', + snapshot: {}, + generated_at: '2026-05-01T00:00:00.000Z', + }, + ], + planProposals: [ + { + id: 'proposal-1', + user_id: 'user-1', + status: 'draft', + title: 'Berlin build', + plan: {}, + safety_flags: [], + created_at: '2026-05-01T00:00:00.000Z', + }, + ], + calendarItems: [ + { + id: 'calendar-1', + user_id: 'user-1', + source: 'ai_proposal', + status: 'planned', + item_type: 'race', + title: 'Berlin Marathon', + starts_on: '2026-09-27', + created_at: '2026-05-01T00:00:00.000Z', + }, + ], + } + + expect(summarizeAIWorkspace(data)).toMatchObject({ + hasProviderData: true, + connectedProviderCount: 1, + activityCount: 1, + latestSnapshotStatus: 'ready', + processingImportCount: 1, + draftProposalCount: 1, + acceptedCalendarItemCount: 1, + }) +}) + +test('enriches the AI workspace with live Strava activities and connection state', () => { + const data = enrichAIWorkspaceWithStravaSignal(buildEmptyAIWorkspaceData('user-1'), 'user-1', { + connected: true, + fetched_at: '2026-05-16T08:00:00.000Z', + athlete: { + id: 123, + display_name: 'Pawan', + }, + recentActivities: [ + { + id: 999, + title: 'Trail Intervals', + sport_type: 'TrailRun', + start_date: '2026-05-16T06:00:00.000Z', + distance_meters: 9000, + moving_time_seconds: 3300, + elapsed_time_seconds: 3600, + achievement_count: 1, + }, + ], + }) + + expect(data.providerConnections).toEqual([ + expect.objectContaining({ + id: 'strava-live', + provider: 'strava', + status: 'connected', + provider_account_id: '123', + }), + ]) + expect(data.activitySummaries[0]).toMatchObject({ + id: 'strava-999', + provider: 'strava', + title: 'Trail Intervals', + sport_type: 'trail', + distance_meters: 9000, + intensity: 'breakthrough', + }) + expect(summarizeAIWorkspace(data)).toMatchObject({ + hasProviderData: true, + connectedProviderCount: 1, + activityCount: 1, + }) +}) diff --git a/src/__tests__/authRedirects.test.ts b/src/__tests__/authRedirects.test.ts new file mode 100644 index 0000000..ab4ece2 --- /dev/null +++ b/src/__tests__/authRedirects.test.ts @@ -0,0 +1,24 @@ +import { + getAuthErrorMessage, + loginRedirectPath, + sanitizeAuthRedirectPath, +} from '@/lib/authRedirects' + +test('auth redirect sanitizer keeps local app paths but avoids auth loops', () => { + expect(sanitizeAuthRedirectPath('/passport')).toBe('/passport') + expect(sanitizeAuthRedirectPath('/claim/badge/token')).toBe('/claim/badge/token') + expect(sanitizeAuthRedirectPath('/login?next=%2Fpassport')).toBe('/') + expect(sanitizeAuthRedirectPath('/auth/callback')).toBe('/') + expect(sanitizeAuthRedirectPath('https://example.com')).toBe('/') +}) + +test('auth errors are mapped to user-facing messages', () => { + expect(getAuthErrorMessage('Invalid login credentials')).toBe('Invalid email or password.') + expect(getAuthErrorMessage('Email not confirmed')).toBe('Check your email to confirm your account before signing in.') + expect(getAuthErrorMessage('email rate limit exceeded')).toBe('Too many attempts. Please wait a minute and try again.') +}) + +test('login redirect path only includes useful query params', () => { + expect(loginRedirectPath({ error: 'Nope', nextPath: '/' })).toBe('/login?error=Nope') + expect(loginRedirectPath({ message: 'Check email', nextPath: '/passport' })).toBe('/login?message=Check+email&next=%2Fpassport') +}) diff --git a/src/__tests__/avatars.test.ts b/src/__tests__/avatars.test.ts new file mode 100644 index 0000000..5002df9 --- /dev/null +++ b/src/__tests__/avatars.test.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { AVATAR_OPTIONS, DEFAULT_AVATAR_KEY, getAvatarOption, isAvatarKey } from '@/lib/avatars' + +test('avatar catalog provides themed predefined choices', () => { + expect(AVATAR_OPTIONS).toHaveLength(8) + expect(AVATAR_OPTIONS.map(option => option.key)).toEqual([ + 'runner-dawn', + 'tri-kit', + 'trail-summit', + 'velo-break', + 'swim-lane', + 'night-finish', + 'medal-wall', + 'city-major', + ]) +}) + +test('avatar lookup falls back to the default runner avatar', () => { + expect(DEFAULT_AVATAR_KEY).toBe('runner-dawn') + expect(getAvatarOption('tri-kit').name).toBe('Hiker') + expect(getAvatarOption('missing').key).toBe(DEFAULT_AVATAR_KEY) + expect(isAvatarKey('trail-summit')).toBe(true) + expect(isAvatarKey('missing')).toBe(false) +}) + +test('avatar catalog points to the exact uploaded image assets', () => { + expect(AVATAR_OPTIONS.map(option => option.imageSrc)).toEqual([ + '/avatars/runner.png', + '/avatars/hiker.png', + '/avatars/mountain.png', + '/avatars/cycling.png', + '/avatars/swimming.png', + '/avatars/finish.png', + '/avatars/medal.png', + '/avatars/triathlon.png', + ]) + + for (const option of AVATAR_OPTIONS) { + const avatarPath = join(process.cwd(), 'public', option.imageSrc) + expect(existsSync(avatarPath)).toBe(true) + + const png = readFileSync(avatarPath) + expect(png.subarray(1, 4).toString('ascii')).toBe('PNG') + expect(png.readUInt32BE(16)).toBe(1254) + expect(png.readUInt32BE(20)).toBe(1254) + } +}) diff --git a/src/__tests__/badges.test.ts b/src/__tests__/badges.test.ts index d530654..d63da25 100644 --- a/src/__tests__/badges.test.ts +++ b/src/__tests__/badges.test.ts @@ -64,6 +64,11 @@ test('two races in the same country earn only one country badge', () => { expect(countryBadges).toHaveLength(1) }) +test('blank country codes do not earn country badges', () => { + const badges = computeBadges([makeRace({ location_country: '' })]) + expect(badges.some(b => b.key === 'country_')).toBe(false) +}) + // ─── Distance milestone badges ──────────────────────────────── test('70.3 triathlon earns finisher_703 badge', () => { const badges = computeBadges([makeRace({ sport_type: 'triathlon', distance_category: '70.3' })]) diff --git a/src/__tests__/claimTokens.test.ts b/src/__tests__/claimTokens.test.ts new file mode 100644 index 0000000..4f0bcbe --- /dev/null +++ b/src/__tests__/claimTokens.test.ts @@ -0,0 +1,18 @@ +import { createClaimToken, hashClaimToken, sanitizeNextPath } from '@/lib/claimTokens' + +test('claim tokens are URL safe and hashed before storage', () => { + const token = createClaimToken() + const hash = hashClaimToken(token) + + expect(token).toMatch(/^[A-Za-z0-9_-]+$/) + expect(hash).toMatch(/^[a-f0-9]{64}$/) + expect(hash).not.toBe(token) + expect(hashClaimToken(token)).toBe(hash) +}) + +test('next path sanitizer only accepts local absolute paths', () => { + expect(sanitizeNextPath('/claim/badge/token')).toBe('/claim/badge/token') + expect(sanitizeNextPath('https://example.com')).toBe('/') + expect(sanitizeNextPath('//example.com')).toBe('/') + expect(sanitizeNextPath(null)).toBe('/') +}) diff --git a/src/__tests__/dbAccessors.test.ts b/src/__tests__/dbAccessors.test.ts new file mode 100644 index 0000000..4dea233 --- /dev/null +++ b/src/__tests__/dbAccessors.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const dbSource = readFileSync(join(process.cwd(), 'src/lib/db.ts'), 'utf8') + +test('public profile and passport accessors require share_public', () => { + expect(dbSource).toMatch(/async findPublicByUsername\(username: string\): Promise[\s\S]+\.eq\('share_public', true\)/) + expect(dbSource).toMatch(/async findPublicByUsername\(username: string\): Promise[\s\S]+\.eq\('share_public', true\)/) +}) + +test('passport accessors read profile, races, and badges through a single supabase client', () => { + expect(dbSource).toMatch(/async findPrivateByUser\(userId: string\): Promise[\s\S]+const supabase = await createClient\(\)[\s\S]+Promise\.all\(/) + expect(dbSource).toMatch(/async findPublicByUsername\(username: string\): Promise[\s\S]+const supabase = await createClient\(\)[\s\S]+Promise\.all\(/) +}) + +test('AI workspace accessor reads all planning tables and falls back before schema migration lands', () => { + expect(dbSource).toMatch(/aiWorkspace: \{[\s\S]+async findByUser\(userId: string\): Promise/) + expect(dbSource).toContain(".from('activity_provider_connections')") + expect(dbSource).toContain(".from('activity_raw_imports')") + expect(dbSource).toContain(".from('activity_summaries')") + expect(dbSource).toContain(".from('ai_training_snapshots')") + expect(dbSource).toContain(".from('ai_workspace_layouts')") + expect(dbSource).toContain(".from('ai_plan_proposals')") + expect(dbSource).toContain(".from('calendar_items')") + expect(dbSource).toContain(".from('discover_attendees')") + expect(dbSource).toContain(".from('discover_races')") + expect(dbSource).toMatch(/if \(results\.some\(result => isMissingAIWorkspaceTable\(result\.error\)\)\) \{\s+return buildEmptyAIWorkspaceData\(userId\)/) +}) + +test('missing optional feature tables include PostgREST schema-cache errors', () => { + expect(dbSource).toContain("error?.code === '42P01'") + expect(dbSource).toContain("error?.code === 'PGRST205'") + expect(dbSource).toContain("Could not find the table") +}) diff --git a/src/__tests__/discoverCatalog.test.ts b/src/__tests__/discoverCatalog.test.ts new file mode 100644 index 0000000..3cb31d0 --- /dev/null +++ b/src/__tests__/discoverCatalog.test.ts @@ -0,0 +1,32 @@ +import { DISCOVER_SEED_RACES, isSeedDiscoverRace, mergeDiscoverRaces } from '@/lib/discoverCatalog' +import type { DiscoverRace } from '@/lib/types' + +const makeRace = (overrides: Partial = {}): DiscoverRace => ({ + id: 'db-race-1', + name: 'TCS Sydney Marathon', + date: '2026-08-30', + location_city: 'Sydney', + location_country: 'AU', + sport_type: 'running', + distance_category: 'full', + registration_url: 'https://db.example.com/sydney', + description: 'Database copy wins', + created_at: '2026-05-18', + ...overrides, +}) + +test('scraped discover catalog includes the curated race batch', () => { + expect(DISCOVER_SEED_RACES).toHaveLength(14) + expect(DISCOVER_SEED_RACES.map(race => race.name)).toContain('TCS Sydney Marathon') +}) + +test('database races replace matching scraped races by name and date', () => { + const merged = mergeDiscoverRaces([makeRace()]) + const sydney = merged.find(race => race.name === 'TCS Sydney Marathon') + + expect(sydney).toMatchObject({ + id: 'db-race-1', + registration_url: 'https://db.example.com/sydney', + }) + expect(sydney && isSeedDiscoverRace(sydney)).toBe(false) +}) diff --git a/src/__tests__/discoverSeed.test.ts b/src/__tests__/discoverSeed.test.ts new file mode 100644 index 0000000..710f904 --- /dev/null +++ b/src/__tests__/discoverSeed.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const seed = readFileSync(join(process.cwd(), 'supabase/seed.sql'), 'utf8') + +test('discover seed can be re-run without duplicating races', () => { + expect(seed).toContain('create unique index if not exists discover_races_name_date_unique_idx') + expect(seed).toContain('on conflict (name, date) do update set') +}) + +test('discover seed includes scraped races across marathon, triathlon, and trail categories', () => { + expect(seed.match(/-- Source:/g)).toHaveLength(14) + expect(seed).toContain("'running',\n 'full'") + expect(seed).toContain("'triathlon',\n 'ironman'") + expect(seed).toContain("'triathlon',\n '70.3'") + expect(seed).toContain("'running',\n 'ultra'") +}) diff --git a/src/__tests__/metadata.test.ts b/src/__tests__/metadata.test.ts new file mode 100644 index 0000000..220fab0 --- /dev/null +++ b/src/__tests__/metadata.test.ts @@ -0,0 +1,26 @@ +import { countryFlag, formatCountryName, formatDistanceLabel, getSportMeta } from '@/lib/metadata' + +test('sport metadata returns stable labels and safe fallback', () => { + expect(getSportMeta('triathlon')).toMatchObject({ + label: 'Triathlon', + shortLabel: 'TRI', + color: '#e8001d', + }) + expect(getSportMeta('unknown')).toMatchObject({ + label: 'Other', + shortLabel: 'OTH', + }) +}) + +test('distance labels normalize known values and preserve unknown values', () => { + expect(formatDistanceLabel('70.3')).toBe('70.3 Triathlon') + expect(formatDistanceLabel('full')).toBe('Full Marathon') + expect(formatDistanceLabel('backyard')).toBe('BACKYARD') +}) + +test('country helpers do not throw on invalid codes', () => { + expect(countryFlag('SG')).toBe('πŸ‡ΈπŸ‡¬') + expect(countryFlag('SINGAPORE')).toBe('SINGAPORE') + expect(formatCountryName('SG')).toBe('Singapore') + expect(formatCountryName('ZZ')).toBe('ZZ') +}) diff --git a/src/__tests__/passport.test.ts b/src/__tests__/passport.test.ts new file mode 100644 index 0000000..35f5f50 --- /dev/null +++ b/src/__tests__/passport.test.ts @@ -0,0 +1,135 @@ +import { buildPassportSummary } from '@/lib/passport' +import type { Badge, EventBadgeCollectible, Profile, Race } from '@/lib/types' + +const profile: Profile = { + id: 'user-1', + username: 'pawan', + display_name: 'Pawan', + created_at: '2026-01-01', +} + +const makeRace = (overrides: Partial = {}): Race => ({ + id: 'race-1', + name: 'Singapore Marathon', + date: '2025-12-01', + location_country: 'SG', + location_city: 'Singapore', + sport_type: 'running', + distance_category: 'full', + finish_time: '03:30:00', + ...overrides, +}) + +const makeBadge = (overrides: Partial = {}): Badge => ({ + key: 'first_finish', + name: 'First Finish', + category: 'milestone', + earned_at: '2025-12-01', + ...overrides, +}) + +const eventBadge: EventBadgeCollectible = { + id: 'claim-1', + event_badge_id: 'event-badge-1', + badge_name: 'Marina Bay Participant', + description: 'Awarded for event participation.', + artwork_key: 'event-stamp', + claimed_at: '2026-05-01T10:00:00.000Z', + event_name: 'Marina Bay 10K', + event_date: '2026-05-01', + location_city: 'Singapore', + location_country: 'SG', + organizer_name: 'Marka Events', +} + +test('zero races returns empty private prompts', () => { + const summary = buildPassportSummary({ profile, races: [], badges: [], visibility: 'private' }) + + expect(summary.state.isEmpty).toBe(true) + expect(summary.stats).toEqual({ races: 0, countries: 0, badges: 0, personalBests: 0 }) + expect(summary.prompts.map(prompt => prompt.key)).toEqual(['log_first_race', 'connect_strava']) +}) + +test('public empty state omits private calls to action', () => { + const summary = buildPassportSummary({ profile, races: [], badges: [], visibility: 'public' }) + + expect(summary.prompts).toHaveLength(1) + expect(summary.prompts[0]).toMatchObject({ key: 'public_empty' }) + expect(summary.prompts[0].href).toBeUndefined() +}) + +test('personal bests use deterministic tie breaking', () => { + const races = [ + makeRace({ id: 'later', date: '2025-12-02', finish_time: '03:30:00' }), + makeRace({ id: 'earlier', date: '2025-12-01', finish_time: '03:30:00' }), + makeRace({ id: 'slower', date: '2025-11-01', finish_time: '03:40:00' }), + ] + + const summary = buildPassportSummary({ profile, races, badges: [makeBadge()], visibility: 'private' }) + + expect(summary.personalBests).toHaveLength(1) + expect(summary.personalBests[0].race.id).toBe('earlier') +}) + +test('countries and achievements are derived from races and badges', () => { + const races = [ + makeRace({ id: 'sg', location_country: 'SG', date: '2025-01-01' }), + makeRace({ id: 'jp', location_country: 'JP', location_city: 'Tokyo', date: '2025-02-01' }), + ] + const badges = [ + makeBadge(), + makeBadge({ key: 'country_SG', name: 'Singapore Explorer', category: 'geography' }), + ] + + const summary = buildPassportSummary({ profile, races, badges, visibility: 'private' }) + + expect(summary.stats.countries).toBe(2) + expect(summary.countries.map(country => country.code)).toEqual(['SG', 'JP']) + expect(summary.earnedAchievements.map(achievement => achievement.key)).toContain('country_SG') + expect(summary.nextAchievements.length).toBeGreaterThan(0) +}) + +test('blank country races and badges are omitted from passport geography', () => { + const summary = buildPassportSummary({ + profile, + races: [makeRace({ location_country: '' })], + badges: [makeBadge({ key: 'country_', name: ' Explorer', category: 'geography' })], + visibility: 'public', + }) + + expect(summary.stats.countries).toBe(0) + expect(summary.stats.badges).toBe(0) + expect(summary.countries).toEqual([]) + expect(summary.earnedAchievements).toEqual([]) +}) + +test('partial private state prompts users to complete the journey', () => { + const summary = buildPassportSummary({ + profile, + races: [makeRace()], + badges: [], + visibility: 'private', + }) + + expect(summary.state.isPartial).toBe(true) + expect(summary.prompts.map(prompt => prompt.key)).toEqual([ + 'sync_achievements', + 'new_country', + 'share_passport', + ]) +}) + +test('organizer event collectibles count as passport badges without becoming computed achievements', () => { + const summary = buildPassportSummary({ + profile, + races: [], + badges: [], + event_badges: [eventBadge], + visibility: 'private', + }) + + expect(summary.state.isEmpty).toBe(false) + expect(summary.stats.badges).toBe(1) + expect(summary.eventBadges[0].badge_name).toBe('Marina Bay Participant') + expect(summary.earnedAchievements).toEqual([]) +}) diff --git a/src/__tests__/schema.test.ts b/src/__tests__/schema.test.ts new file mode 100644 index 0000000..b072262 --- /dev/null +++ b/src/__tests__/schema.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const schema = readFileSync(join(process.cwd(), 'supabase/schema.sql'), 'utf8') +const racesRepairMigration = readFileSync( + join(process.cwd(), 'supabase/migrations/20260523000000_repair_races_before_strava_column.sql'), + 'utf8' +) +const discoverSeedMigration = readFileSync( + join(process.cwd(), 'supabase/migrations/20260523001000_create_discover_races_for_seed.sql'), + 'utf8' +) + +test('schema includes the profile fields used by Passport V2 and admin tools', () => { + expect(schema).toContain('share_public boolean not null default true') + expect(schema).toContain("avatar_key text not null default 'runner-dawn'") + expect(schema).toContain('is_admin boolean not null default false') + expect(schema).toContain('strava_access_token text') + expect(schema).toContain('strava_refresh_token text') + expect(schema).toContain('strava_token_expires_at bigint') +}) + +test('races repair migration creates the base table before adding Strava columns', () => { + expect(racesRepairMigration).toContain('create table if not exists public.races') + expect(racesRepairMigration.indexOf('create table if not exists public.races')) + .toBeLessThan(racesRepairMigration.indexOf('add column if not exists strava_activity_id bigint')) + expect(racesRepairMigration).toContain('create unique index races_strava_activity_id_unique_idx') + expect(racesRepairMigration).toContain('create policy "Users can read own races"') + expect(racesRepairMigration).toContain("to_regclass('public.profiles') is not null") +}) + +test('discover seed migration creates the table required by seed data', () => { + expect(discoverSeedMigration).toContain('create table if not exists public.discover_races') + expect(discoverSeedMigration).toContain('create unique index if not exists discover_races_name_date_unique_idx') + expect(discoverSeedMigration).toContain("to_regclass('public.profiles') is not null") +}) + +test('public passport RLS only exposes races and badges for public profiles', () => { + expect(schema).toMatch(/create policy "Public profiles are viewable"[\s\S]+using \(share_public = true\);/) + expect(schema).toMatch(/create policy "Public races viewable via profile"[\s\S]+profiles\.share_public = true/) + expect(schema).toMatch(/create policy "Public badges viewable"[\s\S]+profiles\.share_public = true/) +}) + +test('discover tables are present with admin-gated writes and own-attendance policies', () => { + expect(schema).toContain('create table public.discover_races') + expect(schema).toContain('create table public.discover_attendees') + expect(schema).toContain('create unique index discover_races_name_date_unique_idx') + expect(schema).toMatch(/create policy "Admins can manage discover races"[\s\S]+profiles\.is_admin = true/) + expect(schema).toContain('primary key (user_id, discover_race_id)') + expect(schema).toMatch(/create policy "Users can mark own discover attendance"[\s\S]+auth\.uid\(\) = user_id/) +}) + +test('organizer collectible tables keep verified organizers, claim links, and passport claims separate', () => { + expect(schema).toContain('create table public.organizers') + expect(schema).toContain("check (status in ('pending','verified','suspended'))") + expect(schema).toContain('create table public.organizer_events') + expect(schema).toContain('create table public.event_badges') + expect(schema).toContain('create table public.badge_claim_links') + expect(schema).toContain('token_hash text not null unique') + expect(schema).toContain('create table public.user_event_badges') + expect(schema).toContain('unique (user_id, event_badge_id)') + expect(schema).toMatch(/create policy "Admins can manage organizers"[\s\S]+profiles\.is_admin = true/) + expect(schema).toMatch(/create policy "Verified organizer owners can manage own events"[\s\S]+organizers\.status = 'verified'/) +}) + +test('event badge claims go through token-hash RPC functions instead of direct user inserts', () => { + expect(schema).toContain('create or replace function public.event_badge_claim_preview(claim_token_hash text)') + expect(schema).toContain('create or replace function public.claim_event_badge_by_token(claim_token_hash text)') + expect(schema).toContain('grant execute on function public.event_badge_claim_preview(text) to anon, authenticated') + expect(schema).toContain('grant execute on function public.claim_event_badge_by_token(text) to authenticated') + expect(schema).not.toMatch(/create policy "Users can .*insert.*event collectibles"/) +}) + +test('AI workspace schema stores provider imports, snapshots, proposals, and calendar layout per user', () => { + expect(schema).toContain('create table public.activity_provider_connections') + expect(schema).toContain("check (provider in ('strava','garmin','fit','manual'))") + expect(schema).toContain('create table public.activity_raw_imports') + expect(schema).toContain("check (import_type in ('oauth_sync','fit_upload','manual_upload'))") + expect(schema).toContain('payload jsonb not null default') + expect(schema).toContain('create table public.activity_summaries') + expect(schema).toContain('create table public.ai_training_snapshots') + expect(schema).toContain('create table public.ai_workspace_layouts') + expect(schema).toContain("check (active_preset in ('calendar','workout','insights'))") + expect(schema).toContain("widget_order text[] not null default array['race_calendar','workout_routine','insights','data_sources']::text[]") + expect(schema).toContain("panel_layout jsonb not null default '[]'::jsonb") + expect(schema).toContain('create table public.ai_plan_proposals') + expect(schema).toContain("check (status in ('draft','accepted','dismissed','failed'))") + expect(schema).toContain('create table public.calendar_items') + expect(schema).toContain("check (item_type in ('race','workout','recovery','note'))") + expect(schema).toContain('discover_race_id uuid references public.discover_races on delete set null') + expect(schema).toContain('ai_plan_proposal_id uuid references public.ai_plan_proposals on delete set null') +}) + +test('AI workspace tables are private to the owning athlete', () => { + expect(schema).toMatch(/create policy "Users can manage own activity provider connections"[\s\S]+auth\.uid\(\) = user_id/) + expect(schema).toMatch(/create policy "Users can manage own activity raw imports"[\s\S]+auth\.uid\(\) = user_id/) + expect(schema).toMatch(/create policy "Users can manage own activity summaries"[\s\S]+auth\.uid\(\) = user_id/) + expect(schema).toMatch(/create policy "Users can manage own AI training snapshots"[\s\S]+auth\.uid\(\) = user_id/) + expect(schema).toMatch(/create policy "Users can manage own AI workspace layout"[\s\S]+auth\.uid\(\) = user_id/) + expect(schema).toMatch(/create policy "Users can manage own AI plan proposals"[\s\S]+auth\.uid\(\) = user_id/) + expect(schema).toMatch(/create policy "Users can manage own calendar items"[\s\S]+auth\.uid\(\) = user_id/) +}) diff --git a/src/__tests__/strava.test.ts b/src/__tests__/strava.test.ts new file mode 100644 index 0000000..8220d14 --- /dev/null +++ b/src/__tests__/strava.test.ts @@ -0,0 +1,58 @@ +import { buildStravaAISignal } from '@/lib/strava' + +test('maps Strava athlete activities and stats into the AI signal shape', () => { + const signal = buildStravaAISignal( + { + id: 123, + firstname: 'Pawan', + lastname: 'Kumar', + username: 'pk', + city: 'Singapore', + country: 'Singapore', + }, + [ + { + id: 999, + name: 'Morning Tempo', + sport_type: 'Run', + workout_type: 0, + distance: 12000, + moving_time: 3600, + elapsed_time: 3700, + start_date: '2026-05-16T06:00:00.000Z', + achievement_count: 2, + }, + ], + { + recent_run_totals: { + count: 3, + distance: 24000, + achievement_count: 5, + }, + ytd_run_totals: { + count: 30, + distance: 420000, + }, + biggest_run_distance: 42195, + }, + '2026-05-16T08:00:00.000Z', + ) + + expect(signal.athlete).toMatchObject({ + id: 123, + display_name: 'Pawan Kumar', + }) + expect(signal.recentActivities[0]).toMatchObject({ + id: 999, + title: 'Morning Tempo', + distance_meters: 12000, + moving_time_seconds: 3600, + achievement_count: 2, + }) + expect(signal.stats).toMatchObject({ + recentRunDistanceMeters: 24000, + recentAchievementCount: 5, + ytdRunDistanceMeters: 420000, + biggestRunDistanceMeters: 42195, + }) +}) diff --git a/src/app/admin/organizers/page.tsx b/src/app/admin/organizers/page.tsx new file mode 100644 index 0000000..8ad38ef --- /dev/null +++ b/src/app/admin/organizers/page.tsx @@ -0,0 +1,142 @@ +import Link from 'next/link' +import { requireAdmin } from '@/lib/auth' +import { db } from '@/lib/db' +import { createOrganizer } from '@/lib/organizerBadges' + +export default async function AdminOrganizersPage() { + await requireAdmin() + const organizers = await db.organizers.findAll() + + return ( +
+
+
+ + + + + + Back to Admin + + +

+ Marka Verification +

+

+ Organizers +

+
+
+ +
+
+ +
+ + + +
+ + +
+ + +
+ +
+ +
+ {organizers.length === 0 ? ( + + ) : ( + organizers.map((organizer) => ( +
+
+
+

{organizer.name}

+

+ Owner {organizer.owner_user_id} +

+
+ + {organizer.status} + +
+ {organizer.website_url && ( +

+ {organizer.website_url} +

+ )} +
+ )) + )} +
+
+
+
+ ) +} + +function Field({ + name, + label, + placeholder, + type = 'text', + required = true, +}: { + name: string + label: string + placeholder: string + type?: string + required?: boolean +}) { + return ( +
+ + +
+ ) +} + +function SectionTitle({ label }: { label: string }) { + return ( +
+

+ Β§ {label} +

+
+ ) +} + +function EmptyRow({ label }: { label: string }) { + return ( +
+

+ {label} +

+
+ ) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index eef1f52..80c8daf 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -18,16 +18,24 @@ export default async function AdminPage() { Discover Races - - - - - - Add Race - +
+ + Organizers + + + + + + + Add Race + +
{/* Race list */} diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx new file mode 100644 index 0000000..72e036f --- /dev/null +++ b/src/app/ai/page.tsx @@ -0,0 +1,28 @@ +import { AIWorkspaceView } from '@/components/AIWorkspaceView' +import { requireUser } from '@/lib/auth' +import { enrichAIWorkspaceWithStravaSignal } from '@/lib/aiWorkspace' +import { db } from '@/lib/db' +import { fetchStravaAISignal } from '@/lib/strava' + +type AIPageSearchParams = { + error?: string | string[] + saved?: string | string[] +} + +export default async function AIPage({ + searchParams = Promise.resolve({}), +}: { + searchParams?: Promise +}) { + const user = await requireUser() + const params = await searchParams + const error = Array.isArray(params.error) ? params.error[0] : params.error + const saved = Array.isArray(params.saved) ? params.saved[0] : params.saved + const [workspaceData, stravaSignal] = await Promise.all([ + db.aiWorkspace.findByUser(user.id), + fetchStravaAISignal(user.id), + ]) + const data = enrichAIWorkspaceWithStravaSignal(workspaceData, user.id, stravaSignal) + + return +} diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index d81ebae..e010383 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -35,6 +35,7 @@ export async function GET(req: NextRequest) { access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token.expires_at, + athlete: token.athlete, }) return Response.redirect(`${origin}/strava/import`) diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts index 9961268..f021e81 100644 --- a/src/app/api/strava/connect/route.ts +++ b/src/app/api/strava/connect/route.ts @@ -8,7 +8,7 @@ export async function GET(req: NextRequest) { client_id: process.env.STRAVA_CLIENT_ID!, redirect_uri: redirectUri, response_type: 'code', - scope: 'activity:read_all', + scope: 'read,activity:read_all', }) return Response.redirect(`https://www.strava.com/oauth/authorize?${params}`) diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 440ede2..a3c6a31 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { sanitizeAuthRedirectPath } from '@/lib/authRedirects' export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') - const next = searchParams.get('next') ?? '/' + const next = sanitizeAuthRedirectPath(searchParams.get('next')) if (code) { const supabase = await createClient() diff --git a/src/app/claim/badge/[token]/page.tsx b/src/app/claim/badge/[token]/page.tsx new file mode 100644 index 0000000..b5e920e --- /dev/null +++ b/src/app/claim/badge/[token]/page.tsx @@ -0,0 +1,156 @@ +import Link from 'next/link' +import { getOptionalUser } from '@/lib/auth' +import { hashClaimToken } from '@/lib/claimTokens' +import { db } from '@/lib/db' +import { claimEventBadge } from '@/lib/organizerBadges' +import type { EventBadgeClaimPreview } from '@/lib/types' + +type ClaimState = 'ready' | 'invalid' | 'inactive' | 'not_open' | 'closed' + +export default async function ClaimBadgePage({ + params, + searchParams, +}: { + params: Promise<{ token: string }> + searchParams: Promise<{ claimed?: string }> +}) { + const { token } = await params + const query = await searchParams + const user = await getOptionalUser() + const preview = await db.eventBadges.findClaimPreviewByTokenHash(hashClaimToken(token)) + const state = getClaimState(preview) + const claimed = query.claimed === '1' + + return ( +
+
+
+

+ Marka Collectible +

+

+ Claim Badge +

+
+ +
+ {preview ? ( +
+
+ + M + +
+
+

+ {preview.organizer_name} +

+

+ {preview.badge_name} +

+

+ {preview.event_name} Β· {formatDate(preview.event_date)} +

+

+ {formatEventLocation(preview)} +

+ {preview.description && ( +

+ {preview.description} +

+ )} +
+
+ ) : ( +

+ This badge claim link is not valid +

+ )} + +
+ {claimed && user ? ( +
+

+ Added to your passport +

+ + View Passport + +
+ ) : state !== 'ready' ? ( +

+ {statusLabel(state)} +

+ ) : user ? ( +
+ +
+ ) : ( + + Sign In To Claim + + )} +
+
+
+
+ ) +} + +function getClaimState(preview: EventBadgeClaimPreview | null): ClaimState { + if (!preview) return 'invalid' + if ( + preview.revoked_at + || preview.badge_status !== 'published' + || preview.event_status !== 'published' + || preview.organizer_status !== 'verified' + ) { + return 'inactive' + } + + const now = Date.now() + if (preview.claim_opens_at && now < Date.parse(preview.claim_opens_at)) return 'not_open' + if (preview.claim_closes_at && now > Date.parse(preview.claim_closes_at)) return 'closed' + return 'ready' +} + +function statusLabel(state: ClaimState): string { + switch (state) { + case 'invalid': + return 'This badge claim link is not valid' + case 'inactive': + return 'This badge claim is no longer active' + case 'not_open': + return 'This badge claim is not open yet' + case 'closed': + return 'This badge claim has closed' + case 'ready': + return 'Ready to claim' + } +} + +function formatEventLocation(event: { location_city: string; location_country: string }): string { + const city = event.location_city.trim() + const country = event.location_country.trim().toUpperCase() + if (city && country) return `${city}, ${country}` + return city || country || 'Location TBD' +} + +function formatDate(date: string): string { + return new Date(date).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }).toUpperCase() +} diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index fa65c75..51ed081 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,42 +1,49 @@ import { db } from '@/lib/db' import { getOptionalUser } from '@/lib/auth' import { attendRace, unattendRace } from '@/lib/discover' -import type { DiscoverRace } from '@/lib/types' +import { countryFlag, getSportMeta } from '@/lib/metadata' +import Link from 'next/link' +import { mergeDiscoverRaces, type DiscoverCatalogRace } from '@/lib/discoverCatalog' -const SPORT_COLORS: Record = { - triathlon: '#e8001d', running: '#f59e0b', cycling: '#3b82f6', - duathlon: '#8b5cf6', open_water: '#06b6d4', other: '#888', -} -const SPORT_LABELS: Record = { - triathlon: 'TRI', running: 'RUN', cycling: 'BIKE', - duathlon: 'DUAL', open_water: 'SWIM', other: 'OTH', +type DiscoverSearchParams = { + category?: string | string[] } -function countryFlag(code: string) { - if (!code || code.length !== 2) return '' - return code.toUpperCase().split('').map(c => - String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65) - ).join('') -} +const DISCOVER_CATEGORIES = [ + { key: 'all', label: 'All', href: '/discover' }, + { key: 'marathon', label: 'Marathon', href: '/discover?category=marathon' }, + { key: 'triathlon', label: 'Triathlon', href: '/discover?category=triathlon' }, + { key: 'trail', label: 'Trail', href: '/discover?category=trail' }, +] -export default async function DiscoverPage() { - const user = await getOptionalUser() +export default async function DiscoverPage({ + searchParams = Promise.resolve({}), +}: { + searchParams?: Promise +}) { + const user = await getOptionalUser().catch(() => null) + const params = await searchParams + const category = Array.isArray(params.category) ? params.category[0] : params.category + const activeCategory = category ?? 'all' - const [races, attendingIds] = await Promise.all([ - db.discover.findAll(), - user ? db.discover.getAttendingIds(user.id) : Promise.resolve(new Set()), + const [dbRaces, attendingIds] = await Promise.all([ + db.discover.findAll().catch(() => []), + user ? db.discover.getAttendingIds(user.id).catch(() => new Set()) : Promise.resolve(new Set()), ]) + const races = mergeDiscoverRaces(dbRaces) let racedCountries = new Set() if (user) { - const myRaces = await db.races.findByUser(user.id) + const myRaces = await db.races.findByUser(user.id).catch(() => []) racedCountries = new Set(myRaces.map(r => r.location_country.toUpperCase())) } const now = new Date() - const attending = races.filter(r => attendingIds.has(r.id) && new Date(r.date) >= now) - const upcoming = races.filter(r => !attendingIds.has(r.id) && new Date(r.date) >= now) - const past = races.filter(r => new Date(r.date) < now) + const nowMs = now.getTime() + const visibleRaces = filterRacesByCategory(races, category) + const attending = visibleRaces.filter(r => attendingIds.has(r.id) && new Date(r.date) >= now) + const upcoming = visibleRaces.filter(r => !attendingIds.has(r.id) && new Date(r.date) >= now) + const past = visibleRaces.filter(r => new Date(r.date) < now) return (
@@ -57,11 +64,34 @@ export default async function DiscoverPage() { {attending.length > 0 && (

{attending.length}

-

I'm In

+

I'm In

)} + + {/* Badge legend */} {user && (
@@ -79,7 +109,7 @@ export default async function DiscoverPage() {

- I'm In + I'm In

{attending.map((race) => ( @@ -89,6 +119,7 @@ export default async function DiscoverPage() { isNewCountry={!!user && !racedCountries.has(race.location_country.toUpperCase())} isAttending isLoggedIn={!!user} + nowMs={nowMs} /> ))}
@@ -115,6 +146,7 @@ export default async function DiscoverPage() { isNewCountry={!!user && !racedCountries.has(race.location_country.toUpperCase())} isAttending={false} isLoggedIn={!!user} + nowMs={nowMs} /> ))}
@@ -135,6 +167,7 @@ export default async function DiscoverPage() { isNewCountry={false} isAttending={false} isLoggedIn={!!user} + nowMs={nowMs} past /> ))} @@ -145,18 +178,38 @@ export default async function DiscoverPage() { ) } -function RaceRow({ race, isNewCountry, isAttending, isLoggedIn, past = false }: { - race: DiscoverRace +function filterRacesByCategory(races: DiscoverCatalogRace[], category: string | undefined): DiscoverCatalogRace[] { + if (category === 'marathon') { + return races.filter(race => race.sport_type === 'running' && race.distance_category === 'full') + } + if (category === 'triathlon') { + return races.filter(race => race.sport_type === 'triathlon') + } + if (category === 'trail') { + return races.filter(isTrailRace) + } + return races +} + +function isTrailRace(race: DiscoverCatalogRace): boolean { + const text = `${race.name} ${race.distance_category} ${race.description ?? ''}`.toLowerCase() + return race.sport_type === 'running' && (text.includes('trail') || text.includes('ultra')) +} + +function RaceRow({ race, isNewCountry, isAttending, isLoggedIn, nowMs, past = false }: { + race: DiscoverCatalogRace isNewCountry: boolean isAttending: boolean isLoggedIn: boolean + nowMs: number past?: boolean }) { + const sport = getSportMeta(race.sport_type) const dateStr = new Date(race.date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', }).toUpperCase() - const daysAway = Math.round((new Date(race.date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + const daysAway = Math.round((new Date(race.date).getTime() - nowMs) / (1000 * 60 * 60 * 24)) const timeLabel = daysAway > 60 ? `${Math.round(daysAway / 30)}mo` : `${daysAway}d` return ( @@ -197,8 +250,8 @@ function RaceRow({ race, isNewCountry, isAttending, isLoggedIn, past = false }: {isLoggedIn && !past && (
{ 'use server' - if (isAttending) await unattendRace(race.id) - else await attendRace(race.id) + if (isAttending) await unattendRace(race) + else await attendRace(race) }}> +
+ ) : ( +

+ Create a badge first +

+ )} + + + +
+ ) +} + +async function baseUrl(): Promise { + const headerList = await headers() + const host = headerList.get('x-forwarded-host') ?? headerList.get('host') ?? 'localhost:3000' + const proto = headerList.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https') + return `${proto}://${host}` +} + +function formatEventLocation(event: { location_city: string; location_country: string }): string { + const city = event.location_city.trim() + const country = event.location_country.trim().toUpperCase() + if (city && country) return `${city}, ${country}` + return city || country || 'Location TBD' +} + +function formatDate(date: string): string { + return new Date(date).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }).toUpperCase() +} + +function SectionTitle({ label }: { label: string }) { + return ( +
+

+ Β§ {label} +

+
+ ) +} diff --git a/src/app/organizer/events/new/page.tsx b/src/app/organizer/events/new/page.tsx new file mode 100644 index 0000000..bb7998d --- /dev/null +++ b/src/app/organizer/events/new/page.tsx @@ -0,0 +1,138 @@ +import Link from 'next/link' +import { requireUser } from '@/lib/auth' +import { db } from '@/lib/db' +import { createOrganizerEventBadge } from '@/lib/organizerBadges' + +export default async function NewOrganizerEventBadgePage() { + const user = await requireUser() + const organizers = await db.organizers.findVerifiedByOwner(user.id) + + return ( +
+
+ + + + + + Back to Organizer + + +

+ Participation Collectible +

+

+ New Badge +

+
+ +
+ {organizers.length === 0 ? ( +
+

+ Organizer verification is required before creating badges +

+
+ ) : ( +
+
+ +
+
+ + +
+ + + + +
+
+ +
+ +
+ +
+ +