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 (
+
+ )
+}
+
+function EmptyRow({ label }: { label: string }) {
+ return (
+
+ )
+}
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 && (
-
+
)
diff --git a/src/app/organizer/events/[id]/qr/page.tsx b/src/app/organizer/events/[id]/qr/page.tsx
new file mode 100644
index 0000000..b410264
--- /dev/null
+++ b/src/app/organizer/events/[id]/qr/page.tsx
@@ -0,0 +1,156 @@
+import Link from 'next/link'
+import { headers } from 'next/headers'
+import { notFound } from 'next/navigation'
+import QRCode from 'qrcode'
+import { requireUser } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { generateClaimLinkForBadge } from '@/lib/organizerBadges'
+
+export default async function OrganizerEventQrPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ id: string }>
+ searchParams: Promise<{ badge?: string; token?: string }>
+}) {
+ const user = await requireUser()
+ const { id } = await params
+ const query = await searchParams
+ const event = await db.organizerEvents.findOwnedById(id, user.id)
+ if (!event) notFound()
+
+ const badges = await db.eventBadges.findByEvent(event.id)
+ const selectedBadge = badges.find((badge) => badge.id === query.badge) ?? badges[0]
+ const claimUrl = query.token ? `${await baseUrl()}/claim/badge/${encodeURIComponent(query.token)}` : null
+ const qrSvg = claimUrl
+ ? await QRCode.toString(claimUrl, {
+ type: 'svg',
+ width: 320,
+ margin: 2,
+ color: {
+ dark: '#111111',
+ light: '#f0ebe0',
+ },
+ })
+ : null
+
+ return (
+
+
+
+
+
+ Back to Organizer
+
+
+
+ Claim QR
+
+
+ {event.name}
+
+
+
+
+
+
+
+ {selectedBadge ? (
+ <>
+
+
+ M
+
+
+
+
{selectedBadge.name}
+ {selectedBadge.description && (
+
+ {selectedBadge.description}
+
+ )}
+
+ {formatEventLocation(event)} Β· {formatDate(event.date)}
+
+
+ >
+ ) : (
+
+ No badge exists for this event
+
+ )}
+
+
+
+
+
+
+ {selectedBadge && qrSvg && claimUrl ? (
+
+
+
+
+ Claim URL
+
+
+ {claimUrl}
+
+
+ This QR claim token is only shown in full here. Generate a fresh QR if this link is lost.
+
+
+
+ ) : selectedBadge ? (
+
+ ) : (
+
+ 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 (
+
+ )
+}
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
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function Field({
+ name,
+ label,
+ placeholder,
+ type = 'text',
+ required = true,
+ maxLength,
+}: {
+ name: string
+ label: string
+ placeholder: string
+ type?: string
+ required?: boolean
+ maxLength?: number
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function SectionTitle({ label }: { label: string }) {
+ return (
+
+ )
+}
diff --git a/src/app/organizer/page.tsx b/src/app/organizer/page.tsx
new file mode 100644
index 0000000..d02a484
--- /dev/null
+++ b/src/app/organizer/page.tsx
@@ -0,0 +1,163 @@
+import Link from 'next/link'
+import { requireUser } from '@/lib/auth'
+import { db } from '@/lib/db'
+import type { EventBadge, Organizer, OrganizerEvent } from '@/lib/types'
+
+export default async function OrganizerPage() {
+ const user = await requireUser()
+ const organizers = await db.organizers.findByOwner(user.id)
+ const events = await db.organizerEvents.findByOrganizerIds(organizers.map((organizer) => organizer.id))
+ const badges = await db.eventBadges.findByEventIds(events.map((event) => event.id))
+ const badgesByEvent = groupBadgesByEvent(badges)
+ const verifiedOrganizers = organizers.filter((organizer) => organizer.status === 'verified')
+
+ return (
+
+
+
+
+ Organizer
+
+
+ Event Badges
+
+
+ {verifiedOrganizers.length > 0 && (
+
+
+ New Badge
+
+ )}
+
+
+
+ {organizers.length === 0 ? (
+
+ ) : (
+ <>
+ {verifiedOrganizers.length === 0 && (
+
+ )}
+ {organizers.map((organizer) => (
+ event.organizer_id === organizer.id)}
+ badgesByEvent={badgesByEvent}
+ />
+ ))}
+ >
+ )}
+
+
+ )
+}
+
+function OrganizerSection({
+ organizer,
+ events,
+ badgesByEvent,
+}: {
+ organizer: Organizer
+ events: OrganizerEvent[]
+ badgesByEvent: Map
+}) {
+ return (
+
+
+
+
+ Β§ {organizer.name}
+
+
+
+ {organizer.status}
+
+
+
+
+ {events.length === 0 ? (
+
+ ) : (
+ events.map((event) => {
+ const eventBadges = badgesByEvent.get(event.id) ?? []
+ return (
+
+
+
+
{event.name}
+
+ {formatEventLocation(event)} Β· {formatDate(event.date)}
+
+ {eventBadges.map((badge) => (
+
+ {badge.name} Β· {badge.status}
+
+ ))}
+
+ {eventBadges[0] && organizer.status === 'verified' && (
+
+ QR β
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+ )
+}
+
+function groupBadgesByEvent(badges: EventBadge[]): Map {
+ const grouped = new Map()
+ for (const badge of badges) {
+ grouped.set(badge.organizer_event_id, [...(grouped.get(badge.organizer_event_id) ?? []), badge])
+ }
+ return grouped
+}
+
+function EmptyBand({ label }: { label: string }) {
+ return (
+
+ )
+}
+
+function EmptyRow({ label }: { label: string }) {
+ return (
+
+ )
+}
+
+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/page.tsx b/src/app/page.tsx
index 379dc8b..1bc7059 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -43,8 +43,10 @@ export default async function HomePage() {
{today}
-
-
+
+
+
+
= {
- '5k': '5K', '10k': '10K', 'half': 'Half Marathon', 'full': 'Full Marathon', 'ultra': 'Ultra',
- 'sprint': 'Sprint Tri', 'olympic': 'Olympic Tri', '70.3': '70.3 Triathlon', 'ironman': 'Ironman',
-}
-
-function timeToSeconds(t: string): number {
- const [h, m, s] = t.split(':').map(Number)
- return h * 3600 + m * 60 + (s || 0)
-}
-
-function computePBs(races: Race[]): { label: string; race: Race }[] {
- const best = new Map
()
- for (const race of races) {
- const key = `${race.sport_type}__${race.distance_category}`
- const existing = best.get(key)
- if (!existing || timeToSeconds(race.finish_time) < timeToSeconds(existing.finish_time)) {
- best.set(key, race)
- }
- }
- return Array.from(best.entries()).map(([key, race]) => {
- const dist = key.split('__')[1]
- const label = PB_LABELS[dist] ?? dist.toUpperCase()
- return { label, race }
- })
-}
-
-function countryFlag(code: string) {
- return code.toUpperCase().split('').map(c =>
- String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)
- ).join('')
-}
-
-const MILESTONE_META: Record = {
- first_finish: { icon: 'π', desc: 'First race ever' },
- finisher_703: { icon: 'π₯', desc: '70.3 Triathlon' },
- finisher_ironman:{ icon: 'π', desc: '140.6 miles' },
- finisher_marathon:{ icon: 'π', desc: 'Full marathon' },
- finisher_ultra: { icon: 'β‘', desc: 'Ultramarathon' },
- count_5: { icon: 'β', desc: '5 races finished' },
- count_10: { icon: 'π', desc: '10 races finished' },
- count_25: { icon: 'π', desc: '25 races finished' },
- count_50: { icon: 'π', desc: '50 races finished' },
-}
-
-const ALL_MILESTONES = ['first_finish', 'finisher_703', 'finisher_ironman', 'finisher_marathon', 'finisher_ultra', 'count_5', 'count_10', 'count_25', 'count_50']
+import { buildPassportSummary } from '@/lib/passport'
+import { PassportView } from '@/components/PassportView'
export default async function PassportPage() {
const user = await requireUser()
+ const data = await db.passport.findPrivateByUser(user.id)
- const [badges, races] = await Promise.all([
- db.badges.findByUser(user.id),
- db.races.findByUser(user.id),
- ])
-
- const earnedKeys = new Set(badges.map(b => b.key))
- const pbs = computePBs(races)
- const geography = badges.filter(b => b.category === 'geography')
- const nonGeo = badges.filter(b => b.category !== 'geography')
- const lockedMilestones = ALL_MILESTONES.filter(k => !earnedKeys.has(k))
- const raceCount = races.length
-
- return (
-
-
- {/* Page header */}
-
-
-
Achievements
-
Passport
-
-
-
- {raceCount} races Β· {geography.filter(b => b.key.replace('country_', '').length === 2).length} countries Β· {badges.length} badges
-
-
-
-
- {badges.length} / 18
-
-
-
-
-
- {badges.length === 0 ? (
-
-
No badges yet
-
- Log a race to earn badges β
-
-
- ) : (
-
-
- {/* Geography */}
- {geography.filter(b => b.key.replace('country_', '')).length > 0 && (
-
-
-
- Β§ Geography Β· {geography.filter(b => b.key.replace('country_', '').length === 2).length} {geography.length === 1 ? 'Country' : 'Countries'}
-
-
-
- {geography
- .filter(b => b.key.replace('country_', '').length === 2)
- .map((badge, i, arr) => {
- const code = badge.key.replace('country_', '')
- return (
-
-
{countryFlag(code)}
-
{code}
-
EARNED
-
- )
- })}
-
-
- )}
-
- {/* Personal Bests */}
- {pbs.length > 0 && (
-
-
-
- Β§ Personal Bests
-
-
-
- {pbs.map(({ label, race }) => (
-
-
- {label}
-
-
- {race.finish_time}
-
-
-
- {race.name}
-
-
- {race.location_city} Β· {new Date(race.date).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toUpperCase()}
-
-
-
- ))}
-
-
- )}
+ if (!data) {
+ throw new Error('Profile not found')
+ }
- {/* Milestones */}
-
-
-
- {/* Earned */}
- {nonGeo.map((badge, i) => {
- const meta = MILESTONE_META[badge.key]
- return (
-
-
- {meta?.icon ?? 'π
'}
-
-
-
{badge.name}
-
- β {new Date(badge.earned_at).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toUpperCase()}
-
-
-
- )
- })}
- {/* Locked */}
- {lockedMilestones.map((key) => {
- const meta = MILESTONE_META[key]
- return (
-
-
- {meta?.icon ?? 'π
'}
-
-
-
{key.replace(/_/g, ' ')}
-
{meta?.desc ?? ''}
-
-
- )
- })}
-
-
+ const summary = buildPassportSummary({
+ profile: data.profile,
+ races: data.races,
+ badges: data.badges,
+ event_badges: data.event_badges,
+ visibility: 'private',
+ })
-
- )}
-
- )
+ return
}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index a79546f..dc5de2e 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -3,6 +3,8 @@ import { getProfile, updateProfile } from '@/lib/profile'
import { signOut } from '@/app/login/actions'
import { db } from '@/lib/db'
import Link from 'next/link'
+import { DEFAULT_AVATAR_KEY } from '@/lib/avatars'
+import { ProfileAvatarPicker } from '@/components/ProfileAvatarPicker'
export default async function ProfilePage({
searchParams,
@@ -15,6 +17,8 @@ export default async function ProfilePage({
const { error, saved } = await searchParams
const displayName = profile?.display_name ?? ''
+ const avatarKey = profile?.avatar_key ?? DEFAULT_AVATAR_KEY
+ const profileFormId = 'profile-form'
return (
@@ -22,15 +26,25 @@ export default async function ProfilePage({
{/* Header */}
-
Your Profile
-
- {displayName || 'ATHLETE'}.
-
- {profile?.username && (
-
- marka.app/share/{profile.username}
-
- )}
+
+
+
Your Profile
+
+ {displayName || 'ATHLETE'}.
+
+ {profile?.username && (
+
+ marka.app/share/{profile.username}
+
+ )}
+
+
+
{saved && (
@@ -40,7 +54,7 @@ export default async function ProfilePage({
{error}
)}
-