From 3ea1c3f889be5d31eaa2720a9839af76ef7f8564 Mon Sep 17 00:00:00 2001 From: siliconsolder Date: Fri, 21 Aug 2026 06:26:39 +0000 Subject: [PATCH] Initial clean VYTA import --- .gitignore | 33 + app/vyta/.gitignore | 50 + app/vyta/.prettierrc | 10 + app/vyta/AGENTS.md | 9 + app/vyta/CLAUDE.md | 1 + app/vyta/Dockerfile | 42 + app/vyta/README.md | 36 + app/vyta/eslint.config.mjs | 18 + app/vyta/next.config.ts | 8 + app/vyta/package-lock.json | 7318 +++++++++++++++++ app/vyta/package.json | 36 + app/vyta/postcss.config.mjs | 7 + app/vyta/prettierignore | 5 + app/vyta/public/file.svg | 1 + app/vyta/public/globe.svg | 1 + app/vyta/public/next.svg | 1 + app/vyta/public/sw.js | 84 + app/vyta/public/vercel.svg | 1 + app/vyta/public/window.svg | 1 + .../app/api/auth/callback/pocketid/route.ts | 183 + app/vyta/src/app/api/auth/login/route.ts | 87 + .../src/app/api/garmin/connection/route.ts | 91 + app/vyta/src/app/api/login/route.ts | 169 + app/vyta/src/app/api/logout/route.ts | 47 + .../app/api/notifications/evaluate/route.ts | 63 + app/vyta/src/app/api/push/subscribe/route.ts | 81 + app/vyta/src/app/api/push/test/route.ts | 122 + app/vyta/src/app/api/settings/route.ts | 259 + app/vyta/src/app/api/sync/status/route.ts | 91 + app/vyta/src/app/api/sync/trigger/route.ts | 84 + app/vyta/src/app/api/vyta-data/route.ts | 60 + app/vyta/src/app/apple-icon.tsx | 52 + app/vyta/src/app/favicon.ico | Bin 0 -> 25931 bytes app/vyta/src/app/globals.css | 180 + app/vyta/src/app/halsa/page.tsx | 1576 ++++ app/vyta/src/app/icon.tsx | 54 + app/vyta/src/app/installningar/page.tsx | 278 + app/vyta/src/app/layout.tsx | 39 + app/vyta/src/app/login/page.tsx | 52 + app/vyta/src/app/manifest.ts | 29 + app/vyta/src/app/page.tsx | 870 ++ app/vyta/src/app/somn/page.tsx | 339 + app/vyta/src/app/traning/[id]/page.tsx | 303 + app/vyta/src/app/traning/page.tsx | 528 ++ app/vyta/src/components/ActivityRouteMap.tsx | 113 + app/vyta/src/components/BottomNav.tsx | 54 + app/vyta/src/components/DailyInsight.tsx | 63 + app/vyta/src/components/DateNavigator.tsx | 118 + .../components/GarminConnectionSettings.tsx | 236 + .../src/components/NotificationSettings.tsx | 915 +++ app/vyta/src/components/PushNotifications.tsx | 356 + app/vyta/src/components/SettingsButton.tsx | 26 + app/vyta/src/components/SettingsForm.tsx | 540 ++ app/vyta/src/components/SleepPlanner.tsx | 155 + app/vyta/src/components/SyncStatus.tsx | 558 ++ app/vyta/src/components/TodayHeader.tsx | 87 + app/vyta/src/components/TrendChart.tsx | 68 + app/vyta/src/components/VytaInsightCard.tsx | 156 + app/vyta/src/components/WeeklySummaryCard.tsx | 79 + app/vyta/src/engine/baseline.ts | 28 + app/vyta/src/engine/bioAge.ts | 858 ++ app/vyta/src/engine/recovery.ts | 41 + app/vyta/src/engine/recoveryBreakdown.ts | 90 + app/vyta/src/engine/strainTarget.ts | 53 + app/vyta/src/engine/vytaAgeRecommendations.ts | 1208 +++ app/vyta/src/lib/auth.ts | 78 + app/vyta/src/lib/energy.ts | 346 + app/vyta/src/lib/format.ts | 14 + app/vyta/src/lib/garminConnection.ts | 101 + app/vyta/src/lib/getVytaData.ts | 43 + app/vyta/src/lib/notificationEngine.ts | 480 ++ app/vyta/src/lib/pocketId.ts | 56 + app/vyta/src/lib/pushSubscriptions.ts | 163 + app/vyta/src/lib/sendPush.ts | 169 + app/vyta/src/lib/strainScale.ts | 49 + app/vyta/src/lib/vytaIntelligence.ts | 318 + app/vyta/src/lib/vytaSettings.ts | 421 + app/vyta/src/proxy.ts | 108 + app/vyta/src/types/garmin.ts | 98 + app/vyta/src/types/vyta.ts | 98 + app/vyta/src/types/vytaApi.ts | 288 + app/vyta/tsconfig.json | 34 + sync/vyta-sync/.gitignore | 4 + sync/vyta-sync/Dockerfile | 13 + sync/vyta-sync/collector.py | 1641 ++++ sync/vyta-sync/normalizer.py | 1252 +++ sync/vyta-sync/requirements.txt | 3 + sync/vyta-sync/run_sync.cmd | 8 + sync/vyta-sync/scheduler.py | 288 + sync/vyta-sync/sync.py | 379 + sync/vyta-sync/test_garmin.py | 70 + sync/vyta-sync/upload_to_blob.py | 166 + 92 files changed, 25789 insertions(+) create mode 100644 .gitignore create mode 100644 app/vyta/.gitignore create mode 100644 app/vyta/.prettierrc create mode 100644 app/vyta/AGENTS.md create mode 100644 app/vyta/CLAUDE.md create mode 100644 app/vyta/Dockerfile create mode 100644 app/vyta/README.md create mode 100644 app/vyta/eslint.config.mjs create mode 100644 app/vyta/next.config.ts create mode 100644 app/vyta/package-lock.json create mode 100644 app/vyta/package.json create mode 100644 app/vyta/postcss.config.mjs create mode 100644 app/vyta/prettierignore create mode 100644 app/vyta/public/file.svg create mode 100644 app/vyta/public/globe.svg create mode 100644 app/vyta/public/next.svg create mode 100644 app/vyta/public/sw.js create mode 100644 app/vyta/public/vercel.svg create mode 100644 app/vyta/public/window.svg create mode 100644 app/vyta/src/app/api/auth/callback/pocketid/route.ts create mode 100644 app/vyta/src/app/api/auth/login/route.ts create mode 100755 app/vyta/src/app/api/garmin/connection/route.ts create mode 100644 app/vyta/src/app/api/login/route.ts create mode 100644 app/vyta/src/app/api/logout/route.ts create mode 100644 app/vyta/src/app/api/notifications/evaluate/route.ts create mode 100644 app/vyta/src/app/api/push/subscribe/route.ts create mode 100644 app/vyta/src/app/api/push/test/route.ts create mode 100644 app/vyta/src/app/api/settings/route.ts create mode 100644 app/vyta/src/app/api/sync/status/route.ts create mode 100644 app/vyta/src/app/api/sync/trigger/route.ts create mode 100644 app/vyta/src/app/api/vyta-data/route.ts create mode 100644 app/vyta/src/app/apple-icon.tsx create mode 100644 app/vyta/src/app/favicon.ico create mode 100644 app/vyta/src/app/globals.css create mode 100644 app/vyta/src/app/halsa/page.tsx create mode 100644 app/vyta/src/app/icon.tsx create mode 100644 app/vyta/src/app/installningar/page.tsx create mode 100644 app/vyta/src/app/layout.tsx create mode 100644 app/vyta/src/app/login/page.tsx create mode 100644 app/vyta/src/app/manifest.ts create mode 100644 app/vyta/src/app/page.tsx create mode 100644 app/vyta/src/app/somn/page.tsx create mode 100644 app/vyta/src/app/traning/[id]/page.tsx create mode 100644 app/vyta/src/app/traning/page.tsx create mode 100644 app/vyta/src/components/ActivityRouteMap.tsx create mode 100644 app/vyta/src/components/BottomNav.tsx create mode 100644 app/vyta/src/components/DailyInsight.tsx create mode 100644 app/vyta/src/components/DateNavigator.tsx create mode 100755 app/vyta/src/components/GarminConnectionSettings.tsx create mode 100644 app/vyta/src/components/NotificationSettings.tsx create mode 100644 app/vyta/src/components/PushNotifications.tsx create mode 100644 app/vyta/src/components/SettingsButton.tsx create mode 100644 app/vyta/src/components/SettingsForm.tsx create mode 100644 app/vyta/src/components/SleepPlanner.tsx create mode 100644 app/vyta/src/components/SyncStatus.tsx create mode 100644 app/vyta/src/components/TodayHeader.tsx create mode 100644 app/vyta/src/components/TrendChart.tsx create mode 100644 app/vyta/src/components/VytaInsightCard.tsx create mode 100644 app/vyta/src/components/WeeklySummaryCard.tsx create mode 100644 app/vyta/src/engine/baseline.ts create mode 100644 app/vyta/src/engine/bioAge.ts create mode 100644 app/vyta/src/engine/recovery.ts create mode 100644 app/vyta/src/engine/recoveryBreakdown.ts create mode 100644 app/vyta/src/engine/strainTarget.ts create mode 100644 app/vyta/src/engine/vytaAgeRecommendations.ts create mode 100644 app/vyta/src/lib/auth.ts create mode 100644 app/vyta/src/lib/energy.ts create mode 100644 app/vyta/src/lib/format.ts create mode 100755 app/vyta/src/lib/garminConnection.ts create mode 100644 app/vyta/src/lib/getVytaData.ts create mode 100644 app/vyta/src/lib/notificationEngine.ts create mode 100644 app/vyta/src/lib/pocketId.ts create mode 100644 app/vyta/src/lib/pushSubscriptions.ts create mode 100644 app/vyta/src/lib/sendPush.ts create mode 100644 app/vyta/src/lib/strainScale.ts create mode 100644 app/vyta/src/lib/vytaIntelligence.ts create mode 100644 app/vyta/src/lib/vytaSettings.ts create mode 100644 app/vyta/src/proxy.ts create mode 100644 app/vyta/src/types/garmin.ts create mode 100644 app/vyta/src/types/vyta.ts create mode 100644 app/vyta/src/types/vytaApi.ts create mode 100644 app/vyta/tsconfig.json create mode 100644 sync/vyta-sync/.gitignore create mode 100644 sync/vyta-sync/Dockerfile create mode 100644 sync/vyta-sync/collector.py create mode 100644 sync/vyta-sync/normalizer.py create mode 100644 sync/vyta-sync/requirements.txt create mode 100755 sync/vyta-sync/run_sync.cmd create mode 100644 sync/vyta-sync/scheduler.py create mode 100644 sync/vyta-sync/sync.py create mode 100644 sync/vyta-sync/test_garmin.py create mode 100644 sync/vyta-sync/upload_to_blob.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c469c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +.env +.env.* +*.pem +*secret* +*credentials* +*token* + +data/ +private-data/ +public/data/ +.garmin_tokens/ +garmin-credentials.json +vyta-garmin.json +sync-status.json +sync-request.json +push-subscriptions.json +notification-state.json +settings.json + +node_modules/ +.next/ +.vercel/ +out/ +build/ +*.tsbuildinfo +__pycache__/ +*.pyc +logs/ + +*probe* +*output* +*.bak* +.DS_Store diff --git a/app/vyta/.gitignore b/app/vyta/.gitignore new file mode 100644 index 0000000..176de60 --- /dev/null +++ b/app/vyta/.gitignore @@ -0,0 +1,50 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.vercel +.env* + +# local Garmin credentials/tokens +private-data/garmin-credentials.json +private-data/.garmin_tokens/ +.garmin_tokens/ +garmin-credentials.json diff --git a/app/vyta/.prettierrc b/app/vyta/.prettierrc new file mode 100644 index 0000000..59f7ea2 --- /dev/null +++ b/app/vyta/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "useTabs": false, + "printWidth": 100, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always" +} \ No newline at end of file diff --git a/app/vyta/AGENTS.md b/app/vyta/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/app/vyta/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/app/vyta/CLAUDE.md b/app/vyta/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/app/vyta/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/app/vyta/Dockerfile b/app/vyta/Dockerfile new file mode 100644 index 0000000..b36ab66 --- /dev/null +++ b/app/vyta/Dockerfile @@ -0,0 +1,42 @@ +FROM node:24-alpine AS deps + +WORKDIR /app + +COPY package.json package-lock.json ./ + +RUN npm ci + + +FROM node:24-alpine AS builder + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + + +FROM node:24-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/app/vyta/README.md b/app/vyta/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/app/vyta/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/vyta/eslint.config.mjs b/app/vyta/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/app/vyta/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/app/vyta/next.config.ts b/app/vyta/next.config.ts new file mode 100644 index 0000000..5be9448 --- /dev/null +++ b/app/vyta/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactCompiler: true, + output: "standalone", +}; + +export default nextConfig; diff --git a/app/vyta/package-lock.json b/app/vyta/package-lock.json new file mode 100644 index 0000000..2349344 --- /dev/null +++ b/app/vyta/package-lock.json @@ -0,0 +1,7318 @@ +{ + "name": "vyta", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vyta", + "version": "0.1.0", + "dependencies": { + "@vercel/blob": "^2.8.0", + "bcryptjs": "^3.0.3", + "jose": "^6.2.8", + "leaflet": "^1.9.4", + "next": "16.3.1", + "openid-client": "^6.8.5", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-leaflet": "^5.0.0", + "web-push": "^3.6.7" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.3.1", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@next/env": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz", + "integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.1.tgz", + "integrity": "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz", + "integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz", + "integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz", + "integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz", + "integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz", + "integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz", + "integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz", + "integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz", + "integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vercel/blob": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.8.0.tgz", + "integrity": "sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/oidc": "^3.6.1", + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vercel/cli-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.3.tgz", + "integrity": "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==", + "license": "Apache-2.0", + "dependencies": { + "xdg-app-paths": "5", + "zod": "4.1.11" + } + }, + "node_modules/@vercel/cli-config/node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@vercel/cli-exec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-1.0.1.tgz", + "integrity": "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==", + "license": "Apache-2.0", + "dependencies": { + "execa": "5.1.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.8.4.tgz", + "integrity": "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/cli-config": "0.2.3", + "@vercel/cli-exec": "1.0.1", + "jose": "^5.9.6" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vercel/oidc/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "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" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "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" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "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": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.406", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz", + "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.1.tgz", + "integrity": "sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.3.1", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", + "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "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==", + "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", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", + "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.1", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.1", + "@next/swc-darwin-x64": "16.3.1", + "@next/swc-linux-arm64-gnu": "16.3.1", + "@next/swc-linux-arm64-musl": "16.3.1", + "@next/swc-linux-x64-gnu": "16.3.1", + "@next/swc-linux-x64-musl": "16.3.1", + "@next/swc-win32-arm64-msvc": "16.3.1", + "@next/swc-win32-x64-msvc": "16.3.1", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openid-client": { + "version": "6.8.5", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.5.tgz", + "integrity": "sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.8", + "oauth4webapi": "^3.8.7" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-paths": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", + "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", + "license": "MIT", + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xdg-app-paths": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", + "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1", + "xdg-portable": "^7.2.0" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/xdg-portable": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", + "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/app/vyta/package.json b/app/vyta/package.json new file mode 100644 index 0000000..b0cff05 --- /dev/null +++ b/app/vyta/package.json @@ -0,0 +1,36 @@ +{ + "name": "vyta", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@vercel/blob": "^2.8.0", + "bcryptjs": "^3.0.3", + "jose": "^6.2.8", + "leaflet": "^1.9.4", + "next": "16.3.1", + "openid-client": "^6.8.5", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-leaflet": "^5.0.0", + "web-push": "^3.6.7" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.3.1", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/app/vyta/postcss.config.mjs b/app/vyta/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/app/vyta/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/app/vyta/prettierignore b/app/vyta/prettierignore new file mode 100644 index 0000000..26bac0e --- /dev/null +++ b/app/vyta/prettierignore @@ -0,0 +1,5 @@ +.next +node_modules +.vercel +private-data +public/data \ No newline at end of file diff --git a/app/vyta/public/file.svg b/app/vyta/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/app/vyta/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/vyta/public/globe.svg b/app/vyta/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/app/vyta/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/vyta/public/next.svg b/app/vyta/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/app/vyta/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/vyta/public/sw.js b/app/vyta/public/sw.js new file mode 100644 index 0000000..651940a --- /dev/null +++ b/app/vyta/public/sw.js @@ -0,0 +1,84 @@ +self.addEventListener("push", (event) => { + let payload = {}; + + try { + payload = event.data + ? event.data.json() + : {}; + } catch { + payload = { + title: "Vyta", + body: event.data + ? event.data.text() + : "Ny uppdatering", + }; + } + + const title = + payload.title || "Vyta"; + + const options = { + body: + payload.body || + "Ny hälsouppdatering finns.", + + icon: + payload.icon || + "/icon", + + badge: + payload.badge || + "/icon", + + tag: + payload.tag || + "vyta", + + data: { + url: + payload.url || + "/", + }, + }; + + event.waitUntil( + self.registration.showNotification( + title, + options + ) + ); +}); + + +self.addEventListener( + "notificationclick", + (event) => { + event.notification.close(); + + const targetUrl = + event.notification.data?.url || + "/"; + + event.waitUntil( + clients + .matchAll({ + type: "window", + includeUncontrolled: true, + }) + .then((clientList) => { + for (const client of clientList) { + if ("focus" in client) { + client.navigate(targetUrl); + return client.focus(); + } + } + + if (clients.openWindow) { + return clients.openWindow( + targetUrl + ); + } + }) + ); + } +); \ No newline at end of file diff --git a/app/vyta/public/vercel.svg b/app/vyta/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/app/vyta/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/vyta/public/window.svg b/app/vyta/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/app/vyta/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/vyta/src/app/api/auth/callback/pocketid/route.ts b/app/vyta/src/app/api/auth/callback/pocketid/route.ts new file mode 100644 index 0000000..06418a4 --- /dev/null +++ b/app/vyta/src/app/api/auth/callback/pocketid/route.ts @@ -0,0 +1,183 @@ +import { + NextRequest, + NextResponse, +} from "next/server"; + +import { + cookies, +} from "next/headers"; + +import * as oidc from "openid-client"; + +import { + createSessionToken, + SESSION_COOKIE, + SESSION_MAX_AGE, +} from "@/lib/auth"; + +import { + getPocketIdConfig, + getPocketIdRedirectUri, +} from "@/lib/pocketId"; + +const PKCE_COOKIE = + "vyta_oidc_pkce"; + +const STATE_COOKIE = + "vyta_oidc_state"; + +export async function GET( + request: NextRequest +) { + try { + const config = + await getPocketIdConfig(); + + const cookieStore = + await cookies(); + + const codeVerifier = + cookieStore.get( + PKCE_COOKIE + )?.value; + + const expectedState = + cookieStore.get( + STATE_COOKIE + )?.value; + + if ( + !codeVerifier || + !expectedState + ) { + throw new Error( + "OIDC state eller PKCE saknas" + ); + } + + /* + * Viktigt bakom Pangolin/reverse proxy: + * + * request.url kan innehålla den interna + * hosten/protokollet. Token exchange måste + * använda exakt samma redirect_uri som + * authorize-anropet använde. + */ + const callbackUrl = + new URL( + getPocketIdRedirectUri() + ); + + /* + * Behåll query-parametrarna från den faktiska + * callbacken: code, state osv. + */ + const incomingUrl = + new URL( + request.url + ); + + callbackUrl.search = + incomingUrl.search; + + const tokens = + await oidc.authorizationCodeGrant( + config, + callbackUrl, + { + pkceCodeVerifier: + codeVerifier, + + expectedState, + } + ); + + const claims = + tokens.claims(); + + if ( + !claims || + typeof claims.sub !== + "string" + ) { + throw new Error( + "Pocket ID returnerade ingen giltig användaridentitet" + ); + } + + const sessionToken = + await createSessionToken( + claims.sub + ); + + const publicUrl = + process.env + .VYTA_PUBLIC_URL; + + if (!publicUrl) { + throw new Error( + "VYTA_PUBLIC_URL saknas" + ); + } + + const response = + NextResponse.redirect( + new URL( + "/", + publicUrl + ) + ); + + response.cookies.set({ + name: + SESSION_COOKIE, + + value: + sessionToken, + + httpOnly: + true, + + secure: + true, + + sameSite: + "lax", + + path: + "/", + + maxAge: + SESSION_MAX_AGE, + }); + + response.cookies.delete( + PKCE_COOKIE + ); + + response.cookies.delete( + STATE_COOKIE + ); + + return response; + } catch ( + error + ) { + console.error( + "Pocket ID callback error:", + error + ); + + const publicUrl = + process.env + .VYTA_PUBLIC_URL || + "https://vyta.bitli.se"; + + return NextResponse.redirect( + new URL( + "/login?error=oidc", + publicUrl + ) + ); + } +} diff --git a/app/vyta/src/app/api/auth/login/route.ts b/app/vyta/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..024d9a3 --- /dev/null +++ b/app/vyta/src/app/api/auth/login/route.ts @@ -0,0 +1,87 @@ +import { + NextResponse, +} from "next/server"; + +import { + cookies, +} from "next/headers"; + +import * as oidc from "openid-client"; + +import { + getPocketIdConfig, + getPocketIdRedirectUri, +} from "@/lib/pocketId"; + +const PKCE_COOKIE = + "vyta_oidc_pkce"; + +const STATE_COOKIE = + "vyta_oidc_state"; + +export async function GET() { + const config = + await getPocketIdConfig(); + + const codeVerifier = + oidc.randomPKCECodeVerifier(); + + const codeChallenge = + await oidc.calculatePKCECodeChallenge( + codeVerifier + ); + + const state = + oidc.randomState(); + + const authorizationUrl = + oidc.buildAuthorizationUrl( + config, + { + redirect_uri: + getPocketIdRedirectUri(), + + scope: + "openid profile email", + + code_challenge: + codeChallenge, + + code_challenge_method: + "S256", + + state, + } + ); + + const cookieStore = + await cookies(); + + cookieStore.set( + PKCE_COOKIE, + codeVerifier, + { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 600, + } + ); + + cookieStore.set( + STATE_COOKIE, + state, + { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 600, + } + ); + + return NextResponse.redirect( + authorizationUrl + ); +} diff --git a/app/vyta/src/app/api/garmin/connection/route.ts b/app/vyta/src/app/api/garmin/connection/route.ts new file mode 100755 index 0000000..fa69e0e --- /dev/null +++ b/app/vyta/src/app/api/garmin/connection/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; + +import { isAuthenticated } from "@/lib/auth"; +import { + deleteGarminCredentials, + getGarminConnectionStatus, + saveGarminCredentials, +} from "@/lib/garminConnection"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + if (!(await isAuthenticated())) { + return unauthorized(); + } + + return NextResponse.json(await getGarminConnectionStatus()); +} + +export async function POST(request: Request) { + if (!(await isAuthenticated())) { + return unauthorized(); + } + + try { + const body = await request.json(); + const email = typeof body.email === "string" ? body.email.trim() : ""; + const password = typeof body.password === "string" ? body.password : ""; + + if (!email || !email.includes("@")) { + return invalid("Ange en giltig Garmin-mejl."); + } + + if (!password) { + return invalid("Ange Garmin-lösenord."); + } + + const status = await saveGarminCredentials(email, password); + + return NextResponse.json({ + ok: true, + ...status, + }); + } catch (error) { + console.error("Garmin connection save error:", error); + + return NextResponse.json( + { + error: "Kunde inte spara Garmin-kopplingen.", + }, + { + status: 500, + } + ); + } +} + +export async function DELETE() { + if (!(await isAuthenticated())) { + return unauthorized(); + } + + const status = await deleteGarminCredentials(); + + return NextResponse.json({ + ok: true, + ...status, + }); +} + +function unauthorized() { + return NextResponse.json( + { + error: "Unauthorized", + }, + { + status: 401, + } + ); +} + +function invalid(message: string) { + return NextResponse.json( + { + error: message, + }, + { + status: 400, + } + ); +} diff --git a/app/vyta/src/app/api/login/route.ts b/app/vyta/src/app/api/login/route.ts new file mode 100644 index 0000000..10fbb88 --- /dev/null +++ b/app/vyta/src/app/api/login/route.ts @@ -0,0 +1,169 @@ +import bcrypt from "bcryptjs"; + +import { + NextRequest, + NextResponse, +} from "next/server"; + +import { + createSessionToken, + SESSION_COOKIE, + SESSION_MAX_AGE, +} from "@/lib/auth"; + +function getPublicOrigin( + request: NextRequest +) { + const forwardedHost = + request.headers.get( + "x-forwarded-host" + ); + + const host = + forwardedHost || + request.headers.get( + "host" + ); + + const forwardedProto = + request.headers.get( + "x-forwarded-proto" + ); + + const protocol = + forwardedProto || + ( + host?.includes( + "172.16." + ) || + host?.includes( + "localhost" + ) || + host?.includes( + "127.0.0.1" + ) + ? "http" + : "https" + ); + + if (host) { + return `${protocol}://${host}`; + } + + return request.nextUrl.origin; +} + +export async function POST( + request: NextRequest +) { + const formData = + await request.formData(); + + const username = + String( + formData.get( + "username" + ) ?? "" + ).trim(); + + const password = + String( + formData.get( + "password" + ) ?? "" + ); + + const expectedUsername = + process.env.VYTA_USERNAME; + + const passwordHash = + process.env.VYTA_PASSWORD_HASH; + + if ( + !expectedUsername || + !passwordHash + ) { + console.error( + "VYTA_USERNAME eller VYTA_PASSWORD_HASH saknas" + ); + + return new NextResponse( + "Auth är inte konfigurerad", + { + status: 500, + } + ); + } + + const passwordMatches = + await bcrypt.compare( + password, + passwordHash + ); + + const usernameMatches = + username === + expectedUsername; + + const origin = + getPublicOrigin( + request + ); + + if ( + !usernameMatches || + !passwordMatches + ) { + return NextResponse.redirect( + new URL( + "/login?error=1", + origin + ), + 303 + ); + } + + const token = + await createSessionToken( + username + ); + + const response = + NextResponse.redirect( + new URL( + "/", + origin + ), + 303 + ); + + const isHttps = + origin.startsWith( + "https://" + ); + + response.cookies.set({ + name: + SESSION_COOKIE, + + value: + token, + + httpOnly: + true, + + secure: + isHttps, + + sameSite: + "strict", + + path: + "/", + + maxAge: + SESSION_MAX_AGE, + }); + + return response; +} diff --git a/app/vyta/src/app/api/logout/route.ts b/app/vyta/src/app/api/logout/route.ts new file mode 100644 index 0000000..804a3ac --- /dev/null +++ b/app/vyta/src/app/api/logout/route.ts @@ -0,0 +1,47 @@ +import { + NextResponse, +} from "next/server"; + +import { + SESSION_COOKIE, +} from "@/lib/auth"; + +export async function POST() { + const publicUrl = + process.env.VYTA_PUBLIC_URL; + + const response = + NextResponse.redirect( + new URL( + "/login", + publicUrl || + "https://vyta.bitli.se" + ), + 303 + ); + + response.cookies.set({ + name: + SESSION_COOKIE, + + value: + "", + + path: + "/", + + expires: + new Date(0), + + httpOnly: + true, + + secure: + true, + + sameSite: + "lax", + }); + + return response; +} diff --git a/app/vyta/src/app/api/notifications/evaluate/route.ts b/app/vyta/src/app/api/notifications/evaluate/route.ts new file mode 100644 index 0000000..a061a46 --- /dev/null +++ b/app/vyta/src/app/api/notifications/evaluate/route.ts @@ -0,0 +1,63 @@ +import { timingSafeEqual } from "crypto"; + +import { NextResponse } from "next/server"; + +import { evaluateNotifications } from "@/lib/notificationEngine"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + if (!authorized(request)) { + return NextResponse.json( + { + error: "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + const result = await evaluateNotifications(); + + return NextResponse.json(result); + } catch (error) { + console.error("Notification evaluation failed:", error); + + return NextResponse.json( + { + error: "Notification evaluation failed", + }, + { + status: 500, + } + ); + } +} + +function authorized(request: Request) { + const secret = process.env.VYTA_INTERNAL_SYNC_SECRET; + + if (!secret) { + return false; + } + + const authorization = request.headers.get("authorization"); + + if (!authorization?.startsWith("Bearer ")) { + return false; + } + + const supplied = authorization.slice(7); + + const expectedBuffer = Buffer.from(secret); + + const suppliedBuffer = Buffer.from(supplied); + + if (expectedBuffer.length !== suppliedBuffer.length) { + return false; + } + + return timingSafeEqual(expectedBuffer, suppliedBuffer); +} diff --git a/app/vyta/src/app/api/push/subscribe/route.ts b/app/vyta/src/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..f0b7c2a --- /dev/null +++ b/app/vyta/src/app/api/push/subscribe/route.ts @@ -0,0 +1,81 @@ +import { + NextResponse, +} from "next/server"; + +import { + isAuthenticated, +} from "@/lib/auth"; + +import { + savePushSubscription, +} from "@/lib/pushSubscriptions"; + + +export const dynamic = + "force-dynamic"; + + +export async function POST( + request: Request +) { + const authenticated = + await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: + "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + const subscription = + await request.json(); + + if ( + !subscription || + typeof subscription.endpoint !== + "string" || + !subscription.keys?.p256dh || + !subscription.keys?.auth + ) { + return NextResponse.json( + { + error: + "Ogiltig push-subscription", + }, + { + status: 400, + } + ); + } + + await savePushSubscription( + subscription + ); + + return NextResponse.json({ + ok: true, + }); + } catch (error) { + console.error( + "Push subscribe error:", + error + ); + + return NextResponse.json( + { + error: + "Kunde inte spara push-subscription", + }, + { + status: 500, + } + ); + } +} \ No newline at end of file diff --git a/app/vyta/src/app/api/push/test/route.ts b/app/vyta/src/app/api/push/test/route.ts new file mode 100644 index 0000000..de610d4 --- /dev/null +++ b/app/vyta/src/app/api/push/test/route.ts @@ -0,0 +1,122 @@ +import { + NextResponse, +} from "next/server"; + +import { + isAuthenticated, +} from "@/lib/auth"; + +import { + sendPush, +} from "@/lib/sendPush"; + +export const dynamic = + "force-dynamic"; + +export async function POST() { + const authenticated = + await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: + "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + const result = + await sendPush({ + title: + "Vyta fungerar 🎉", + + body: + "Pushnotiser är aktiverade.", + + url: "/", + + tag: + "vyta-test", + }); + + return NextResponse.json({ + ok: true, + ...result, + }); + } catch ( + error: unknown + ) { + console.error( + "Push test error:", + error + ); + + const details = + extractPushError( + error + ); + + console.error( + "Push test details:", + details + ); + + return NextResponse.json( + { + error: + "Kunde inte skicka testnotis", + + details, + }, + { + status: 500, + } + ); + } +} + +function extractPushError( + error: unknown +) { + if ( + typeof error !== + "object" || + error === null + ) { + return { + message: + String(error), + }; + } + + const object = + error as Record< + string, + unknown + >; + + return { + message: + typeof object.message === + "string" + ? object.message + : null, + + statusCode: + typeof object.statusCode === + "number" + ? object.statusCode + : null, + + body: + typeof object.body === + "string" + ? object.body + : null, + }; +} \ No newline at end of file diff --git a/app/vyta/src/app/api/settings/route.ts b/app/vyta/src/app/api/settings/route.ts new file mode 100644 index 0000000..e640e38 --- /dev/null +++ b/app/vyta/src/app/api/settings/route.ts @@ -0,0 +1,259 @@ +import { NextResponse } from "next/server"; + +import { isAuthenticated } from "@/lib/auth"; + +import { + getVytaSettings, + saveVytaSettings, + type StrainScale, +} from "@/lib/vytaSettings"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const authenticated = await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: "Unauthorized", + }, + { + status: 401, + } + ); + } + + const settings = await getVytaSettings(); + + return NextResponse.json(settings); +} + +export async function POST(request: Request) { + const authenticated = await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + const body = await request.json(); + + /* + * Partiella uppdateringar. + * + * Om ett fält inte skickas behåller vi + * det befintliga värdet. + */ + + const existing = await getVytaSettings(); + + const heightCm = + body.heightCm === undefined + ? existing.heightCm + : parseOptionalNumber(body.heightCm); + + const weightKg = + body.weightKg === undefined + ? existing.weightKg + : parseOptionalNumber(body.weightKg); + + const weightTargetKg = + body.weightTargetKg === undefined + ? existing.weightTargetKg + : parseOptionalNumber(body.weightTargetKg); + + const calorieTarget = + body.calorieTarget === undefined + ? existing.calorieTarget + : parseOptionalNumber(body.calorieTarget); + + const stepGoal = + body.stepGoal === undefined + ? existing.stepGoal + : parseOptionalNumber(body.stepGoal); + + const intensityMinutesGoal = + body.intensityMinutesGoal === undefined + ? existing.intensityMinutesGoal + : parseOptionalNumber(body.intensityMinutesGoal); + + const highIntensityMinutesGoal = + body.highIntensityMinutesGoal === undefined + ? existing.highIntensityMinutesGoal + : parseOptionalNumber(body.highIntensityMinutesGoal); + + const sleepGoalMinutes = + body.sleepGoalMinutes === undefined + ? existing.sleepGoalMinutes + : parseOptionalNumber(body.sleepGoalMinutes); + + const strainScale: StrainScale = + body.strainScale === undefined + ? existing.strainScale + : body.strainScale === "100" + ? "100" + : "21"; + + /* + * Validering + */ + + if ( + heightCm !== null && + (heightCm < 100 || heightCm > 250) + ) { + return invalid( + "Längden måste vara mellan 100 och 250 cm." + ); + } + + if ( + weightKg !== null && + (weightKg < 30 || weightKg > 350) + ) { + return invalid( + "Vikten måste vara mellan 30 och 350 kg." + ); + } + + if ( + weightTargetKg !== null && + (weightTargetKg < 30 || + weightTargetKg > 350) + ) { + return invalid( + "Viktmålet måste vara mellan 30 och 350 kg." + ); + } + + if ( + calorieTarget !== null && + (calorieTarget < 800 || + calorieTarget > 6000) + ) { + return invalid( + "Kalorimålet måste vara mellan 800 och 6 000 kcal." + ); + } + + if ( + stepGoal !== null && + (stepGoal < 1000 || + stepGoal > 50000) + ) { + return invalid( + "Stegmålet måste vara mellan 1 000 och 50 000 steg." + ); + } + + if ( + intensityMinutesGoal !== null && + (intensityMinutesGoal < 0 || + intensityMinutesGoal > 1500) + ) { + return invalid( + "Aktivitetsmålet måste vara mellan 0 och 1 500 min/vecka." + ); + } + + if ( + highIntensityMinutesGoal !== null && + (highIntensityMinutesGoal < 0 || + highIntensityMinutesGoal > 1000) + ) { + return invalid( + "Målet för hög intensitet måste vara mellan 0 och 1 000 min/vecka." + ); + } + + if ( + sleepGoalMinutes !== null && + (sleepGoalMinutes < 240 || + sleepGoalMinutes > 720) + ) { + return invalid( + "Sömnmålet måste vara mellan 4 och 12 timmar." + ); + } + + const notifications = + typeof body.notifications === "object" && + body.notifications !== null + ? body.notifications + : undefined; + + const settings = await saveVytaSettings({ + heightCm, + weightKg, + weightTargetKg, + + calorieTarget, + + stepGoal, + intensityMinutesGoal, + highIntensityMinutesGoal, + sleepGoalMinutes, + + strainScale, + + notifications, + }); + + return NextResponse.json({ + ok: true, + settings, + }); + } catch (error) { + console.error( + "Settings save error:", + error + ); + + return NextResponse.json( + { + error: + "Kunde inte spara inställningarna.", + }, + { + status: 500, + } + ); + } +} + +function parseOptionalNumber( + value: unknown +): number | null { + if ( + value === null || + value === undefined || + value === "" + ) { + return null; + } + + const number = Number(value); + + return Number.isFinite(number) + ? number + : null; +} + +function invalid(message: string) { + return NextResponse.json( + { + error: message, + }, + { + status: 400, + } + ); +} \ No newline at end of file diff --git a/app/vyta/src/app/api/sync/status/route.ts b/app/vyta/src/app/api/sync/status/route.ts new file mode 100644 index 0000000..097bb41 --- /dev/null +++ b/app/vyta/src/app/api/sync/status/route.ts @@ -0,0 +1,91 @@ +import fs from "fs/promises"; + +import { + NextResponse, +} from "next/server"; + +import { + isAuthenticated, +} from "@/lib/auth"; + +export const dynamic = + "force-dynamic"; + +const STATUS_PATH = + process.env + .VYTA_SYNC_STATUS_PATH || + "/data/sync-status.json"; + +export async function GET() { + const authenticated = + await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: + "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + const raw = + await fs.readFile( + STATUS_PATH, + "utf-8" + ); + + const status = + JSON.parse( + raw + ); + + return NextResponse.json( + status + ); + } catch ( + error: unknown + ) { + if ( + typeof error === + "object" && + error !== null && + "code" in error && + ( + error as { + code?: string; + } + ).code === "ENOENT" + ) { + return NextResponse.json({ + status: + "unknown", + lastSuccessfulSync: + null, + nextAutomaticSyncInSeconds: + null, + syncIntervalSeconds: + 300, + }); + } + + console.error( + "Sync status error:", + error + ); + + return NextResponse.json( + { + error: + "Kunde inte läsa sync-status.", + }, + { + status: 500, + } + ); + } +} diff --git a/app/vyta/src/app/api/sync/trigger/route.ts b/app/vyta/src/app/api/sync/trigger/route.ts new file mode 100644 index 0000000..df59e6f --- /dev/null +++ b/app/vyta/src/app/api/sync/trigger/route.ts @@ -0,0 +1,84 @@ +import fs from "fs/promises"; +import path from "path"; + +import { + NextResponse, +} from "next/server"; + +import { + isAuthenticated, +} from "@/lib/auth"; + +export const dynamic = + "force-dynamic"; + +const REQUEST_PATH = + process.env + .VYTA_SYNC_REQUEST_PATH || + "/data/sync-request.json"; + +export async function POST() { + const authenticated = + await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: + "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + await fs.mkdir( + path.dirname( + REQUEST_PATH + ), + { + recursive: true, + } + ); + + const requestedAt = + new Date() + .toISOString(); + + await fs.writeFile( + REQUEST_PATH, + JSON.stringify( + { + requestedAt, + }, + null, + 2 + ), + "utf-8" + ); + + return NextResponse.json({ + ok: true, + requestedAt, + }); + } catch ( + error + ) { + console.error( + "Manual sync error:", + error + ); + + return NextResponse.json( + { + error: + "Kunde inte starta synkningen.", + }, + { + status: 500, + } + ); + } +} diff --git a/app/vyta/src/app/api/vyta-data/route.ts b/app/vyta/src/app/api/vyta-data/route.ts new file mode 100644 index 0000000..9516299 --- /dev/null +++ b/app/vyta/src/app/api/vyta-data/route.ts @@ -0,0 +1,60 @@ +import { + NextResponse, +} from "next/server"; + +import { + isAuthenticated, +} from "@/lib/auth"; + +import { + getVytaData, +} from "@/lib/getVytaData"; + +export const dynamic = + "force-dynamic"; + +export async function GET() { + const authenticated = + await isAuthenticated(); + + if (!authenticated) { + return NextResponse.json( + { + error: "Unauthorized", + }, + { + status: 401, + } + ); + } + + try { + const data = + await getVytaData(); + + return NextResponse.json( + data, + { + headers: { + "Cache-Control": + "private, no-store, max-age=0", + }, + } + ); + } catch (error) { + console.error( + "Kunde inte läsa Vyta-data:", + error + ); + + return NextResponse.json( + { + error: + "Kunde inte läsa Vyta-data", + }, + { + status: 500, + } + ); + } +} \ No newline at end of file diff --git a/app/vyta/src/app/apple-icon.tsx b/app/vyta/src/app/apple-icon.tsx new file mode 100644 index 0000000..303eae2 --- /dev/null +++ b/app/vyta/src/app/apple-icon.tsx @@ -0,0 +1,52 @@ +import { ImageResponse } from "next/og"; + +export const size = { + width: 180, + height: 180, +}; + +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+
+
+ V +
+
+
+ ), + { + ...size, + } + ); +} \ No newline at end of file diff --git a/app/vyta/src/app/favicon.ico b/app/vyta/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/vyta/src/app/globals.css b/app/vyta/src/app/globals.css new file mode 100644 index 0000000..4b5d707 --- /dev/null +++ b/app/vyta/src/app/globals.css @@ -0,0 +1,180 @@ +@import "tailwindcss"; + +:root { + color-scheme: dark; + background: #080a0c; +} + +html { + min-height: 100%; + background: #080a0c; +} + +body { + min-height: 100%; + margin: 0; + + background: #080a0c; + color: #ffffff; + + font-family: Arial, Helvetica, sans-serif; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + overscroll-behavior-y: none; + + -webkit-tap-highlight-color: transparent; + + user-select: none; +} + +* { + box-sizing: border-box; +} + +button, +a { + font: inherit; +} + +button { + cursor: pointer; +} + +a { + color: inherit; + text-decoration: none; +} + +input, +textarea { + user-select: text; +} +.vyta-page { + padding-top: calc(env(safe-area-inset-top, 0px) + 2.75rem); +} + +.vyta-bottom-safe { + padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem); +} +.vyta-orb-breath { + animation: vyta-orb-breath 5.8s ease-in-out infinite; + filter: blur(22px); + transform-origin: 50% 50%; + will-change: opacity, transform; +} + +.vyta-orb-shell { + animation: vyta-orb-morph 10s ease-in-out infinite; + transform-origin: 50% 50%; + will-change: transform, filter; +} + +.vyta-orb-particles { + animation: vyta-orb-drift 34s linear infinite; + transform-origin: 50% 50%; + will-change: transform; +} + +.vyta-orb-particle { + animation-name: vyta-orb-twinkle; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + box-shadow: 0 0 10px currentColor; + will-change: opacity, filter; +} + +@keyframes vyta-orb-breath { + 0%, + 100% { + opacity: 0.28; + transform: scale(0.96); + } + + 48% { + opacity: 0.66; + transform: scale(1.045); + } +} + +@keyframes vyta-orb-morph { + 0%, + 100% { + border-radius: 44% 56% 49% 51% / 50% 44% 56% 50%; + filter: brightness(0.96) saturate(0.98); + transform: rotate(0deg) scale(0.992); + } + + 28% { + border-radius: 50% 50% 44% 56% / 47% 53% 48% 52%; + filter: brightness(1.08) saturate(1.08); + transform: rotate(1.2deg) scale(1.028); + } + + 58% { + border-radius: 52% 48% 43% 57% / 46% 54% 46% 54%; + filter: brightness(1.03) saturate(1.04); + transform: rotate(2deg) scale(1.01); + } + + 78% { + border-radius: 47% 53% 56% 44% / 56% 45% 55% 44%; + filter: brightness(0.94) saturate(0.96); + transform: rotate(-1.6deg) scale(0.985); + } +} + +@keyframes vyta-orb-drift { + from { + transform: rotate(0deg) scale(1); + } + + to { + transform: rotate(360deg) scale(1); + } +} + +@keyframes vyta-orb-twinkle { + 0%, + 100% { + opacity: 0.25; + filter: blur(0px); + } + + 45% { + opacity: 0.95; + filter: blur(0.2px); + } + + 70% { + opacity: 0.55; + filter: blur(0px); + } +} + +.vyta-orb-rim { + border: 1px solid rgba(209, 250, 229, 0.18); + box-shadow: + 0 0 0 1px rgba(16, 185, 129, 0.08), + 0 0 28px rgba(16, 185, 129, 0.16), + inset 0 0 18px rgba(255, 255, 255, 0.08), + inset 0 0 72px rgba(0, 0, 0, 0.55); + animation: vyta-orb-rim 5.8s ease-in-out infinite; + transform-origin: 50% 50%; +} + +@keyframes vyta-orb-rim { + 0%, + 100% { + opacity: 0.58; + filter: brightness(0.92); + transform: rotate(0deg) scale(0.985); + } + + 48% { + opacity: 1; + filter: brightness(1.2); + transform: rotate(1.5deg) scale(1.035); + } +} diff --git a/app/vyta/src/app/halsa/page.tsx b/app/vyta/src/app/halsa/page.tsx new file mode 100644 index 0000000..72e02b2 --- /dev/null +++ b/app/vyta/src/app/halsa/page.tsx @@ -0,0 +1,1576 @@ +"use client"; + +import Link from "next/link"; + +import { Suspense, useEffect, useMemo, useState } from "react"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import BottomNav from "@/components/BottomNav"; +import DateNavigator from "@/components/DateNavigator"; + +import type { VytaApiData } from "@/types/vytaApi"; + +import { + calculateBioAge, + calculatePaceOfAging, + simulateBioAge, + type HealthspanInput, + type HealthspanContributor, +} from "@/engine/bioAge"; + +type Settings = { + heightCm: number | null; + + weightKg: number | null; + + calorieTarget: number | null; + + weightTargetKg: number | null; + + stepGoal: number | null; + + intensityMinutesGoal: number | null; + + highIntensityMinutesGoal: number | null; + + sleepGoalMinutes: number | null; +}; + +type Recommendation = { + id: string; + + title: string; + + current: string; + + target: string; + + description: string; + + improvement: number; + + changes: Partial; +}; + +const BIRTH_DATE = "1990-09-30"; + +const HEALTHSPAN_DAYS = 180; + +const PACE_DAYS = 30; + +export default function HealthPage() { + return ( + + Hämtar hälsodata… + + } + > + + + ); +} + +function HealthPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [data, setData] = useState(null); + + const [settings, setSettings] = useState(null); + + const [error, setError] = useState(null); + + const [selectedHistoryEndIndex, setSelectedHistoryEndIndex] = useState(null); + + useEffect(() => { + Promise.all([ + fetch("/api/vyta-data", { + cache: "no-store", + }).then(async (response) => { + if (!response.ok) { + throw new Error("Kunde inte läsa Garmin-data."); + } + + return response.json(); + }), + + fetch("/api/settings", { + cache: "no-store", + }).then(async (response) => { + if (!response.ok) { + throw new Error("Kunde inte läsa Vyta-inställningar."); + } + + return response.json(); + }), + ]) + .then(([vytaData, vytaSettings]) => { + setData(vytaData); + + setSettings(vytaSettings); + + setError(null); + }) + .catch((currentError) => { + console.error(currentError); + + setError( + currentError instanceof Error ? currentError.message : "Kunde inte läsa hälsodata." + ); + }); + }, []); + + const requestedDate = searchParams.get("date"); + + const dates = useMemo(() => { + if (!data) { + return []; + } + + return data.history.map((day) => day.date).sort(); + }, [data]); + + const latestDate = data?.meta.latestDataDate ?? dates[dates.length - 1] ?? ""; + + useEffect(() => { + if (!data) { + return; + } + + if (requestedDate) { + const requestedIndex = data.history.findIndex((day) => day.date === requestedDate); + + if (requestedIndex >= 0) { + setSelectedHistoryEndIndex(requestedIndex); + return; + } + + router.replace("/halsa"); + return; + } + + setSelectedHistoryEndIndex(Math.max(0, data.history.length - 1)); + }, [data, requestedDate, router]); + + const selectedEndIndex = useMemo(() => { + if (!data || data.history.length === 0) { + return -1; + } + + return clamp(selectedHistoryEndIndex ?? data.history.length - 1, 0, data.history.length - 1); + }, [data, selectedHistoryEndIndex]); + + const healthspanHistory = useMemo(() => { + if (!data || selectedEndIndex < 0) { + return []; + } + + return data.history.slice( + Math.max(0, selectedEndIndex - HEALTHSPAN_DAYS + 1), + selectedEndIndex + 1 + ); + }, [data, selectedEndIndex]); + + const paceHistory = useMemo(() => { + if (!data || selectedEndIndex < 0) { + return []; + } + + return data.history.slice(Math.max(0, selectedEndIndex - PACE_DAYS + 1), selectedEndIndex + 1); + }, [data, selectedEndIndex]); + + const selectedWeekHistory = useMemo(() => { + if (!data || selectedEndIndex < 0) { + return []; + } + + return data.history.slice(Math.max(0, selectedEndIndex - 6), selectedEndIndex + 1); + }, [data, selectedEndIndex]); + + if (error) { + return ( +
+
+

VYTA

+ +

Kunde inte läsa hälsodata

+ +

{error}

+
+
+ ); + } + + if (!data || !settings) { + return ( +
+ Hämtar hälsodata… +
+ ); + } + + const chronologicalAge = calculateChronologicalAge(BIRTH_DATE); + + /* + * Garmin VO₂max: + * use the actual latest Garmin value. + */ + const vo2max = findLatestVo2max(data); + + const longTermInput = buildHealthspanInput({ + history: healthspanHistory, + + chronologicalAge, + activities: data.activities, + + vo2max, + hrvBaseline: data.baselines.hrv, + }); + + const recentInput = buildHealthspanInput({ + history: paceHistory, + + chronologicalAge, + activities: data.activities, + + vo2max, + hrvBaseline: data.baselines.hrv, + }); + + const healthspan = calculateBioAge(longTermInput); + + const recentHealthspan = calculateBioAge(recentInput); + + const pace = calculatePaceOfAging(healthspan, recentHealthspan); + + const healthspanPeriodLabel = formatPeriodLabel(selectedWeekHistory); + + const displayedPaceValue = getDisplayedPaceValue(pace.value, healthspan.delta); + + const selectedDate = + selectedEndIndex >= 0 ? (data.history[selectedEndIndex]?.date ?? latestDate) : latestDate; + + const currentIndex = dates.indexOf(selectedDate); + + const previousDate = currentIndex > 0 ? dates[currentIndex - 1] : null; + + const nextDate = + currentIndex >= 0 && currentIndex < dates.length - 1 ? dates[currentIndex + 1] : null; + + const garminFitnessAge = findLatestFitnessAge(data); + + const recommendations = buildRecommendations(longTermInput, settings); + + const combinedChanges = recommendations.reduce>( + (current, recommendation) => ({ + ...current, + + ...recommendation.changes, + }), + {} + ); + + const potential = + recommendations.length > 0 ? simulateBioAge(longTermInput, combinedChanges) : healthspan; + + const potentialImprovement = Math.max(0, healthspan.age - potential.age); + + const fitnessContributors = healthspan.contributors.filter((contributor) => + [ + "vo2max", + "bodyComposition", + "steps", + "lowModerateIntensity", + "highIntensity", + "strengthTraining", + ].includes(contributor.key) + ); + + const recoveryContributors = healthspan.contributors.filter( + (contributor) => !fitnessContributors.some((fitness) => fitness.key === contributor.key) + ); + + return ( +
+
+
+

VYTA

+ +
+
+ + GARMIN +
+ + + + +
+
+ +
+ +
+ +
+
+
+
+ +
+
+ + + +
+
+
+

+ PACE OF AGING +

+
+ + Slow +
+
+ +

+ {`${formatPace(displayedPaceValue)}x`} +

+ +
+

Fast

+

◇

+
+
+ + +
+ +
+ + + +
+
+ + {healthspan.dataDays < HEALTHSPAN_DAYS && ( +
+
+
+
+

+ Vyta Age bygger fortfarande historik +

+

+ Du har {healthspan.dataDays} av 180 dagar. Resultatet hålls medvetet närmare + din kronologiska ålder tills mer Garmin-data finns. +

+
+
+
+ )} + + + + +
+ + +
+
+ + +
+ ); +} + +/* + * ========================================================= + * BUILD HEALTHSPAN INPUT + * ========================================================= + */ + +function buildHealthspanInput({ + history, + chronologicalAge, + activities, + vo2max, + hrvBaseline, +}: { + history: VytaApiData["history"]; + + chronologicalAge: number; + + activities: VytaApiData["activities"]; + + vo2max: number | null; + + hrvBaseline: number | null; +}): HealthspanInput { + const averageRhr = recencyWeightedAverage(history.map((day) => day.heart.restingHeartRate)); + const averageHrv = recencyWeightedAverage(history.map((day) => day.heart.hrv)); + const averageSteps = recencyWeightedAverage(history.map((day) => day.activity.steps)); + + const sleepDurations = history + .map((day) => day.sleep.durationMinutes) + .filter( + (value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0 + ); + + const averageSleepMinutes = recencyWeightedAverage(sleepDurations); + + const sleepConsistency = calculateSleepConsistency(history, sleepDurations); + + const activityMetrics = calculateActivityMetrics(history, activities); + + return { + chronologicalAge, + + vo2max, + + restingHeartRate: averageRhr, + + hrv: averageHrv, + + hrvBaseline, + + averageSteps, + + averageSleepMinutes, + + sleepConsistency, + + lowModerateIntensityMinutesPerWeek: activityMetrics.lowModerateMinutesPerWeek, + + highIntensityMinutesPerWeek: activityMetrics.highIntensityMinutesPerWeek, + + strengthMinutesPerWeek: activityMetrics.strengthMinutesPerWeek, + + leanBodyMassPercent: null, + + dataDays: history.length, + }; +} + +/* + * ========================================================= + * SLEEP CONSISTENCY PROXY + * ========================================================= + */ + +function calculateSleepConsistency(history: VytaApiData["history"], durations: number[]) { + const sleepStartMinutes = history + .map((day) => getSleepStartMinute(day.sleep.sleepStartTime)) + .filter((value): value is number => value !== null); + + if (sleepStartMinutes.length >= 5) { + return calculateRegularityScore(sleepStartMinutes, 150); + } + + return calculateRegularityScore(durations, 150); +} + +/* + * ========================================================= + * RECOMMENDATIONS + * ========================================================= + */ + +function buildRecommendations( + input: HealthspanInput, + + settings: Settings +): Recommendation[] { + const items: Recommendation[] = []; + + if (isFiniteNumber(input.vo2max)) { + addRecommendation( + items, + input, + { + vo2max: input.vo2max + 2, + }, + { + id: "vo2max", + + title: "Höj VO₂max", + + current: `${formatOne(input.vo2max)}`, + + target: `${formatOne(input.vo2max + 2)}+`, + + description: "VO₂max är en av de starkaste långsiktiga fitness-signalerna i Vyta Age.", + } + ); + } + + if (isFiniteNumber(input.averageSteps) && input.averageSteps < 9000) { + const vytaTarget = Math.max(9000, roundTo(input.averageSteps + 1500, 500)); + + const target = Math.max(settings.stepGoal ?? 0, vytaTarget); + + addRecommendation( + items, + input, + { + averageSteps: target, + }, + { + id: "steps", + + title: "Öka vardagsrörelsen", + + current: `${Math.round(input.averageSteps).toLocaleString("sv-SE")} steg`, + + target: `${target.toLocaleString("sv-SE")}+`, + + description: + settings.stepGoal !== null + ? `Ditt mål är ${Math.round(settings.stepGoal).toLocaleString( + "sv-SE" + )} steg/dag. VYTA rekommenderar ${vytaTarget.toLocaleString( + "sv-SE" + )}+ baserat på Garmin-trenden.` + : `VYTA rekommenderar ${vytaTarget.toLocaleString( + "sv-SE" + )}+ steg/dag baserat på Garmin-trenden.`, + } + ); + } + + if (isFiniteNumber(input.averageSleepMinutes) && input.averageSleepMinutes < 7.5 * 60) { + addRecommendation( + items, + input, + { + averageSleepMinutes: Math.max(settings.sleepGoalMinutes ?? 0, 7.5 * 60), + }, + { + id: "sleep", + + title: "Öka sömnlängden", + + current: formatDuration(input.averageSleepMinutes), + + target: formatDuration(Math.max(settings.sleepGoalMinutes ?? 0, 7.5 * 60)), + + description: + settings.sleepGoalMinutes !== null + ? `Ditt sömnmål är ${formatDuration( + settings.sleepGoalMinutes + )}. VYTA jämför det med din långsiktiga Garmin-sömn.` + : "VYTA rekommenderar minst cirka 7 h 30 min utifrån din långsiktiga Garmin-sömn.", + } + ); + } + + if (isFiniteNumber(input.sleepConsistency) && input.sleepConsistency < 80) { + addRecommendation( + items, + input, + { + sleepConsistency: 85, + }, + { + id: "sleep-consistency", + + title: "Gör sömnen jämnare", + + current: `${Math.round(input.sleepConsistency)}%`, + + target: "85%+", + + description: "Mer konsekvent sömn från dag till dag förbättrar den långsiktiga profilen.", + } + ); + } + + if ( + isFiniteNumber(input.lowModerateIntensityMinutesPerWeek) && + input.lowModerateIntensityMinutesPerWeek < (settings.intensityMinutesGoal ?? 150) + ) { + const target = Math.max(settings.intensityMinutesGoal ?? 0, 150); + + addRecommendation( + items, + input, + { + lowModerateIntensityMinutesPerWeek: target, + }, + { + id: "intensity", + + title: "Öka aktivitetsminuterna", + + current: `${Math.round(input.lowModerateIntensityMinutesPerWeek)} min/vecka`, + + target: `${target}+ min`, + + description: + settings.intensityMinutesGoal !== null + ? `Ditt mål är ${settings.intensityMinutesGoal} min/vecka. VYTA använder Garmins aktivitetszoner som underlag och rekommenderar minst ${target} min/vecka.` + : `VYTA rekommenderar minst ${target} min/vecka baserat på Garmins aktivitetszoner.`, + } + ); + } + + if (isFiniteNumber(input.highIntensityMinutesPerWeek) && input.highIntensityMinutesPerWeek < 45) { + addRecommendation( + items, + input, + { + highIntensityMinutesPerWeek: Math.max(settings.highIntensityMinutesGoal ?? 0, 45), + }, + { + id: "high-intensity", + + title: "Bygg högintensiv tid", + + current: `${Math.round(input.highIntensityMinutesPerWeek)} min/vecka`, + + target: `${Math.max(settings.highIntensityMinutesGoal ?? 0, 45)}+ min`, + + description: + settings.highIntensityMinutesGoal !== null + ? `Ditt mål är ${settings.highIntensityMinutesGoal} min/vecka. Garmins aktivitetszoner används som underlag.` + : "VYTA rekommenderar minst 45 min/vecka utifrån Garmins aktivitetszoner.", + } + ); + } + + return items + .filter((item) => item.improvement > 0.05) + .sort((a, b) => b.improvement - a.improvement) + .slice(0, 5); +} + +function addRecommendation( + items: Recommendation[], + + input: HealthspanInput, + + changes: Partial, + + content: Omit +) { + const before = calculateBioAge(input); + + const after = simulateBioAge(input, changes); + + items.push({ + ...content, + + changes, + + improvement: Math.max(0, roundOne(before.age - after.age)), + }); +} + +/* + * ========================================================= + * UI COMPONENTS + * ========================================================= + */ + +function HealthspanOrb({ healthspan }: { healthspan: ReturnType }) { + const positive = healthspan.delta <= 0; + const accent = positive ? "emerald" : "amber"; + const particles = Array.from({ length: 156 }, (_, index) => { + const angle = (index * 137.508) % 360; + const radius = 54 + ((index * 31) % 34); + const size = 1 + ((index * 7) % 7) / 2; + + const glow = index % 11 === 0; + + return { angle, radius, size, glow }; + }); + + return ( +
+
+
+
+
+
+ +
+ {particles.map((particle, index) => ( + + ))} +
+ +
+ +
+

+ {formatOne(healthspan.age)} +

+

VYTA AGE

+

+ {getAgeDeltaLabel(healthspan.delta)} +

+
+
+ ); +} + +function PaceScale({ value }: { value: number | null }) { + const normalized = value === null ? 50 : clamp(((value - 0.5) / 1) * 50, 0, 100); + + return ( +
+
+
+ {Array.from({ length: 72 }, (_, index) => ( + + ))} +
+
+
+
+ -1.0x + 1.0x + 3.0x +
+
+ ); +} + +function MiniHealthMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function ContributorGroup({ + title, + subtitle, + contributors, + tone, +}: { + title: string; + subtitle: string; + contributors: HealthspanContributor[]; + tone: "green" | "purple"; +}) { + if (contributors.length === 0) return null; + + return ( +
+
+

{title}

+

{subtitle}

+
+ +
+ {contributors.map((contributor, index) => ( + + ))} +
+
+ ); +} + +function ContributorRow({ + contributor, + tone, + isLast, +}: { + contributor: HealthspanContributor; + tone: "green" | "purple"; + isLast: boolean; +}) { + return ( +
+ +
+

{contributor.label}

+

{contributor.note}

+
+

+ {contributor.valueLabel} +

+
+ ); +} + +function ImpactRow({ + contributor, + isLast, +}: { + contributor: HealthspanContributor; + isLast: boolean; +}) { + const tone = + contributor.ageImpact < -0.05 ? "green" : contributor.ageImpact > 0.05 ? "orange" : "neutral"; + + return ( +
+ +
+

{contributor.label}

+

{contributor.valueLabel}

+
+ +
+ ); +} + +function AgeImpact({ impact, available }: { impact: number; available: boolean }) { + if (!available) { + return ( + + ingen data + + ); + } + + const good = impact < -0.05; + const bad = impact > 0.05; + + return ( + + {good ? "−" : bad ? "+" : "±"} + {formatOne(Math.abs(impact))} år + + ); +} + +function HeroMetric({ + label, + value, + subtitle, + accent, +}: { + label: string; + value: string; + subtitle: string; + accent: "green" | "purple" | "orange"; +}) { + const accentClass = + accent === "green" + ? "text-emerald-400" + : accent === "orange" + ? "text-amber-300" + : "text-violet-400"; + + return ( +
+

{label}

+

{value}

+

{subtitle}

+
+ ); +} + +function AgeDeltaBadge({ delta }: { delta: number }) { + if (delta < -0.05) { + return ( + + {formatOne(Math.abs(delta))} år yngre + + ); + } + if (delta > 0.05) { + return ( + + +{formatOne(delta)} år + + ); + } + return ( + I nivå + ); +} + +function RecommendationCard({ recommendation }: { recommendation: Recommendation }) { + return ( +
+
+ +
+
+

{recommendation.title}

+ + −{formatOne(recommendation.improvement)} år + +
+
+ {recommendation.current} + → + {recommendation.target} +
+
+ +
+

{recommendation.description}

+
+ ); +} + +function LegendDot({ tone, label }: { tone: "green" | "orange" | "neutral"; label: string }) { + const dot = + tone === "green" ? "bg-emerald-400" : tone === "orange" ? "bg-amber-300" : "bg-zinc-600"; + return ( + + + {label} + + ); +} + +function MetricIcon({ metric, tone }: { metric: string; tone: "green" | "purple" | "orange" }) { + const toneClass = + tone === "green" + ? "bg-emerald-400/10 text-emerald-400" + : tone === "orange" + ? "bg-amber-400/10 text-amber-300" + : "bg-violet-400/10 text-violet-400"; + const key = metric.toLowerCase(); + + let path = ( + + ); + + if (key.includes("vo2") || key.includes("intensity")) { + path = ( + <> + + + ); + } else if (key.includes("bmi") || key.includes("weight")) { + path = ( + <> + + + + ); + } else if (key.includes("step")) { + path = ( + <> + + + + ); + } else if (key.includes("sleep")) { + path = ( + <> + + + ); + } else if (key.includes("stress")) { + path = ( + <> + + + + ); + } else if (key.includes("strength")) { + path = ( + <> + + + ); + } + + return ( +
+ +
+ ); +} + +function BackIcon() { + return ( + + ); +} + +function InfoIcon() { + return ( + + ); +} + +function SettingsIcon() { + return ( + + ); +} + +/* + * ========================================================= + * GARMIN HELPERS + * ========================================================= + */ + +function getDisplayedPaceValue(paceValue: number | null, ageDelta: number) { + const deltaDerivedPace = clamp(1 + ageDelta * 0.25, 0.5, 1.5); + + if (paceValue === null || Math.abs(paceValue - 1) < 0.04) { + return deltaDerivedPace; + } + + return paceValue; +} + +function findLatestFitnessAge(data: VytaApiData) { + const today = safeNumber(data.today.training?.fitnessAge); + + if (today !== null) { + return today; + } + + for (let index = data.history.length - 1; index >= 0; index--) { + const value = safeNumber(data.history[index].training?.fitnessAge); + + if (value !== null) { + return value; + } + } + + return null; +} + +function formatPeriodLabel(history: VytaApiData["history"]) { + if (history.length === 0) { + return "—"; + } + + const first = history[0]?.date; + const last = history[history.length - 1]?.date; + + if (!first || !last) { + return "—"; + } + + return `${formatShortDate(first)} - ${formatShortDate(last)}`; +} + +function formatShortDate(value: string) { + const date = new Date(`${value}T12:00:00`); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }) + .format(date) + .toUpperCase(); +} + +function getAgeDeltaLabel(delta: number) { + if (delta < -0.05) { + return `${formatOne(Math.abs(delta))} år yngre`; + } + + if (delta > 0.05) { + return `${formatOne(delta)} år äldre`; + } + + return "I nivå"; +} + +function findLatestVo2max(data: VytaApiData) { + const today = safeNumber(data.today.training?.vo2max); + + if (today !== null) { + return today; + } + + for (let index = data.history.length - 1; index >= 0; index--) { + const value = safeNumber(data.history[index].training?.vo2max); + + if (value !== null) { + return value; + } + } + + return null; +} + +function calculateWeeklyAverage(dailyValues: number[]) { + if (dailyValues.length === 0) { + return null; + } + + const total = dailyValues.reduce((sum, value) => sum + value, 0); + + return (total / dailyValues.length) * 7; +} + +function recencyWeightedAverage(values: Array) { + let weightedTotal = 0; + let weightTotal = 0; + + values.forEach((value, index) => { + if (typeof value !== "number" || !Number.isFinite(value)) { + return; + } + + const ageFromNewest = values.length - 1 - index; + const weight = Math.exp(-ageFromNewest / 18); + + weightedTotal += value * weight; + weightTotal += weight; + }); + + return weightTotal > 0 ? weightedTotal / weightTotal : null; +} + +function average(values: Array) { + const valid = values.filter( + (value): value is number => typeof value === "number" && Number.isFinite(value) + ); + + if (valid.length === 0) { + return null; + } + + return valid.reduce((total, value) => total + value, 0) / valid.length; +} + +function safeNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +/* + * ========================================================= + * FORMAT + * ========================================================= + */ + +function calculateChronologicalAge(birthDate: string) { + const birth = new Date(`${birthDate}T12:00:00`); + + const now = new Date(); + + return (now.getTime() - birth.getTime()) / (365.2425 * 24 * 60 * 60 * 1000); +} + +function getConfidenceLabel(confidence: "building" | "medium" | "high") { + switch (confidence) { + case "high": + return "Hög tillförlitlighet"; + + case "medium": + return "Bra underlag"; + + default: + return "Bygger historik"; + } +} + +function roundTo(value: number, increment: number) { + return Math.round(value / increment) * increment; +} + +function formatOne(value: number) { + return value.toFixed(1).replace(".", ","); +} + +function formatPace(value: number) { + return value.toFixed(1); +} + +function formatTwo(value: number) { + return value.toFixed(2).replace(".", ","); +} + +function formatDuration(minutes: number) { + const rounded = Math.round(minutes); + + const hours = Math.floor(rounded / 60); + + const remainder = rounded % 60; + + return `${hours} h ${remainder.toString().padStart(2, "0")} min`; +} + +function roundOne(value: number) { + return Math.round(value * 10) / 10; +} + +function clamp( + value: number, + + minimum: number, + + maximum: number +) { + return Math.max(minimum, Math.min(maximum, value)); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function calculateActivityMetrics( + history: VytaApiData["history"], + activities: VytaApiData["activities"] +) { + if (history.length === 0) { + return { + lowModerateMinutesPerWeek: null, + highIntensityMinutesPerWeek: null, + strengthMinutesPerWeek: null, + }; + } + + const dates = new Set(history.map((day) => day.date)); + const matchingActivities = activities.filter( + (activity) => activity.startTime !== null && dates.has(activity.startTime.slice(0, 10)) + ); + + if (matchingActivities.length === 0) { + return { + lowModerateMinutesPerWeek: null, + highIntensityMinutesPerWeek: null, + strengthMinutesPerWeek: null, + }; + } + + let lowModerateMinutes = 0; + let highIntensityMinutes = 0; + let strengthMinutes = 0; + let hasZoneData = false; + let hasStrengthData = false; + + for (const activity of matchingActivities) { + const zones = activity.heartRateZones; + const zone1 = safeNumber(zones.zone1Minutes) ?? 0; + const zone2 = safeNumber(zones.zone2Minutes) ?? 0; + const zone3 = safeNumber(zones.zone3Minutes) ?? 0; + const zone4 = safeNumber(zones.zone4Minutes) ?? 0; + const zone5 = safeNumber(zones.zone5Minutes) ?? 0; + + if (zone1 > 0 || zone2 > 0 || zone3 > 0 || zone4 > 0 || zone5 > 0) { + hasZoneData = true; + } + + lowModerateMinutes += zone1 + zone2 + zone3; + highIntensityMinutes += zone4 + zone5; + + if (isStrengthActivity(activity.type, activity.name)) { + const duration = safeNumber(activity.durationMinutes); + + if (duration !== null) { + strengthMinutes += duration; + hasStrengthData = true; + } + } + } + + return { + lowModerateMinutesPerWeek: hasZoneData ? (lowModerateMinutes / history.length) * 7 : null, + highIntensityMinutesPerWeek: hasZoneData ? (highIntensityMinutes / history.length) * 7 : null, + strengthMinutesPerWeek: hasStrengthData ? (strengthMinutes / history.length) * 7 : null, + }; +} + +function isStrengthActivity(type: string | null, name: string | null) { + const value = `${type ?? ""} ${name ?? ""}`.toLowerCase(); + + return [ + "strength", + "strength_training", + "functional_strength", + "functional_strength_training", + "weight_training", + "weights", + "styrka", + ].some((candidate) => value.includes(candidate)); +} + +function getSleepStartMinute(value: unknown) { + if (typeof value !== "string") { + return null; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return null; + } + + const minutes = date.getHours() * 60 + date.getMinutes(); + + return minutes < 12 * 60 ? minutes + 24 * 60 : minutes; +} + +function calculateRegularityScore(values: number[], toleranceMinutes: number) { + if (values.length < 5) { + return null; + } + + const mean = values.reduce((total, value) => total + value, 0) / values.length; + + const variance = + values.reduce((total, value) => total + Math.pow(value - mean, 2), 0) / values.length; + + const standardDeviation = Math.sqrt(variance); + + return clamp(100 - (standardDeviation / toleranceMinutes) * 100, 0, 100); +} diff --git a/app/vyta/src/app/icon.tsx b/app/vyta/src/app/icon.tsx new file mode 100644 index 0000000..a3beb3e --- /dev/null +++ b/app/vyta/src/app/icon.tsx @@ -0,0 +1,54 @@ +import { ImageResponse } from "next/og"; + +export const size = { + width: 512, + height: 512, +}; + +export const contentType = "image/png"; + +export default function Icon() { + return new ImageResponse( + ( +
+
+
+ V +
+
+
+ ), + { + ...size, + } + ); +} \ No newline at end of file diff --git a/app/vyta/src/app/installningar/page.tsx b/app/vyta/src/app/installningar/page.tsx new file mode 100644 index 0000000..5fdc647 --- /dev/null +++ b/app/vyta/src/app/installningar/page.tsx @@ -0,0 +1,278 @@ +import SyncStatus from "@/components/SyncStatus"; +import NotificationSettings from "@/components/NotificationSettings"; +import Link from "next/link"; + +import PushNotifications from "@/components/PushNotifications"; +import SettingsForm from "@/components/SettingsForm"; +import GarminConnectionSettings from "@/components/GarminConnectionSettings"; + +import { calculateVytaTdee, getTdeeConfidenceLabel } from "@/lib/energy"; + +import { getVytaData } from "@/lib/getVytaData"; + +import { getVytaSettings } from "@/lib/vytaSettings"; +import { getGarminConnectionStatus } from "@/lib/garminConnection"; + +export const dynamic = "force-dynamic"; + +export default async function SettingsPage() { + const [data, settings, garminConnection] = await Promise.all([ + getVytaData(), + getVytaSettings(), + getGarminConnectionStatus(), + ]); + + const tdee = calculateVytaTdee(data.history, data.meta.latestDataDate, 28); + + const history = data.history.slice(-28); + + const averageSteps = average(history.map((day) => day.activity.steps)); + + const averageIntensityMinutes = weeklyAverage( + history.map( + (day) => + (day.activity.moderateIntensityMinutes ?? 0) + (day.activity.vigorousIntensityMinutes ?? 0) + ) + ); + + const averageHighIntensityMinutes = weeklyAverage( + history.map((day) => day.activity.vigorousIntensityMinutes ?? 0) + ); + + const averageSleepMinutes = average(history.map((day) => day.sleep.durationMinutes)); + + /* + * VYTA-rekommendationerna är datadrivna men försiktiga. + * Garmin-data beskriver nuläget; Vyta föreslår nästa rimliga mål. + */ + const suggestedStepGoal = + averageSteps !== null ? Math.max(9000, roundTo(averageSteps + 1500, 500)) : 9000; + + const suggestedIntensityGoal = Math.max( + 150, + averageIntensityMinutes !== null ? roundTo(averageIntensityMinutes + 30, 15) : 150 + ); + + const suggestedHighIntensityGoal = Math.max( + 45, + averageHighIntensityMinutes !== null ? roundTo(averageHighIntensityMinutes + 15, 5) : 45 + ); + + const suggestedSleepGoalMinutes = + averageSleepMinutes !== null && averageSleepMinutes > 8 * 60 + ? Math.round(averageSleepMinutes) + : 7.5 * 60; + + const recentWeights = settings.weightHistory.slice(-8).reverse(); + + const weightTrend = calculateWeightTrend(settings.weightHistory); + + return ( +
+
+
+
+ + ← Idag + + +

VYTA

+
+ +

Inställningar

+ +

Kropp, energi, notiser och personliga mål.

+
+ +
+ +
+ +
+
+
+

VIKTTREND

+ +

+ {settings.weightKg !== null ? `${formatWeight(settings.weightKg)} kg` : "—"} +

+ +

Senaste registrerade vikt

+
+ + {weightTrend !== null && ( +
+

0 + ? "text-amber-300" + : "text-zinc-400" + }`} + > + {weightTrend > 0 ? "+" : ""} + {formatWeight(weightTrend)} kg +

+ +

sedan första mätningen

+
+ )} +
+ + {recentWeights.length > 0 ? ( +
+ {recentWeights.map((entry) => ( +
+ {formatDate(entry.date)} + + {formatWeight(entry.weightKg)} kg +
+ ))} +
+ ) : ( +

+ Spara din vikt för att börja bygga en vikttrend. +

+ )} +
+ + + + + + + +
+

VYTA

+ +

+ Dina personliga mål sparas separat i Vyta. Garmin-data ändras aldrig när du ändrar + inställningarna här. +

+
+ +
+

KONTO

+ +
+

Pocket ID

+ +

+ Din inloggning hanteras säkert via Pocket ID. +

+
+ +
+ +
+
+
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} +
+ ); +} + +function calculateWeightTrend( + history: Array<{ + weightKg: number; + }> +) { + if (history.length < 2) { + return null; + } + + const first = history[0]; + + const latest = history[history.length - 1]; + + return latest.weightKg - first.weightKg; +} + +function average(values: Array) { + const valid = values.filter( + (value): value is number => typeof value === "number" && Number.isFinite(value) + ); + + if (valid.length === 0) { + return null; + } + + return valid.reduce((total, value) => total + value, 0) / valid.length; +} + +function weeklyAverage(dailyValues: number[]) { + if (dailyValues.length === 0) { + return null; + } + + return (dailyValues.reduce((total, value) => total + value, 0) / dailyValues.length) * 7; +} + +function roundTo(value: number, increment: number) { + return Math.round(value / increment) * increment; +} + +function formatWeight(value: number) { + return value.toFixed(1).replace(".", ","); +} + +function formatDate(date: string) { + const parsed = new Date(`${date}T12:00:00`); + + if (Number.isNaN(parsed.getTime())) { + return date; + } + + return parsed.toLocaleDateString("sv-SE", { + day: "numeric", + month: "short", + }); +} diff --git a/app/vyta/src/app/layout.tsx b/app/vyta/src/app/layout.tsx new file mode 100644 index 0000000..b2cc450 --- /dev/null +++ b/app/vyta/src/app/layout.tsx @@ -0,0 +1,39 @@ +import type { + Metadata, + Viewport, +} from "next"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Vyta", + description: + "Din personliga hälsa, återhämtning och träning.", + applicationName: "Vyta", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + title: "Vyta", + statusBarStyle: "black", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + viewportFit: "cover", + themeColor: "#080a0c", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/app/vyta/src/app/login/page.tsx b/app/vyta/src/app/login/page.tsx new file mode 100644 index 0000000..94a78c2 --- /dev/null +++ b/app/vyta/src/app/login/page.tsx @@ -0,0 +1,52 @@ +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ + error?: string; + }>; +}) { + const params = + await searchParams; + + const hasOidcError = + params.error === + "oidc"; + + return ( +
+
+
+

+ VYTA +

+ +

+ Välkommen tillbaka +

+ +

+ Logga in säkert med Pocket ID. +

+
+ +
+ + Fortsätt med Pocket ID + + + {hasOidcError && ( +

+ Pocket ID-inloggningen misslyckades. +

+ )} +
+
+
+ ); +} diff --git a/app/vyta/src/app/manifest.ts b/app/vyta/src/app/manifest.ts new file mode 100644 index 0000000..abded08 --- /dev/null +++ b/app/vyta/src/app/manifest.ts @@ -0,0 +1,29 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Vyta", + short_name: "Vyta", + + description: + "Återhämtning, belastning, sömn och hälsa baserat på dina kroppssignaler.", + + start_url: "/", + scope: "/", + + display: "standalone", + + background_color: "#080a0c", + theme_color: "#080a0c", + + orientation: "portrait", + + icons: [ + { + src: "/icon", + sizes: "512x512", + type: "image/png", + }, + ], + }; +} \ No newline at end of file diff --git a/app/vyta/src/app/page.tsx b/app/vyta/src/app/page.tsx new file mode 100644 index 0000000..bb43a5b --- /dev/null +++ b/app/vyta/src/app/page.tsx @@ -0,0 +1,870 @@ +import Link from "next/link"; + +import BottomNav from "@/components/BottomNav"; +import DateNavigator from "@/components/DateNavigator"; +import VytaInsightCard from "@/components/VytaInsightCard"; +import WeeklySummaryCard from "@/components/WeeklySummaryCard"; + +import { getVytaData } from "@/lib/getVytaData"; + +import { getVytaSettings } from "@/lib/vytaSettings"; + +import { formatDecimal } from "@/lib/format"; + +import { formatStrain, getStrainSuffix } from "@/lib/strainScale"; +import { calculateVytaIntelligence } from "@/lib/vytaIntelligence"; + +type PageProps = { + searchParams: Promise<{ + date?: string; + }>; +}; + +export default async function Home({ searchParams }: PageProps) { + const [data, settings] = await Promise.all([getVytaData(), getVytaSettings()]); + + const params = await searchParams; + + const dates = data.history.map((day) => day.date).sort(); + + const latestDate = data.meta.latestDataDate; + + const requestedDate = params.date; + + const selectedDate = requestedDate && dates.includes(requestedDate) ? requestedDate : latestDate; + + const currentIndex = dates.indexOf(selectedDate); + + const previousDate = currentIndex > 0 ? dates[currentIndex - 1] : null; + + const nextDate = + currentIndex >= 0 && currentIndex < dates.length - 1 ? dates[currentIndex + 1] : null; + + const isLatest = selectedDate === latestDate; + + const historicalDay = data.history.find((day) => day.date === selectedDate); + + if (!historicalDay) { + return ( +
+ Kunde inte hitta data för dagen. +
+ ); + } + + /* + * ======================================================= + * RECOVERY / BELASTNING + * ======================================================= + */ + + const recovery = isLatest ? data.today.recovery : historicalDay.recovery; + + const strain = isLatest ? data.today.strain : historicalDay.strain; + + /* + * strain ligger alltid internt på 0–21. + * strainScale påverkar endast presentationen. + */ + const strainScale = settings.strainScale; + + /* + * ======================================================= + * HEART + * ======================================================= + */ + + const hrv = isLatest ? data.today.heart.hrv : historicalDay.heart.hrv; + + const restingHeartRate = isLatest + ? data.today.heart.restingHeartRate + : historicalDay.heart.restingHeartRate; + + /* + * ======================================================= + * SÖMN + * ======================================================= + */ + + const sleepScore = isLatest ? data.today.sleep.score : historicalDay.sleep.score; + + const sleepDuration = isLatest + ? data.today.sleep.durationMinutes + : (historicalDay.sleep.durationMinutes ?? null); + + const sleepAwake = isLatest + ? data.today.sleep.awakeMinutes + : (historicalDay.sleep.awakeMinutes ?? null); + + const sleepNeed = isLatest + ? data.today.sleep.needMinutes + : (historicalDay.sleep.needMinutes ?? null); + + const sleepDebt = isLatest + ? data.today.sleep.debtMinutes + : (historicalDay.sleep.debtMinutes ?? null); + + const deepSleep = isLatest + ? data.today.sleep.deepMinutes + : (historicalDay.sleep.deepMinutes ?? null); + + const remSleep = isLatest + ? data.today.sleep.remMinutes + : (historicalDay.sleep.remMinutes ?? null); + + /* + * ======================================================= + * ENERGY + * ======================================================= + */ + + const stress = isLatest ? data.today.energy.stress : historicalDay.energy.stress; + + const bodyBattery = isLatest + ? data.today.energy.bodyBattery + : (historicalDay.energy.bodyBattery ?? null); + + /* + * ======================================================= + * HEALTH + * ======================================================= + */ + + const spo2 = isLatest + ? data.today.health.spo2Average + : (historicalDay.health?.spo2Average ?? null); + + const respiration = isLatest + ? data.today.health.respirationSleep + : (historicalDay.health?.respirationSleep ?? null); + + /* + * ======================================================= + * AKTIVITET + * ======================================================= + */ + + const activity = isLatest ? data.today.activity : historicalDay.activity; + + const steps = activity?.steps ?? null; + + const distanceMeters = activity?.distanceMeters ?? null; + + const totalCalories = activity?.totalCalories ?? null; + + const activeCalories = activity?.activeCalories ?? null; + + const moderateIntensityMinutes = activity?.moderateIntensityMinutes ?? 0; + + const vigorousIntensityMinutes = activity?.vigorousIntensityMinutes ?? 0; + + /* + * Garmin-princip: + * vigorous räknas dubbelt. + */ + const intensityMinutes = moderateIntensityMinutes + vigorousIntensityMinutes * 2; + + const distanceKm = distanceMeters !== null ? distanceMeters / 1000 : null; + + /* + * Vyta-mål tills vi eventuellt + * hämtar Garmins egna stegsmål. + */ + const stepGoal = 10000; + + const stepProgress = steps !== null ? Math.min(100, Math.round((steps / stepGoal) * 100)) : 0; + + /* + * ======================================================= + * DERIVED + * ======================================================= + */ + + const recoveryStatus = getRecoveryStatus(recovery); + + const strainStatus = getStrainStatus(strain); + + const sleepEfficiency = + sleepDuration !== null && sleepDuration > 0 && sleepAwake !== null + ? Math.round((sleepDuration / (sleepDuration + sleepAwake)) * 100) + : null; + + const intelligence = calculateVytaIntelligence({ + recovery, + strain, + + hrv, + hrvBaseline: data.baselines.hrv, + + restingHeartRate, + restingHeartRateBaseline: data.baselines.restingHeartRate, + + sleepScore, + sleepBaseline: data.baselines.sleepScore, + + stress, + stressBaseline: data.baselines.stress, + }); + + const recentWeek = data.history.filter((day) => day.date <= selectedDate).slice(-7); + + const weeklyRecovery = average(recentWeek.map((day) => day.recovery)); + + const weeklySleep = average(recentWeek.map((day) => day.sleep.score)); + + const weeklyHrv = average(recentWeek.map((day) => day.heart.hrv)); + + const weeklyStrain = average(recentWeek.map((day) => day.strain)); + + const weekStart = recentWeek[0]?.date ?? selectedDate; + + const weeklyActivities = data.activities.filter((activity) => { + if (!activity.startTime) { + return false; + } + + const date = activity.startTime.slice(0, 10); + + return date >= weekStart && date <= selectedDate; + }).length; + + const weeklyInsight = buildWeeklyInsight({ + recovery: weeklyRecovery, + sleep: weeklySleep, + hrv: weeklyHrv, + strain: weeklyStrain, + activities: weeklyActivities, + }); + + const insightContributors = [ + { + label: "HRV", + value: intelligence.contributors.hrv.label, + score: intelligence.contributors.hrv.score, + }, + { + label: "Vilopuls", + value: intelligence.contributors.restingHeartRate.label, + score: intelligence.contributors.restingHeartRate.score, + }, + { + label: "Sömn", + value: intelligence.contributors.sleep.label, + score: intelligence.contributors.sleep.score, + }, + { + label: "Stress", + value: intelligence.contributors.stress.label, + score: intelligence.contributors.stress.score, + }, + ]; + + return ( +
+ {/* + * Extra top-padding endast på Idag + * för att undvika Dynamic Island. + */} +
+ {/* + * ================================================= + * HEADER + * ================================================= + */} + +
+
+

VYTA

+ +
+
+ + + GARMIN +
+ + + + +
+
+ +

Översikt

+ +

Din kropp och återhämtning.

+
+ + {/* + * ================================================= + * DATE NAV + * ================================================= + */} + + + + {/* + * ================================================= + * RECOVERY + * ================================================= + */} + +
+
+
+

ÅTERHÄMTNING

+ +
+ + {recovery ?? "—"} + + + {recovery !== null && %} +
+
+ + {recovery !== null && ( + + {recoveryStatus} + + )} +
+ +
+
+
+ +

+ Baserat på HRV, vilopuls, sömn och återhämtningssignaler. +

+
+ + {/* + * ================================================= + * STRAIN + * ================================================= + */} + +
+
+
+

BELASTNING

+ +
+ + {formatStrain(strain, strainScale)} + + + {getStrainSuffix(strainScale)} +
+
+ + + {strainStatus} + +
+ +
+
+
+
+ + {isLatest && ( + + )} + + {isLatest && ( + + )} + + {/* + * ================================================= + * AKTIVITET + * ================================================= + */} + +
+
+
+

AKTIVITET

+ +

+ {steps !== null ? steps.toLocaleString("sv-SE") : "—"} +

+ +

steg

+
+ + {steps !== null && ( +
+

{stepProgress}%

+ +

+ av {stepGoal.toLocaleString("sv-SE")} +

+
+ )} +
+ +
+
+
+ +
+ + + + + + + +
+ +
+
+ Intensitetsminuter + + + {moderateIntensityMinutes.toLocaleString("sv-SE")} måttliga ·{" "} + {vigorousIntensityMinutes.toLocaleString("sv-SE")} intensiva + +
+
+
+ + {/* + * ================================================= + * QUICK METRICS + * ================================================= + */} + +
+ + + + + + + + + + + +
+ + {/* + * ================================================= + * SÖMN + * ================================================= + */} + +
+
+

SÖMN

+ +

{sleepScore ?? "—"}

+
+ +
+ + + + + + + + + + + +
+
+ + {/* + * ================================================= + * HEALTH + * ================================================= + */} + +
+ + + +
+ + {/* + * ================================================= + * VYTA INSIGHT + * ================================================= + */} + +
+

VYTA INSIGHT

+ +

+ {buildInsight({ + recovery, + strain, + sleepScore, + hrv, + steps, + intensityMinutes, + })} +

+
+
+ + +
+ ); +} + +/* + * ========================================================= + * SETTINGS ICON + * ========================================================= + */ + +function SettingsIcon() { + return ( + + ); +} + +/* + * ========================================================= + * COMPONENTS + * ========================================================= + */ + +function MetricCard({ + label, + value, + subtitle, +}: { + label: string; + value: string; + subtitle: string; +}) { + return ( +
+

{label}

+ +

{value}

+ +

{subtitle}

+
+ ); +} + +function SmallMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function ActivityMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +/* + * ========================================================= + * STATUS + * ========================================================= + */ + +function getRecoveryStatus(recovery: number | null) { + if (recovery === null) { + return "Ingen data"; + } + + if (recovery >= 85) { + return "Utmärkt"; + } + + if (recovery >= 70) { + return "Bra"; + } + + if (recovery >= 50) { + return "Måttlig"; + } + + return "Låg"; +} + +function getStrainStatus(strain: number) { + if (strain >= 17) { + return "Mycket hög"; + } + + if (strain >= 13) { + return "Hög"; + } + + if (strain >= 8) { + return "Optimal"; + } + + if (strain >= 4) { + return "Lätt"; + } + + return "Låg"; +} + +/* + * ========================================================= + * INSIGHT + * ========================================================= + */ + +function buildInsight({ + recovery, + strain, + sleepScore, + hrv, + steps, + intensityMinutes, +}: { + recovery: number | null; + + strain: number; + + sleepScore: number | null; + + hrv: number | null; + + steps: number | null; + + intensityMinutes: number; +}) { + if (recovery !== null && recovery >= 80 && strain < 8) { + return "Du verkar väl återhämtad och dagens belastning är fortfarande låg. Det finns gott om utrymme för träning om kroppen känns bra."; + } + + if (recovery !== null && recovery < 50) { + return "Återhämtningen är lägre än önskat. En lugnare dag och fokus på sömn och återhämtning kan vara klokt."; + } + + if (strain >= 14) { + return "Dagens belastning är redan hög. Mer intensiv träning ger sannolikt begränsad extra nytta idag."; + } + + if (sleepScore !== null && sleepScore < 70) { + return "Sömnen är dagens svagaste signal. Prioritera återhämtning och försök få en starkare natt innan nästa hårda pass."; + } + + if (steps !== null && steps < 5000 && intensityMinutes === 0) { + return "Aktiviteten är hittills låg idag. En promenad eller annan lätt rörelse kan vara ett enkelt sätt att öka dagens belastning utan ett hårt träningspass."; + } + + if (steps !== null && steps >= 10000) { + return "Du har nått dagens stegsmål. Kombinera den goda vardagsaktiviteten med lagom träningsbelastning och återhämtning."; + } + + if (hrv !== null && recovery !== null && recovery >= 70) { + return "Dina återhämtningssignaler ser stabila ut. Fortsätt balansera aktivitet och återhämtning."; + } + + return "Fortsätt bygga kontinuitet. Vyta blir mer träffsäkert när fler dagar med Garmin-data samlas in."; +} + +/* + * ========================================================= + * FORMAT + * ========================================================= + */ + +function formatDuration(minutes: number) { + const hours = Math.floor(minutes / 60); + + const remainder = Math.round(minutes % 60); + + if (hours === 0) { + return `${remainder} min`; + } + + return `${hours} h ${remainder} min`; +} + +function average(values: Array) { + const valid = values.filter( + (value): value is number => typeof value === "number" && Number.isFinite(value) + ); + + if (valid.length === 0) { + return null; + } + + return valid.reduce((sum, value) => sum + value, 0) / valid.length; +} + +function buildWeeklyInsight({ + recovery, + sleep, + hrv, + strain, + activities, +}: { + recovery: number | null; + sleep: number | null; + hrv: number | null; + strain: number | null; + activities: number; +}) { + if (recovery !== null && recovery >= 70 && sleep !== null && sleep >= 80) { + return "Återhämtning och sömn har varit starka under veckan. Du verkar tåla belastningen bra och har goda förutsättningar att fortsätta bygga."; + } + + if (sleep !== null && sleep < 70) { + return "Sömnen är veckans tydligaste förbättringsområde. Prioritera regelbunden läggtid och mer sömn innan du ökar träningsbelastningen."; + } + + if (recovery !== null && recovery < 50) { + return "Återhämtningen har varit låg under flera dagar. En lugnare period kan ge bättre effekt än att fortsätta öka belastningen."; + } + + if (strain !== null && strain < 6 && activities <= 2) { + return "Veckans belastning har varit relativt låg. Om kroppen känns bra finns utrymme för mer aktivitet kommande dagar."; + } + + if (hrv !== null && activities >= 4) { + return "Du har fått in bra kontinuitet i träningen. Följ HRV, sömn och vilopuls för att säkerställa att återhämtningen håller jämna steg."; + } + + return "Veckan ser överlag balanserad ut. Fortsätt följa sömn, återhämtning och belastning tillsammans snarare än som enskilda värden."; +} diff --git a/app/vyta/src/app/somn/page.tsx b/app/vyta/src/app/somn/page.tsx new file mode 100644 index 0000000..5340b17 --- /dev/null +++ b/app/vyta/src/app/somn/page.tsx @@ -0,0 +1,339 @@ +import Link from "next/link"; +import BottomNav from "@/components/BottomNav"; +import DateNavigator from "@/components/DateNavigator"; +import SleepPlanner from "@/components/SleepPlanner"; +import TrendChart from "@/components/TrendChart"; + +import { getVytaData } from "@/lib/getVytaData"; +import { formatMinutes } from "@/lib/format"; + +type PageProps = { + searchParams: Promise<{ + date?: string; + }>; +}; + +export default async function SleepPage({ searchParams }: PageProps) { + const data = await getVytaData(); + const params = await searchParams; + + const dates = data.history.map((day) => day.date).sort(); + + const latestDate = data.meta.latestDataDate; + + const selectedDate = params.date && dates.includes(params.date) ? params.date : latestDate; + + const currentIndex = dates.indexOf(selectedDate); + + const previousDate = currentIndex > 0 ? dates[currentIndex - 1] : null; + + const nextDate = + currentIndex >= 0 && currentIndex < dates.length - 1 ? dates[currentIndex + 1] : null; + + const isLatest = selectedDate === latestDate; + + const historicalDay = data.history.find((day) => day.date === selectedDate); + + if (!historicalDay) { + return ( +
+ Kunde inte hitta sömndata för dagen. +
+ ); + } + + const day = isLatest ? data.today : historicalDay; + + const sleep = day.sleep; + + const sleepHistory = data.history + .filter((historyDay) => historyDay.date <= selectedDate && historyDay.sleep.score !== null) + .slice(-30); + + const sleepScores = sleepHistory.map((historyDay) => historyDay.sleep.score ?? 0); + + const labels = sleepHistory.map((historyDay) => formatShortDate(historyDay.date)); + + const awakeMinutes = sleep.awakeMinutes ?? null; + + const durationMinutes = sleep.durationMinutes ?? null; + + const sleepEfficiency = + durationMinutes !== null && durationMinutes > 0 && awakeMinutes !== null + ? Math.round((durationMinutes / (durationMinutes + awakeMinutes)) * 100) + : null; + + const sleepNeed = sleep.needMinutes ?? 0; + + const actualSleep = durationMinutes ?? 0; + + const sleepPerformance = + sleepNeed > 0 ? Math.min(100, Math.round((actualSleep / sleepNeed) * 100)) : (sleep.score ?? 0); + + return ( +
+
+
+
+

VYTA

+ +
+
+ + + GARMIN +
+ + + + +
+
+ +

Sömn

+ +

Din sömn och återhämtning från Garmin.

+
+ + + +
+
+
+

+ SÖMNPRESTATION +

+ +
+ + {sleepPerformance} + + + % +
+
+ +
+

Garmin score

+ +

{sleep.score ?? "—"}

+
+
+ +
+
+
+ +
+ + + + + + + +
+
+ + {isLatest && ( + + )} + +
+

SÖMNSTADIER

+ +
+ + + + + + + +
+
+ + {sleepScores.length > 1 && ( +
+

SÖMNTREND

+ +

+ {sleep.score ?? "—"} + + poäng +

+ +
+ +
+
+ )} + +
+ + + + + + + +
+ +
+
+ Natt + + {formatLongDate(selectedDate)} +
+
+
+ + +
+ ); +} + +function SleepStage({ + label, + value, + total, +}: { + label: string; + value: number | null; + total: number; +}) { + const minutes = value ?? 0; + + const percentage = total > 0 ? Math.min(100, Math.round((minutes / total) * 100)) : 0; + + return ( +
+
+ {label} + +
+ {value !== null ? formatMinutes(value) : "—"} + + {value !== null && {percentage}%} +
+
+ +
+
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function MetricCard({ + label, + value, + subtitle, +}: { + label: string; + value: string; + subtitle: string; +}) { + return ( +
+

{label}

+ +

{value}

+ +

{subtitle}

+
+ ); +} + +function formatShortDate(dateString: string) { + return new Intl.DateTimeFormat("sv-SE", { + day: "numeric", + month: "short", + }) + .format(new Date(`${dateString}T12:00:00`)) + .replace(".", ""); +} + +function formatLongDate(dateString: string) { + return new Intl.DateTimeFormat("sv-SE", { + weekday: "long", + day: "numeric", + month: "long", + }).format(new Date(`${dateString}T12:00:00`)); +} + +function SettingsIcon() { + return ( + + ); +} diff --git a/app/vyta/src/app/traning/[id]/page.tsx b/app/vyta/src/app/traning/[id]/page.tsx new file mode 100644 index 0000000..9d344f5 --- /dev/null +++ b/app/vyta/src/app/traning/[id]/page.tsx @@ -0,0 +1,303 @@ +import Link from "next/link"; + +import ActivityRouteMap from "@/components/ActivityRouteMap"; +import BottomNav from "@/components/BottomNav"; +import { getVytaData } from "@/lib/getVytaData"; +import { formatDecimal, formatMinutes } from "@/lib/format"; +import { getVytaSettings } from "@/lib/vytaSettings"; + +import { formatStrain, getStrainSuffix } from "@/lib/strainScale"; + +export default async function ActivityPage({ + params, +}: { + params: Promise<{ + id: string; + }>; +}) { + const { id } = await params; + + const data = await getVytaData(); + const settings = await getVytaSettings(); + + const strainScale = settings.strainScale; + + const activity = data.activities.find((item) => item.id === id); + + if (!activity) { + return ( +
+
+ + ← Tillbaka + + +

Aktiviteten hittades inte

+
+ + +
+ ); + } + + return ( +
+
+ + ← Träning + + +
+

GARMIN-AKTIVITET

+ +

+ {activity.name ?? activity.type ?? "Aktivitet"} +

+ +

{formatActivityDate(activity.startTime)}

+
+ + {activity.details?.route && activity.details.route.length > 1 && ( + + )} + +
+

VYTA STRAIN

+ +
+ + {formatStrain(activity.strain, strainScale)} + + + {getStrainSuffix(strainScale)} +
+ +
+
+
+
+ +
+ + + + + + + + + + + +
+ +
+

TRAINING EFFECT

+ +
+ + + +
+ + {activity.trainingEffectLabel && ( +

+ {formatTrainingEffect(activity.trainingEffectLabel)} +

+ )} +
+ + {activity.heartRateZones && ( +
+

PULSZONER

+ +
+ + + + + + + + + +
+
+ )} + + {activity.bodyBatteryImpact !== null && ( +
+

KROPPSPÅVERKAN

+ +
+
+

{activity.bodyBatteryImpact}

+ +

Body Battery

+
+
+
+ )} +
+ + +
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function MetricBlock({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function ZoneRow({ + label, + value, + total, +}: { + label: string; + value: number | null; + total: number | null; +}) { + const minutes = value ?? 0; + + const percentage = total && total > 0 ? Math.min(100, Math.round((minutes / total) * 100)) : 0; + + return ( +
+
+ {label} + + + {value !== null ? formatMinutes(Math.round(value)) : "—"} + +
+ +
+
+
+
+ ); +} + +function formatActivityDate(startTime: string | null) { + if (!startTime) { + return "Okänt datum"; + } + + const datePart = startTime.slice(0, 10); + + const time = startTime.match(/(\d{2}:\d{2})/)?.[1]; + + const formatted = new Intl.DateTimeFormat("sv-SE", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }).format(new Date(`${datePart}T12:00:00`)); + + return time ? `${formatted} · ${time}` : formatted; +} + +function formatDistance(meters: number) { + if (meters >= 1000) { + return `${(meters / 1000).toFixed(2).replace(".", ",")} km`; + } + + return `${Math.round(meters)} m`; +} + +function formatTrainingEffect(label: string) { + const names: Record = { + RECOVERY: "Återhämtning", + AEROBIC_BASE: "Aerob bas", + TEMPO: "Tempo", + LACTATE_THRESHOLD: "Mjölksyratröskel", + ANAEROBIC_CAPACITY: "Anaerob kapacitet", + }; + + return names[label] ?? label.replaceAll("_", " "); +} diff --git a/app/vyta/src/app/traning/page.tsx b/app/vyta/src/app/traning/page.tsx new file mode 100644 index 0000000..86fa21e --- /dev/null +++ b/app/vyta/src/app/traning/page.tsx @@ -0,0 +1,528 @@ +"use client"; + +import Link from "next/link"; + +import { Suspense, useEffect, useMemo, useState } from "react"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import BottomNav from "@/components/BottomNav"; +import DateNavigator from "@/components/DateNavigator"; +import TrendChart from "@/components/TrendChart"; + +import type { VytaApiData } from "@/types/vytaApi"; + +import { formatDecimal, formatMinutes } from "@/lib/format"; + +import { + convertStrain, + formatStrain, + getStrainMax, + getStrainSuffix, + type StrainScale, +} from "@/lib/strainScale"; + +export default function TrainingPage() { + return ( + + Hämtar träningsdata… + + } + > + + + ); +} + +function TrainingPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [data, setData] = useState(null); + + const [range, setRange] = useState<7 | 30>(7); + + const [strainScale, setStrainScale] = useState("21"); + + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/settings", { + cache: "no-store", + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Kunde inte läsa inställningar (${response.status})`); + } + + return response.json(); + }) + .then((settings) => { + setStrainScale(settings.strainScale === "100" ? "100" : "21"); + }) + .catch((err) => { + console.error("Kunde inte läsa belastningsskala:", err); + }); + }, []); + + useEffect(() => { + fetch("/api/vyta-data", { + cache: "no-store", + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Kunde inte läsa Vyta-data (${response.status})`); + } + + return response.json(); + }) + .then((result) => { + setData(result); + setError(null); + }) + .catch((err) => { + console.error(err); + + setError(err instanceof Error ? err.message : "Kunde inte läsa Vyta-data"); + }); + }, []); + + const requestedDate = searchParams.get("date"); + + const dates = useMemo(() => { + if (!data) { + return []; + } + + return data.history.map((day) => day.date).sort(); + }, [data]); + + const latestDate = data?.meta.latestDataDate ?? ""; + + const selectedDate = requestedDate && dates.includes(requestedDate) ? requestedDate : latestDate; + + const currentIndex = dates.indexOf(selectedDate); + + const previousDate = currentIndex > 0 ? dates[currentIndex - 1] : null; + + const nextDate = + currentIndex >= 0 && currentIndex < dates.length - 1 ? dates[currentIndex + 1] : null; + + useEffect(() => { + if (data && requestedDate && !dates.includes(requestedDate)) { + router.replace("/traning"); + } + }, [data, requestedDate, dates, router]); + + const selectedDay = useMemo(() => { + if (!data || !selectedDate) { + return null; + } + + if (selectedDate === data.meta.latestDataDate) { + return data.today; + } + + return data.history.find((day) => day.date === selectedDate) ?? null; + }, [data, selectedDate]); + + const selectedHistory = useMemo(() => { + if (!data || !selectedDate) { + return []; + } + + return data.history.filter((day) => day.date <= selectedDate).slice(-range); + }, [data, range, selectedDate]); + + const activitiesForDay = useMemo(() => { + if (!data || !selectedDate) { + return []; + } + + return data.activities + .filter((activity) => { + if (!activity.startTime) { + return false; + } + + return activity.startTime.slice(0, 10) === selectedDate; + }) + .sort((a, b) => (b.startTime ?? "").localeCompare(a.startTime ?? "")); + }, [data, selectedDate]); + + if (error) { + return ( +
+
+

VYTA

+ +

Kunde inte läsa träningsdata

+ +

{error}

+
+
+ ); + } + + if (!data || !selectedDay) { + return ( +
+ Hämtar Garmin-data… +
+ ); + } + + const strainValues = selectedHistory.map((day) => day.strain); + + const labels = selectedHistory.map((day) => formatDate(day.date)); + + const averageStrain = + strainValues.length > 0 + ? strainValues.reduce((sum, value) => sum + value, 0) / strainValues.length + : 0; + + const maxStrain = strainValues.length > 0 ? Math.max(...strainValues) : 0; + + const displayedStrainValues = strainValues.map((value) => convertStrain(value, strainScale) ?? 0); + + return ( +
+
+
+
+

VYTA

+ +
+
+ + + GARMIN +
+ + + + +
+
+ +

Träning

+ +

Belastning från dina Garmin-aktiviteter.

+
+ + + +
+
+
+

+ DAGENS BELASTNING +

+ +
+ + {formatStrain(selectedDay.strain, strainScale)} + + + {getStrainSuffix(strainScale)} +
+
+ +
+

Aktiviteter

+ +

{activitiesForDay.length}

+
+
+ +

+ Beräknad från Garmin Training Load, pulszoner och aktivitetstid. +

+
+ +
+
+
+

+ BELASTNINGSTREND +

+ +

+ {formatStrain(averageStrain, strainScale)} +

+ +

Snitt föregående {range} dagar

+
+ + +
+ + {strainValues.length > 0 && ( +
+ +
+ )} +
+ +
+ + + + + + + +
+ +
+
+

AKTIVITETER

+ + {activitiesForDay.length > 0 && ( + Klicka för detaljer + )} +
+ +
+ {activitiesForDay.length > 0 ? ( + activitiesForDay.map((activity) => ( + + )) + ) : ( +
+

Inga registrerade aktiviteter denna dag.

+ +

+ Belastning och övriga Garmin-värden visas ändå. +

+
+ )} +
+
+
+ + +
+ ); +} + +function Activity({ + activity, + strainScale, +}: { + activity: VytaApiData["activities"][number]; + strainScale: StrainScale; +}) { + const date = getActivityDate(activity.startTime); + + const time = getActivityTime(activity.startTime); + + return ( + +
+
+
+

{activity.name ?? formatActivityType(activity.type)}

+ + › +
+ +

+ {date} + + {time ? ` · ${time}` : ""} +

+ +

+ {activity.durationMinutes !== null ? formatMinutes(activity.durationMinutes) : "—"} +

+
+ +
+

+ {formatStrain(activity.strain, strainScale)} +

+ +

strain {getStrainSuffix(strainScale)}

+
+
+ +
+ + + + + +
+ + ); +} + +function Mini({ label, value }: { label: string; value: string | number }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function MetricCard({ + label, + value, + subtitle, +}: { + label: string; + value: string; + subtitle: string; +}) { + return ( +
+

{label}

+ +

{value}

+ +

{subtitle}

+
+ ); +} + +function RangeToggle({ range, onChange }: { range: 7 | 30; onChange: (value: 7 | 30) => void }) { + return ( +
+ + + +
+ ); +} + +function formatDate(dateString: string) { + return new Intl.DateTimeFormat("sv-SE", { + day: "numeric", + month: "short", + }) + .format(new Date(`${dateString}T12:00:00`)) + .replace(".", ""); +} + +function getActivityDate(startTime: string | null) { + if (!startTime) { + return "Okänt datum"; + } + + const datePart = startTime.slice(0, 10); + + return new Intl.DateTimeFormat("sv-SE", { + weekday: "short", + day: "numeric", + month: "short", + }) + .format(new Date(`${datePart}T12:00:00`)) + .replace(".", ""); +} + +function getActivityTime(startTime: string | null) { + if (!startTime) { + return null; + } + + const match = startTime.match(/(\d{2}:\d{2})/); + + return match?.[1] ?? null; +} + +function formatActivityType(type: string | null) { + if (!type) { + return "Aktivitet"; + } + + const names: Record = { + strength_training: "Styrketräning", + + treadmill_running: "Löpband", + + running: "Löpning", + + walking: "Promenad", + + cycling: "Cykling", + + hiit: "HIIT", + }; + + return names[type] ?? type.replaceAll("_", " ").replace(/^\w/, (letter) => letter.toUpperCase()); +} + +function SettingsIcon() { + return ( + + ); +} diff --git a/app/vyta/src/components/ActivityRouteMap.tsx b/app/vyta/src/components/ActivityRouteMap.tsx new file mode 100644 index 0000000..1b13c53 --- /dev/null +++ b/app/vyta/src/components/ActivityRouteMap.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useMemo } from "react"; + +import { CircleMarker, MapContainer, Polyline, TileLayer, useMap } from "react-leaflet"; + +import type { LatLngBoundsExpression, LatLngExpression } from "leaflet"; + +import "leaflet/dist/leaflet.css"; + +type RoutePoint = { + lat: number; + lon: number; + time?: number | null; + speedMps?: number | null; + altitudeMeters?: number | null; +}; + +type Props = { + route: RoutePoint[]; +}; + +export default function ActivityRouteMap({ route }: Props) { + const positions = useMemo( + () => + route + .filter((point) => Number.isFinite(point.lat) && Number.isFinite(point.lon)) + .map((point) => [point.lat, point.lon] as LatLngExpression), + [route] + ); + + if (positions.length < 2) { + return null; + } + + const first = positions[0]; + const last = positions[positions.length - 1]; + + return ( +
+
+
+

RUTT

+ + Garmin GPS +
+
+ +
+ + + + + + + + + + + +
+
+ ); +} + +function FitRoute({ positions }: { positions: LatLngExpression[] }) { + const map = useMap(); + + useEffect(() => { + map.fitBounds(positions as LatLngBoundsExpression, { + padding: [24, 24], + maxZoom: 16, + animate: false, + }); + }, [map, positions]); + + return null; +} diff --git a/app/vyta/src/components/BottomNav.tsx b/app/vyta/src/components/BottomNav.tsx new file mode 100644 index 0000000..3c3ae36 --- /dev/null +++ b/app/vyta/src/components/BottomNav.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const navItems = [ + { + href: "/", + label: "Idag", + icon: "●", + }, + { + href: "/somn", + label: "Sömn", + icon: "◒", + }, + { + href: "/traning", + label: "Träning", + icon: "▲", + }, + { + href: "/halsa", + label: "Hälsa", + icon: "♥", + }, +]; + +export default function BottomNav() { + const pathname = usePathname(); + + return ( + + ); +} \ No newline at end of file diff --git a/app/vyta/src/components/DailyInsight.tsx b/app/vyta/src/components/DailyInsight.tsx new file mode 100644 index 0000000..b11c5f7 --- /dev/null +++ b/app/vyta/src/components/DailyInsight.tsx @@ -0,0 +1,63 @@ +type DailyInsightProps = { + recovery: number; + strain: number; + targetMin: number; + targetMax: number; +}; + +export default function DailyInsight({ + recovery, + strain, + targetMin, + targetMax, +}: DailyInsightProps) { + let title = "Bra balans idag"; + let text = + "Din återhämtning är stabil och belastningen ligger på en bra nivå."; + + if (recovery >= 85 && strain < targetMin) { + title = "Du har mer att ge"; + text = + "Återhämtningen är stark och du ligger under dagens rekommenderade belastning. Det finns utrymme för mer aktivitet idag."; + } else if (recovery >= 67 && strain > targetMax) { + title = "Dagens mål är nått"; + text = + "Du har passerat dagens rekommenderade belastning. Fokusera på återhämtning resten av dagen."; + } else if (recovery < 34) { + title = "Prioritera återhämtning"; + text = + "Dina kroppssignaler visar låg återhämtning. Håll belastningen låg och prioritera sömn och återhämtning."; + } else if (recovery < 67) { + title = "Ta en balanserad dag"; + text = + "Återhämtningen är måttlig. Lätt till medelhög aktivitet passar bättre än maximal belastning idag."; + } else if ( + recovery >= 67 && + strain >= targetMin && + strain <= targetMax + ) { + title = "Du ligger helt rätt"; + text = + "Din belastning ligger inom dagens rekommenderade intervall och återhämtningen är god."; + } + + return ( +
+
+
+ +

+ VYTA INSIGHT +

+
+ +

+ {title} +

+ +

+ {text} +

+
+ ); +} \ No newline at end of file diff --git a/app/vyta/src/components/DateNavigator.tsx b/app/vyta/src/components/DateNavigator.tsx new file mode 100644 index 0000000..e180880 --- /dev/null +++ b/app/vyta/src/components/DateNavigator.tsx @@ -0,0 +1,118 @@ +import Link from "next/link"; + +type DateNavigatorProps = { + date: string; + previousDate: string | null; + nextDate: string | null; + latestDate: string; + basePath: string; +}; + +export default function DateNavigator({ + date, + previousDate, + nextDate, + latestDate, + basePath, +}: DateNavigatorProps) { + const isLatest = + date === latestDate; + + function getHref( + target: string + ) { + if (target === latestDate) { + return basePath; + } + + return `${basePath}?date=${encodeURIComponent( + target + )}`; + } + + return ( +
+
+ {previousDate ? ( + + ‹ + + ) : ( + + )} + +
+ {isLatest ? ( + <> +

+ {formatDate(date)} +

+ +

+ IDAG +

+ + ) : ( + +

+ {formatDate(date)} +

+ +

+ HISTORIK +

+ + )} +
+ + {nextDate ? ( + + › + + ) : ( + + )} +
+
+ ); +} + +function formatDate( + dateString: string +) { + return new Intl.DateTimeFormat( + "sv-SE", + { + weekday: "long", + day: "numeric", + month: "long", + } + ) + .format( + new Date( + `${dateString}T12:00:00` + ) + ) + .replace(".", ""); +} \ No newline at end of file diff --git a/app/vyta/src/components/GarminConnectionSettings.tsx b/app/vyta/src/components/GarminConnectionSettings.tsx new file mode 100755 index 0000000..95dfdde --- /dev/null +++ b/app/vyta/src/components/GarminConnectionSettings.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useState } from "react"; + +type Props = { + initialConnected: boolean; + initialEmail: string | null; + initialUpdatedAt: string | null; + latestDataDate: string; + tdeeDays: number; +}; + +export default function GarminConnectionSettings({ + initialConnected, + initialEmail, + initialUpdatedAt, + latestDataDate, + tdeeDays, +}: Props) { + const [connected, setConnected] = useState(initialConnected); + const [email, setEmail] = useState(initialEmail ?? ""); + const [savedEmail, setSavedEmail] = useState(initialEmail); + const [updatedAt, setUpdatedAt] = useState(initialUpdatedAt); + const [password, setPassword] = useState(""); + const [saving, setSaving] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [message, setMessage] = useState(null); + + async function saveConnection() { + setSaving(true); + setMessage(null); + + try { + const response = await fetch("/api/garmin/connection", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + password, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Kunde inte spara Garmin-kopplingen."); + } + + setConnected(Boolean(result.connected)); + setSavedEmail(result.email ?? email); + setUpdatedAt(result.updatedAt ?? new Date().toISOString()); + setPassword(""); + + const syncResponse = await fetch("/api/sync/trigger", { + method: "POST", + }); + + setMessage( + syncResponse.ok + ? "Garmin kopplat. Sync startar strax." + : "Garmin kopplat. Starta sync manuellt nedan." + ); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Kunde inte spara Garmin-kopplingen."); + } finally { + setSaving(false); + } + } + + async function disconnect() { + setDisconnecting(true); + setMessage(null); + + try { + const response = await fetch("/api/garmin/connection", { + method: "DELETE", + }); + + const result = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(result?.error || "Kunde inte koppla bort Garmin."); + } + + setConnected(false); + setSavedEmail(null); + setUpdatedAt(null); + setPassword(""); + setMessage("Garmin är bortkopplat."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Kunde inte koppla bort Garmin."); + } finally { + setDisconnecting(false); + } + } + + return ( +
+
+
+

GARMIN-KONTO

+ +
+ +

+ {connected ? "Kopplat" : "Inte kopplat"} +

+
+
+ + {connected && ( + + )} +
+ +
+ + + + + + + {message &&

{message}

} +
+ +
+ + + + +
+
+ ); +} + +function Field({ + label, + type, + value, + onChange, + placeholder, +}: { + label: string; + type: "email" | "password"; + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function formatDate(value: string) { + const date = new Date(`${value}T12:00:00`); + + if (Number.isNaN(date.getTime())) { + return value || "—"; + } + + return date.toLocaleDateString("sv-SE", { + day: "numeric", + month: "short", + }); +} + +function formatDateTime(value: string | null) { + if (!value) { + return "—"; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "—"; + } + + return date.toLocaleString("sv-SE", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/app/vyta/src/components/NotificationSettings.tsx b/app/vyta/src/components/NotificationSettings.tsx new file mode 100644 index 0000000..b3fe230 --- /dev/null +++ b/app/vyta/src/components/NotificationSettings.tsx @@ -0,0 +1,915 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, +} from "react"; + +type NotificationSettings = { + enabled: boolean; + + sleepSummary: boolean; + lowSleepScore: boolean; + sleepDebt: boolean; + + recovery: boolean; + hrvDeviation: boolean; + elevatedRestingHeartRate: boolean; + lowBodyBattery: boolean; + highStress: boolean; + + newActivity: boolean; + stepGoalReached: boolean; + intensityGoalReached: boolean; + vo2maxChanged: boolean; + + newRecommendation: boolean; + vytaAgeChanged: boolean; + weeklySummary: boolean; + + quietHoursEnabled: boolean; + quietHoursStart: string; + quietHoursEnd: string; +}; + +const DEFAULTS: NotificationSettings = { + enabled: true, + + sleepSummary: true, + lowSleepScore: true, + sleepDebt: false, + + recovery: true, + hrvDeviation: true, + elevatedRestingHeartRate: true, + lowBodyBattery: false, + highStress: false, + + newActivity: true, + stepGoalReached: true, + intensityGoalReached: false, + vo2maxChanged: true, + + newRecommendation: true, + vytaAgeChanged: true, + weeklySummary: true, + + quietHoursEnabled: true, + quietHoursStart: "22:00", + quietHoursEnd: "07:00", +}; + +export default function NotificationSettings() { + const [ + settings, + setSettings, + ] = useState( + DEFAULTS + ); + + const [ + loading, + setLoading, + ] = useState(true); + + const [ + saving, + setSaving, + ] = useState(false); + + const [ + error, + setError, + ] = useState( + null + ); + + const loadSettings = + useCallback(async () => { + try { + const response = + await fetch( + "/api/settings", + { + cache: + "no-store", + } + ); + + if (!response.ok) { + throw new Error( + "Kunde inte läsa notisinställningarna." + ); + } + + const result = + await response.json(); + + setSettings({ + ...DEFAULTS, + ...(result.notifications ?? {}), + }); + + setError(null); + } catch ( + currentError + ) { + console.error( + currentError + ); + + setError( + currentError instanceof + Error + ? currentError.message + : "Kunde inte läsa notisinställningarna." + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSettings(); + }, [ + loadSettings, + ]); + + async function saveChange< + K extends keyof NotificationSettings + >( + key: K, + value: NotificationSettings[K] + ) { + const previous = + settings; + + const next = { + ...settings, + [key]: value, + }; + + setSettings(next); + setSaving(true); + setError(null); + + try { + const response = + await fetch( + "/api/settings", + { + method: + "POST", + + headers: { + "Content-Type": + "application/json", + }, + + body: + JSON.stringify({ + notifications: { + [key]: + value, + }, + }), + } + ); + + const result = + await response + .json() + .catch( + () => null + ); + + if (!response.ok) { + throw new Error( + result?.error || + "Kunde inte spara notisinställningen." + ); + } + + /* + * Använd serverns faktiska version. + * Då vet frontend exakt vad som sparades. + */ + if ( + result?.settings + ?.notifications + ) { + setSettings({ + ...DEFAULTS, + ...result.settings + .notifications, + }); + } + } catch ( + currentError + ) { + console.error( + currentError + ); + + setSettings(previous); + + setError( + currentError instanceof + Error + ? currentError.message + : "Kunde inte spara notisinställningen." + ); + } finally { + setSaving(false); + } + } + + const globallyDisabled = + !settings.enabled; + + return ( +
+ + + +
+ + saveChange( + "enabled", + value + ) + } + /> +
+
+ + + + + + saveChange( + "sleepSummary", + value + ) + } + /> + + + saveChange( + "lowSleepScore", + value + ) + } + /> + + + saveChange( + "sleepDebt", + value + ) + } + /> + + + + + + + saveChange( + "recovery", + value + ) + } + /> + + + saveChange( + "hrvDeviation", + value + ) + } + /> + + + saveChange( + "elevatedRestingHeartRate", + value + ) + } + /> + + + saveChange( + "lowBodyBattery", + value + ) + } + /> + + + saveChange( + "highStress", + value + ) + } + /> + + + + + + + saveChange( + "newActivity", + value + ) + } + /> + + + saveChange( + "stepGoalReached", + value + ) + } + /> + + + saveChange( + "intensityGoalReached", + value + ) + } + /> + + + saveChange( + "vo2maxChanged", + value + ) + } + /> + + + + + + + saveChange( + "newRecommendation", + value + ) + } + /> + + + saveChange( + "vytaAgeChanged", + value + ) + } + /> + + + saveChange( + "weeklySummary", + value + ) + } + /> + + + + + + + saveChange( + "quietHoursEnabled", + value + ) + } + /> + + {settings + .quietHoursEnabled && ( +
+ + saveChange( + "quietHoursStart", + value + ) + } + /> + + + saveChange( + "quietHoursEnd", + value + ) + } + /> +
+ )} +
+ + {saving && ( +

+ Sparar… +

+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function SettingsCard({ + children, +}: { + children: + React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +function SectionHeader({ + title, + description, +}: { + title: string; + description?: string; +}) { + return ( +
+

+ {title} +

+ + {description && ( +

+ {description} +

+ )} +
+ ); +} + +function ToggleRow({ + title, + description, + enabled, + disabled, + onChange, +}: { + title: string; + description: string; + enabled: boolean; + disabled: boolean; + + onChange: ( + enabled: boolean + ) => void; +}) { + return ( +
+
+

+ {title} +

+ +

+ {description} +

+
+ + +
+ ); +} + +function AppleSwitch({ + enabled, + disabled, + onChange, +}: { + enabled: boolean; + disabled: boolean; + + onChange: ( + enabled: boolean + ) => void; +}) { + return ( + + ); +} + +function TimeField({ + label, + value, + disabled, + onChange, +}: { + label: string; + value: string; + disabled: boolean; + + onChange: ( + value: string + ) => void; +}) { + const [ + hours = "00", + minutes = "00", + ] = value.split(":"); + + return ( +
+ + {label} + + +
+ + + + : + + + +
+
+ ); +} diff --git a/app/vyta/src/components/PushNotifications.tsx b/app/vyta/src/components/PushNotifications.tsx new file mode 100644 index 0000000..ec7fdbc --- /dev/null +++ b/app/vyta/src/components/PushNotifications.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { + useEffect, + useState, +} from "react"; + + +type PushState = + | "loading" + | "unsupported" + | "disabled" + | "enabled" + | "denied"; + + +function urlBase64ToUint8Array( + base64String: string +) { + const padding = + "=".repeat( + (4 - + (base64String.length % + 4)) % + 4 + ); + + const base64 = + ( + base64String + + padding + ) + .replace(/-/g, "+") + .replace(/_/g, "/"); + + const rawData = + window.atob(base64); + + return Uint8Array.from( + [...rawData].map( + (character) => + character.charCodeAt(0) + ) + ); +} + + +export default function PushNotifications() { + const [state, setState] = + useState( + "loading" + ); + + const [busy, setBusy] = + useState(false); + + + useEffect(() => { + async function check() { + if ( + !( + "serviceWorker" in + navigator + ) || + !( + "PushManager" in + window + ) || + !( + "Notification" in + window + ) + ) { + setState( + "unsupported" + ); + return; + } + + if ( + Notification.permission === + "denied" + ) { + setState("denied"); + return; + } + + const registration = + await navigator + .serviceWorker + .register( + "/sw.js" + ); + + const subscription = + await registration + .pushManager + .getSubscription(); + + setState( + subscription + ? "enabled" + : "disabled" + ); + } + + check().catch( + (error) => { + console.error(error); + setState( + "disabled" + ); + } + ); + }, []); + + + async function enableNotifications() { + setBusy(true); + + try { + const permission = + await Notification + .requestPermission(); + + if ( + permission !== + "granted" + ) { + setState( + permission === + "denied" + ? "denied" + : "disabled" + ); + + return; + } + + const registration = + await navigator + .serviceWorker + .register( + "/sw.js" + ); + + await navigator + .serviceWorker.ready; + + let subscription = + await registration + .pushManager + .getSubscription(); + + if (!subscription) { + const publicKey = + process.env + .NEXT_PUBLIC_VAPID_PUBLIC_KEY; + + if (!publicKey) { + throw new Error( + "VAPID public key saknas" + ); + } + + subscription = + await registration + .pushManager + .subscribe({ + userVisibleOnly: + true, + + applicationServerKey: + urlBase64ToUint8Array( + publicKey + ), + }); + } + + const response = + await fetch( + "/api/push/subscribe", + { + method: "POST", + + headers: { + "Content-Type": + "application/json", + }, + + body: + JSON.stringify( + subscription + ), + } + ); + + if (!response.ok) { + throw new Error( + `Subscribe misslyckades (${response.status})` + ); + } + + setState("enabled"); + } catch (error) { + console.error(error); + + alert( + error instanceof Error + ? error.message + : "Kunde inte aktivera notiser" + ); + } finally { + setBusy(false); + } + } + + + async function sendTest() { + setBusy(true); + + try { + const response = + await fetch( + "/api/push/test", + { + method: + "POST", + } + ); + + const result = + await response.json(); + + if (!response.ok) { + throw new Error( + result.error || + `Testnotis misslyckades (${response.status})` + ); + } + + alert( + `Skickat: ${result.sent}/${result.total}` + ); + } catch (error) { + console.error(error); + + alert( + error instanceof Error + ? error.message + : "Kunde inte skicka testnotis" + ); + } finally { + setBusy(false); + } + } + + + if ( + state === "loading" + ) { + return null; + } + + + if ( + state === "unsupported" + ) { + return ( +
+

+ NOTISER +

+ +

+ Pushnotiser stöds inte i den här webbläsaren. +

+
+ ); + } + + + if ( + state === "denied" + ) { + return ( +
+

+ NOTISER +

+ +

+ Notiser är blockerade för Vyta. Tillåt dem i systemets inställningar. +

+
+ ); + } + + + return ( +
+
+
+

+ NOTISER +

+ +

+ {state === + "enabled" + ? "Vyta-notiser är aktiverade." + : "Få notiser om sömn, recovery och träning."} +

+
+ + +
+ +
+ {state === + "enabled" ? ( + + ) : ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/app/vyta/src/components/SettingsButton.tsx b/app/vyta/src/components/SettingsButton.tsx new file mode 100644 index 0000000..3e7e070 --- /dev/null +++ b/app/vyta/src/components/SettingsButton.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +export default function SettingsButton() { + return ( + + + + ); +} diff --git a/app/vyta/src/components/SettingsForm.tsx b/app/vyta/src/components/SettingsForm.tsx new file mode 100644 index 0000000..aec505b --- /dev/null +++ b/app/vyta/src/components/SettingsForm.tsx @@ -0,0 +1,540 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { calculateBmi, calculatePlannedDeficit, getBmiLabel } from "@/lib/energy"; + +type Props = { + initialHeightCm: number | null; + initialWeightKg: number | null; + initialWeightTargetKg: number | null; + + initialCalorieTarget: number | null; + + initialStepGoal: number | null; + initialIntensityMinutesGoal: number | null; + initialHighIntensityMinutesGoal: number | null; + initialSleepGoalMinutes: number | null; + + initialStrainScale: "21" | "100"; + suggestedStepGoal: number; + suggestedIntensityGoal: number; + suggestedHighIntensityGoal: number; + suggestedSleepGoalMinutes: number; + + averageSteps: number | null; + averageIntensityMinutes: number | null; + averageHighIntensityMinutes: number | null; + averageSleepMinutes: number | null; + + tdee: number | null; + tdeeDays: number; + tdeeConfidence: string; +}; + +export default function SettingsForm({ + initialHeightCm, + initialWeightKg, + initialWeightTargetKg, + + initialCalorieTarget, + + initialStepGoal, + initialIntensityMinutesGoal, + initialHighIntensityMinutesGoal, + initialSleepGoalMinutes, + + initialStrainScale, + + suggestedStepGoal, + suggestedIntensityGoal, + suggestedHighIntensityGoal, + suggestedSleepGoalMinutes, + + averageSteps, + averageIntensityMinutes, + averageHighIntensityMinutes, + averageSleepMinutes, + + tdee, + tdeeDays, + tdeeConfidence, +}: Props) { + const [heightCm, setHeightCm] = useState(stringValue(initialHeightCm)); + + const [weightKg, setWeightKg] = useState(stringValue(initialWeightKg)); + + const [weightTargetKg, setWeightTargetKg] = useState(stringValue(initialWeightTargetKg)); + + const [calorieTarget, setCalorieTarget] = useState(stringValue(initialCalorieTarget)); + + const [stepGoal, setStepGoal] = useState(stringValue(initialStepGoal)); + + const [intensityMinutesGoal, setIntensityMinutesGoal] = useState( + stringValue(initialIntensityMinutesGoal) + ); + + const [highIntensityMinutesGoal, setHighIntensityMinutesGoal] = useState( + stringValue(initialHighIntensityMinutesGoal) + ); + + const [sleepGoalMinutes, setSleepGoalMinutes] = useState( + initialSleepGoalMinutes !== null + ? String(Math.round((initialSleepGoalMinutes / 60) * 10) / 10) + : "" + ); + const [strainScale, setStrainScale] = useState<"21" | "100">(initialStrainScale); + + const [saving, setSaving] = useState(false); + + const [message, setMessage] = useState(null); + + const numericHeight = numberOrNull(heightCm); + + const numericWeight = numberOrNull(weightKg); + + const numericWeightTarget = numberOrNull(weightTargetKg); + + const numericCalorieTarget = numberOrNull(calorieTarget); + + const numericStepGoal = numberOrNull(stepGoal); + + const numericIntensityGoal = numberOrNull(intensityMinutesGoal); + + const numericHighIntensityGoal = numberOrNull(highIntensityMinutesGoal); + + const numericSleepHours = numberOrNull(sleepGoalMinutes); + + const numericSleepGoalMinutes = + numericSleepHours !== null ? Math.round(numericSleepHours * 60) : null; + + const bmi = useMemo( + () => calculateBmi(numericWeight, numericHeight), + [numericWeight, numericHeight] + ); + + const deficit = calculatePlannedDeficit(tdee, numericCalorieTarget); + + async function save() { + setSaving(true); + setMessage(null); + + try { + const response = await fetch("/api/settings", { + method: "POST", + + headers: { + "Content-Type": "application/json", + }, + + body: JSON.stringify({ + heightCm: numericHeight, + + weightKg: numericWeight, + + weightTargetKg: numericWeightTarget, + + calorieTarget: numericCalorieTarget, + + stepGoal: numericStepGoal, + + intensityMinutesGoal: numericIntensityGoal, + + highIntensityMinutesGoal: numericHighIntensityGoal, + + sleepGoalMinutes: numericSleepGoalMinutes, + + strainScale, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Kunde inte spara."); + } + + setMessage("Sparat ✓"); + + window.setTimeout(() => { + window.location.reload(); + }, 500); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Kunde inte spara."); + } finally { + setSaving(false); + } + } + + return ( + <> +
+

KROPP & ENERGI

+ +
+ + + + + + + +
+
+ +
+

BELASTNINGSSKALA

+ +

Välj hur VYTA visar strain i appen.

+ +
+ + + +
+ +

+ Valet ändrar hur belastning visas. Den interna beräkningen ligger kvar på 0–21. +

+
+ +
+
+

AKTIVITETSMÅL

+ +

+ Dina mål är personliga. VYTA visar samtidigt rekommenderade nivåer baserat på din + Garmin-data och Healthspan-modellen. +

+
+ +
+ setStepGoal(String(suggestedStepGoal))} + /> + + setIntensityMinutesGoal(String(suggestedIntensityGoal))} + /> + + setHighIntensityMinutesGoal(String(suggestedHighIntensityGoal))} + /> + + + setSleepGoalMinutes(String(suggestedSleepGoalMinutes / 60).replace(".", ",")) + } + /> +
+ + + + {message &&

{message}

} +
+ +
+

KROPP

+ +
+ + + +
+
+ +
+
+
+

VYTA TDEE

+ +

+ {tdee !== null ? tdee.toLocaleString("sv-SE") : "—"} +

+ +

kcal/dag

+
+ + + {tdeeConfidence} + +
+ +

+ Beräknas från Garmins faktiska totala kaloriförbrukning under upp till 28 avslutade dagar. + {tdeeDays > 0 ? ` ${tdeeDays} dagar används just nu.` : ""} +

+ +
+
+ + + +
+
+
+ + ); +} + +function GoalField({ + label, + suffix, + value, + onChange, + placeholder, + current, + recommended, + onUseRecommended, + inputMode = "numeric", +}: { + label: string; + suffix: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + current: string; + recommended: string; + onUseRecommended: () => void; + inputMode?: "decimal" | "numeric"; +}) { + return ( +
+ + +
+

Garmin: {current}

+ + +
+
+ ); +} + +function Field({ + label, + suffix, + value, + onChange, + inputMode, + placeholder, +}: { + label: string; + suffix: string; + value: string; + + onChange: (value: string) => void; + + inputMode: "decimal" | "numeric"; + + placeholder: string; +}) { + return ( + + ); +} + +function Metric({ + label, + value, + subtitle, +}: { + label: string; + value: string | number; + subtitle: string; +}) { + return ( +
+

{label}

+ +

{value}

+ +

{subtitle}

+
+ ); +} + +function numberOrNull(input: string) { + const cleaned = input.trim().replace(",", "."); + + if (!cleaned) { + return null; + } + + const value = Number(cleaned); + + return Number.isFinite(value) ? value : null; +} + +function stringValue(value: number | null) { + return value !== null ? String(value) : ""; +} + +function formatOne(value: number) { + return value.toFixed(1).replace(".", ","); +} + +function formatDuration(minutes: number) { + const total = Math.round(minutes); + + const hours = Math.floor(total / 60); + + const remainder = total % 60; + + return `${hours} h ${remainder.toString().padStart(2, "0")} min`; +} diff --git a/app/vyta/src/components/SleepPlanner.tsx b/app/vyta/src/components/SleepPlanner.tsx new file mode 100644 index 0000000..7b3b114 --- /dev/null +++ b/app/vyta/src/components/SleepPlanner.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useMemo, useState } from "react"; + +type Props = { + sleepNeedMinutes: number; + sleepDebtMinutes: number; + actualSleepMinutes: number; +}; + +export default function SleepPlanner({ + sleepNeedMinutes, + sleepDebtMinutes, + actualSleepMinutes, +}: Props) { + const [wakeTime, setWakeTime] = useState("06:30"); + + const targetSleepMinutes = Math.max(0, Math.round(sleepNeedMinutes)); + + const bedTime = useMemo(() => { + return subtractMinutesFromTime(wakeTime, targetSleepMinutes); + }, [wakeTime, targetSleepMinutes]); + + const sleepGap = targetSleepMinutes - actualSleepMinutes; + + return ( +
+
+ ◐ + +

+ PLANERA NATTEN +

+
+ +
+ + + = 60} + /> +
+ +
+
+
+

Jag vill vakna

+ +

Ändra tiden för att planera natten.

+
+ + setWakeTime(event.target.value)} + className="w-[104px] rounded-2xl border border-white/[0.07] bg-white/[0.04] px-3 py-2.5 text-center text-sm font-semibold tabular-nums text-white outline-none" + /> +
+ +
+

+ Rekommenderad läggtid +

+ +
+

{bedTime}

+ +

+ för cirka +
+ {formatDuration(targetSleepMinutes)} +

+
+
+
+ + {sleepGap > 0 && ( +

+ Förra natten sov du{" "} + {formatDuration(sleepGap)} mindre än + dagens beräknade sömnbehov. +

+ )} +
+ ); +} + +function SleepMetric({ + label, + value, + warning = false, +}: { + label: string; + value: string; + warning?: boolean; +}) { + return ( +
+

{label}

+ +

+ {value} +

+
+ ); +} + +function subtractMinutesFromTime(time: string, minutes: number) { + const parts = time.split(":"); + + const hours = Number(parts[0]); + + const mins = Number(parts[1]); + + if (!Number.isFinite(hours) || !Number.isFinite(mins)) { + return "—"; + } + + const dayMinutes = 24 * 60; + + const wakeMinutes = hours * 60 + mins; + + const result = (wakeMinutes - minutes + dayMinutes * 2) % dayMinutes; + + const resultHours = Math.floor(result / 60); + + const resultMinutes = result % 60; + + return `${String(resultHours).padStart(2, "0")}:${String(resultMinutes).padStart(2, "0")}`; +} + +function formatDuration(minutes: number) { + const safe = Math.max(0, Math.round(minutes)); + + const hours = Math.floor(safe / 60); + + const remaining = safe % 60; + + if (hours === 0) { + return `${remaining} min`; + } + + if (remaining === 0) { + return `${hours} h`; + } + + return `${hours} h ${remaining} min`; +} diff --git a/app/vyta/src/components/SyncStatus.tsx b/app/vyta/src/components/SyncStatus.tsx new file mode 100644 index 0000000..e738bba --- /dev/null +++ b/app/vyta/src/components/SyncStatus.tsx @@ -0,0 +1,558 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, +} from "react"; + +type SyncStatusData = { + trigger?: + | "startup" + | "manual" + | "scheduled"; + + status?: + | "idle" + | "syncing" + | "error" + | "unknown"; + + startedAt?: + string | null; + + completedAt?: + string | null; + + lastSuccessfulSync?: + string | null; + + nextAutomaticSyncInSeconds?: + number | null; + + syncIntervalSeconds?: + number | null; + + error?: + string | null; +}; + +export default function SyncStatus() { + const [ + status, + setStatus, + ] = useState< + SyncStatusData | null + >(null); + + const [ + loading, + setLoading, + ] = useState( + true + ); + + const [ + requesting, + setRequesting, + ] = useState( + false + ); + + const [ + error, + setError, + ] = useState< + string | null + >(null); + + const loadStatus = + useCallback( + async () => { + try { + const response = + await fetch( + "/api/sync/status", + { + cache: + "no-store", + } + ); + + if ( + !response.ok + ) { + throw new Error( + "Kunde inte läsa sync-status." + ); + } + + const result = + await response.json(); + + setStatus( + result + ); + + setError( + null + ); + + return result as + SyncStatusData; + } catch ( + currentError + ) { + console.error( + currentError + ); + + setError( + currentError instanceof + Error + ? currentError.message + : "Kunde inte läsa sync-status." + ); + + return null; + } finally { + setLoading( + false + ); + } + }, + [] + ); + + useEffect(() => { + loadStatus(); + + /* + * Vi pollar varannan sekund. + * + * Det gör dels countdownen levande, + * dels att UI:t snabbt ser när + * scheduler-containern börjar/avslutar + * en sync. + */ + const interval = + window.setInterval( + loadStatus, + 2000 + ); + + return () => { + window.clearInterval( + interval + ); + }; + }, [ + loadStatus, + ]); + + async function triggerSync() { + if ( + requesting || + status?.status === + "syncing" + ) { + return; + } + + setRequesting( + true + ); + + setError( + null + ); + + try { + const response = + await fetch( + "/api/sync/trigger", + { + method: + "POST", + } + ); + + const result = + await response + .json() + .catch( + () => null + ); + + if ( + !response.ok + ) { + throw new Error( + result?.error || + "Kunde inte starta synkningen." + ); + } + + /* + * Scheduler pollar triggerfilen + * varannan sekund. + * + * Vi väntar därför lite och läser + * status igen. + */ + await new Promise( + ( + resolve + ) => + window.setTimeout( + resolve, + 750 + ) + ); + + await loadStatus(); + } catch ( + currentError + ) { + console.error( + currentError + ); + + setError( + currentError instanceof + Error + ? currentError.message + : "Kunde inte starta synkningen." + ); + } finally { + setRequesting( + false + ); + } + } + + const syncing = + status?.status === + "syncing"; + + return ( +
+
+
+

+ GARMIN +

+ +
+ + +

+ {loading + ? "Kontrollerar…" + : syncing + ? "Synkar…" + : status?.status === + "error" + ? "Syncfel" + : "Ansluten"} +

+
+
+ + +
+ +
+ + + + + + + {status?.trigger && ( + + )} +
+ + {syncing && ( +
+
+
+ )} + + {status?.status === + "error" && + status.error && ( +
+

+ Senaste synkningen misslyckades +

+ +

+ {status.error} +

+
+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function InfoRow({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+ + {label} + + + + {value} + +
+ ); +} + +function formatSyncTime( + value?: string | null +) { + if (!value) { + return "Aldrig"; + } + + const date = + new Date( + value + ); + + if ( + Number.isNaN( + date.getTime() + ) + ) { + return "Okänd"; + } + + const now = + new Date(); + + const sameDay = + date.getFullYear() === + now.getFullYear() && + date.getMonth() === + now.getMonth() && + date.getDate() === + now.getDate(); + + const yesterday = + new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() - 1 + ); + + const wasYesterday = + date.getFullYear() === + yesterday.getFullYear() && + date.getMonth() === + yesterday.getMonth() && + date.getDate() === + yesterday.getDate(); + + const time = + new Intl.DateTimeFormat( + "sv-SE", + { + hour: + "2-digit", + minute: + "2-digit", + } + ).format( + date + ); + + if (sameDay) { + return `Idag ${time}`; + } + + if ( + wasYesterday + ) { + return `Igår ${time}`; + } + + return new Intl.DateTimeFormat( + "sv-SE", + { + day: + "numeric", + month: + "short", + hour: + "2-digit", + minute: + "2-digit", + } + ).format( + date + ); +} + +function formatCountdown( + seconds?: + number | null +) { + if ( + seconds === + null || + seconds === + undefined + ) { + return "—"; + } + + if (seconds <= 10) { + return "snart"; + } + + if (seconds < 60) { + return `om ${seconds} sek`; + } + + const minutes = + Math.ceil( + seconds / 60 + ); + + return `om ${minutes} min`; +} + +function formatInterval( + seconds?: + number | null +) { + if ( + !seconds + ) { + return "—"; + } + + if ( + seconds % + 60 === + 0 + ) { + const minutes = + seconds / 60; + + return minutes === 1 + ? "Varje minut" + : `Var ${minutes}:e minut`; + } + + return `Var ${seconds} sek`; +} + +function formatTrigger( + trigger: string +) { + switch ( + trigger + ) { + case "manual": + return "Manuell"; + + case "scheduled": + return "Automatisk"; + + case "startup": + return "Containerstart"; + + default: + return trigger; + } +} diff --git a/app/vyta/src/components/TodayHeader.tsx b/app/vyta/src/components/TodayHeader.tsx new file mode 100644 index 0000000..54c0c41 --- /dev/null +++ b/app/vyta/src/components/TodayHeader.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, useState } from "react"; + +function getGreeting(hour: number) { + if (hour < 5) return "God natt"; + if (hour < 10) return "God morgon"; + if (hour < 17) return "God dag"; + return "God kväll"; +} + +export default function TodayHeader() { + const [now, setNow] = useState(null); + + useEffect(() => { + const updateTime = () => { + setNow(new Date()); + }; + + updateTime(); + + const interval = window.setInterval(updateTime, 60_000); + + return () => { + window.clearInterval(interval); + }; + }, []); + + if (!now) { + return ( +
+
+
+ VYTA +
+ +
+
+
+
+ ); + } + + const greeting = getGreeting(now.getHours()); + + const weekday = new Intl.DateTimeFormat("sv-SE", { + weekday: "long", + }) + .format(now) + .toUpperCase(); + + const date = new Intl.DateTimeFormat("sv-SE", { + day: "numeric", + month: "short", + }) + .format(now) + .replace(".", "") + .toUpperCase(); + + return ( +
+
+
+ VYTA +
+ +

+ {greeting} +

+ +

+ Så här mår din kropp idag. +

+
+ +
+
+ {weekday} +
+ +
+ {date} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/vyta/src/components/TrendChart.tsx b/app/vyta/src/components/TrendChart.tsx new file mode 100644 index 0000000..006d68d --- /dev/null +++ b/app/vyta/src/components/TrendChart.tsx @@ -0,0 +1,68 @@ +type TrendChartProps = { + values: number[]; + labels?: string[]; + min?: number; + max?: number; +}; + +export default function TrendChart({ + values, + labels, + min, + max, +}: TrendChartProps) { + const width = 320; + const height = 120; + const padding = 12; + + const calculatedMin = min ?? Math.min(...values); + const calculatedMax = max ?? Math.max(...values); + + const range = + calculatedMax - calculatedMin === 0 + ? 1 + : calculatedMax - calculatedMin; + + const points = values + .map((value, index) => { + const x = + padding + + (index / Math.max(values.length - 1, 1)) * + (width - padding * 2); + + const y = + height - + padding - + ((value - calculatedMin) / range) * + (height - padding * 2); + + return `${x},${y}`; + }) + .join(" "); + + return ( +
+ + + + + {labels && labels.length > 0 && ( +
+ {labels[0]} + {labels[Math.floor(labels.length / 2)]} + {labels[labels.length - 1]} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/vyta/src/components/VytaInsightCard.tsx b/app/vyta/src/components/VytaInsightCard.tsx new file mode 100644 index 0000000..c5693da --- /dev/null +++ b/app/vyta/src/components/VytaInsightCard.tsx @@ -0,0 +1,156 @@ +import { formatStrain, type StrainScale } from "@/lib/strainScale"; + +type Contributor = { + label: string; + value: string; + score: number; +}; + +type Props = { + title: string; + text: string; + level: "great" | "good" | "moderate" | "low"; + + recovery: number | null; + currentStrain: number; + + recommendedMin: number; + recommendedMax: number; + + strainScale: StrainScale; + + contributors: Contributor[]; +}; + +export default function VytaInsightCard({ + title, + text, + level, + recovery, + currentStrain, + recommendedMin, + recommendedMax, + strainScale, + contributors, +}: Props) { + const accent = getAccent(level); + + return ( +
+
+
+ ✦ + +

+ VYTA INSIGHT +

+
+ + + PERSONLIG + +
+ +

{title}

+ +

{text}

+ +
+ + + + + +
+ +
+ {contributors.map((item) => ( +
+ {item.label} + + + {item.value} + +
+ ))} +
+
+ ); +} + +function InsightMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function contributorClass(score: number) { + if (score > 0) { + return "text-emerald-400"; + } + + if (score < 0) { + return "text-red-400"; + } + + return "text-zinc-400"; +} + +function getAccent(level: Props["level"]) { + if (level === "great") { + return { + border: "border-emerald-400/25", + text: "text-emerald-400", + badge: "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-400", + }; + } + + if (level === "good") { + return { + border: "border-emerald-400/20", + text: "text-emerald-400", + badge: "border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-400", + }; + } + + if (level === "moderate") { + return { + border: "border-amber-400/20", + text: "text-amber-400", + badge: "border-amber-400/20 bg-amber-400/[0.08] text-amber-400", + }; + } + + return { + border: "border-red-400/20", + text: "text-red-400", + badge: "border-red-400/20 bg-red-400/[0.08] text-red-400", + }; +} diff --git a/app/vyta/src/components/WeeklySummaryCard.tsx b/app/vyta/src/components/WeeklySummaryCard.tsx new file mode 100644 index 0000000..9fba4af --- /dev/null +++ b/app/vyta/src/components/WeeklySummaryCard.tsx @@ -0,0 +1,79 @@ +type Props = { + recoveryAverage: number | null; + sleepAverage: number | null; + hrvAverage: number | null; + strainAverage: number | null; + activities: number; + insight: string; +}; + +export default function WeeklySummaryCard({ + recoveryAverage, + sleepAverage, + hrvAverage, + strainAverage, + activities, + insight, +}: Props) { + return ( +
+
+
+

VECKAN

+ +

Din senaste 7-dagarsbild

+
+ + + 7 DAGAR + +
+ +
+ + + + +
+ +
+
+ Aktiviteter + {activities} +
+
+ +
+

VYTA summerar

+ +

{insight}

+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+ +

{value}

+
+ ); +} + +function formatValue(value: number | null, suffix = "") { + if (value === null) { + return "—"; + } + + return `${Math.round(value)}${suffix}`; +} + +function formatOne(value: number | null) { + if (value === null) { + return "—"; + } + + return value.toFixed(1).replace(".", ","); +} diff --git a/app/vyta/src/engine/baseline.ts b/app/vyta/src/engine/baseline.ts new file mode 100644 index 0000000..9284b04 --- /dev/null +++ b/app/vyta/src/engine/baseline.ts @@ -0,0 +1,28 @@ +export function average( + values: number[] +): number | null { + if (values.length === 0) { + return null; + } + + return ( + values.reduce( + (sum, value) => sum + value, + 0 + ) / values.length + ); +} + +export function calculateBaseline( + values: Array, + days = 30 +): number | null { + const validValues = values + .filter( + (value): value is number => + value !== null + ) + .slice(-days); + + return average(validValues); +} \ No newline at end of file diff --git a/app/vyta/src/engine/bioAge.ts b/app/vyta/src/engine/bioAge.ts new file mode 100644 index 0000000..b44ddd9 --- /dev/null +++ b/app/vyta/src/engine/bioAge.ts @@ -0,0 +1,858 @@ +export type HealthspanContributorKey = + | "vo2max" + | "restingHeartRate" + | "hrv" + | "steps" + | "sleepDuration" + | "sleepConsistency" + | "lowModerateIntensity" + | "highIntensity" + | "strengthTraining" + | "bodyComposition"; + +export type HealthspanInput = { + chronologicalAge: number; + + /* + * Long-term physiological signals. + * Missing data MUST be null, never 0. + */ + + vo2max: number | null; + + restingHeartRate: number | null; + + hrv: number | null; + + hrvBaseline: number | null; + + averageSteps: number | null; + + averageSleepMinutes: number | null; + + /* + * 0-100. + * + * Until Garmin sleep timing is added to the sync, + * Vyta calculates this from day-to-day sleep-duration + * regularity as a proxy. + */ + sleepConsistency: number | null; + + /* + * Activity HR-zone minutes / week. + * Zones 1-3 are used for low/moderate work. + */ + lowModerateIntensityMinutesPerWeek: number | null; + + /* + * Activity HR-zone minutes / week. + * Zones 4-5 are used for high-intensity work. + */ + highIntensityMinutesPerWeek: number | null; + + /* + * Strength activity minutes / week. + */ + strengthMinutesPerWeek: number | null; + + /* + * Lean body mass percentage. + * Missing Garmin body-composition data MUST be null. + */ + leanBodyMassPercent: number | null; + + /* + * Number of usable historical days. + */ + dataDays: number; +}; + +export type HealthspanContributor = { + key: HealthspanContributorKey; + + label: string; + + valueLabel: string; + + note: string; + + ageImpact: number; + + available: boolean; + + confidence: "high" | "medium" | "low"; +}; + +export type HealthspanResult = { + age: number; + + delta: number; + + chronologicalAge: number; + + contributors: HealthspanContributor[]; + + dataDays: number; + + targetDays: number; + + completeness: number; + + confidence: "building" | "medium" | "high"; +}; + +export type PaceOfAgingResult = { + value: number | null; + + label: string; + + trend: "better" | "stable" | "worse" | "building"; +}; + +/* + * ========================================================= + * MAIN HEALTHSPAN ENGINE + * ========================================================= + */ + +export function calculateBioAge(input: HealthspanInput): HealthspanResult { + const contributors: HealthspanContributor[] = [ + buildVo2Contributor(input.vo2max, input.chronologicalAge), + + buildRhrContributor(input.restingHeartRate, input.chronologicalAge), + + buildHrvContributor(input.hrv, input.hrvBaseline), + + buildStepsContributor(input.averageSteps), + + buildSleepDurationContributor(input.averageSleepMinutes), + + buildSleepConsistencyContributor(input.sleepConsistency), + + buildLowModerateContributor(input.lowModerateIntensityMinutesPerWeek), + + buildHighIntensityContributor(input.highIntensityMinutesPerWeek), + + buildStrengthContributor(input.strengthMinutesPerWeek), + + buildBodyCompositionContributor(input.leanBodyMassPercent), + ]; + + const available = contributors.filter((contributor) => contributor.available); + + /* + * Each contributor returns an age impact. + * + * We do NOT penalize missing data. + */ + const rawImpact = applyOverlapCorrection( + available.reduce((total, contributor) => total + contributor.ageImpact, 0), + available + ); + + /* + * Healthspan should move slowly. + * + * Even with excellent or poor data, + * one calculation must not produce + * absurd swings. + */ + const cappedImpact = clamp(rawImpact, -7, 7); + + const maturityWeight = getMaturityWeight(input.dataDays); + + const adjustedImpact = cappedImpact * maturityWeight; + + /* + * Display consistency: + * + * Contributors are shown with one decimal in the UI. + * Use the sum of those same displayed values for the + * displayed Vyta Age and delta so all visible numbers + * always add up exactly. + */ + const adjustedContributors = contributors.map((contributor) => ({ + ...contributor, + + ageImpact: contributor.available ? roundOne(contributor.ageImpact * maturityWeight) : 0, + })); + + const displayedImpact = roundOne( + adjustedContributors.reduce( + (total, contributor) => total + (contributor.available ? contributor.ageImpact : 0), + 0 + ) + ); + + const age = input.chronologicalAge + displayedImpact; + + const completeness = contributors.length > 0 ? available.length / contributors.length : 0; + + return { + age: roundOne(age), + + delta: displayedImpact, + + chronologicalAge: roundOne(input.chronologicalAge), + + contributors: adjustedContributors, + + dataDays: input.dataDays, + + targetDays: 180, + + completeness: Math.round(completeness * 100), + + confidence: + input.dataDays >= 150 && completeness >= 0.75 + ? "high" + : input.dataDays >= 60 + ? "medium" + : "building", + }; +} + +/* + * Backwards-compatible alias. + */ +export type BioAgeInput = HealthspanInput; + +export type BioAgeFactor = HealthspanContributor; + +export type BioAgeResult = HealthspanResult; + +/* + * ========================================================= + * PACE OF AGING + * ========================================================= + * + * Compare the recent 30-day phenotype with + * the longer healthspan phenotype. + * + * This is intentionally smoother than: + * + * "today was bad => aging 2x". + */ + +export function calculatePaceOfAging( + longTerm: HealthspanResult, + + recent: HealthspanResult +): PaceOfAgingResult { + if (recent.dataDays < 14 || longTerm.dataDays < 14) { + return { + value: null, + + label: "Bygger underlag", + + trend: "building", + }; + } + + const difference = recent.age - longTerm.age; + + /* + * Center around 1.0x. + * + * Recent phenotype better than long-term + * -> below 1.0x. + * + * Worse -> above 1.0x. + */ + const pace = clamp(1 + difference * 0.12, 0.5, 1.5); + + const rounded = roundTwo(pace); + + if (rounded <= 0.9) { + return { + value: rounded, + + label: "Positiv utveckling", + + trend: "better", + }; + } + + if (rounded >= 1.1) { + return { + value: rounded, + + label: "Kan förbättras", + + trend: "worse", + }; + } + + return { + value: rounded, + + label: "Stabil utveckling", + + trend: "stable", + }; +} + +/* + * ========================================================= + * SIMULATION + * ========================================================= + */ + +export function simulateBioAge( + input: HealthspanInput, + + changes: Partial +) { + return calculateBioAge({ + ...input, + ...changes, + }); +} + +/* + * ========================================================= + * CONTRIBUTORS + * ========================================================= + */ + +function buildVo2Contributor( + value: number | null, + + age: number +): HealthspanContributor { + if (!isFiniteNumber(value)) { + return unavailable("vo2max", "VO₂max", "Ingen Garmin-data"); + } + + const reference = getVo2Reference(age); + + const difference = value - reference; + + const normalized = difference / 7.5; + + /* + * Strong nonlinear contributor. + * + * This approximates an age-relative percentile curve + * without claiming Garmin/WHOOP proprietary math. + */ + const impact = clamp(-2.8 * Math.tanh(normalized), -3.6, 3.1); + + return { + key: "vo2max", + + label: "VO₂max", + + valueLabel: `${Math.round(value)}`, + + note: + difference >= 5 + ? "Mycket starkt för din ålder" + : difference >= 1 + ? "Över referens för din ålder" + : difference >= -2 + ? "Nära åldersreferens" + : "Här finns förbättringspotential", + + ageImpact: impact, + + available: true, + + confidence: "high", + }; +} + +function buildRhrContributor( + value: number | null, + + age: number +): HealthspanContributor { + if (!isFiniteNumber(value)) { + return unavailable("restingHeartRate", "Vilopuls", "Ingen Garmin-data"); + } + + /* + * Broad age-aware reference. + * This is an internal healthspan curve, + * not a clinical diagnosis. + */ + const reference = clamp(62 + (age - 35) * 0.08, 58, 68); + + const difference = value - reference; + + const impact = clamp(1.25 * Math.tanh(difference / 9), -1.3, 1.5); + + return { + key: "restingHeartRate", + + label: "Vilopuls", + + valueLabel: `${formatOne(value)} bpm`, + + note: + difference <= -5 + ? "Stark kardiovaskulär signal" + : difference <= 1 + ? "Bra nivå" + : "Här finns förbättringspotential", + + ageImpact: impact, + + available: true, + + confidence: "high", + }; +} + +function buildHrvContributor(value: number | null, baseline: number | null): HealthspanContributor { + if (!isFiniteNumber(value)) { + return unavailable("hrv", "HRV", "Ingen Garmin-data"); + } + + const hasBaseline = isFiniteNumber(baseline) && baseline > 0; + const ratio = hasBaseline ? value / baseline : null; + const impact = hasBaseline + ? clamp(-1.15 * Math.tanh(((ratio ?? 1) - 1) / 0.18), -1.2, 1.25) + : clamp(-0.9 * Math.tanh((value - 45) / 18), -0.95, 1.05); + + return { + key: "hrv", + + label: "HRV", + + valueLabel: `${Math.round(value)} ms`, + + note: + ratio === null + ? "Återhämtningssignal från Garmin" + : ratio >= 1.1 + ? "Över din normala nivå" + : ratio >= 0.92 + ? "Nära din normala nivå" + : "Lägre än din normala nivå", + + ageImpact: impact, + + available: true, + + confidence: hasBaseline ? "high" : "medium", + }; +} + +function buildStepsContributor(steps: number | null): HealthspanContributor { + if (!isFiniteNumber(steps)) { + return unavailable("steps", "Dagliga steg", "Ingen Garmin-data"); + } + + let impact = 0; + + if (steps < 3000) { + impact = 1.25; + } else if (steps < 5000) { + impact = interpolate(steps, 3000, 5000, 1.25, 0.65); + } else if (steps < 7000) { + impact = interpolate(steps, 5000, 7000, 0.65, 0.15); + } else if (steps < 9000) { + impact = interpolate(steps, 7000, 9000, 0.15, -0.25); + } else if (steps < 11000) { + impact = interpolate(steps, 9000, 11000, -0.25, -0.55); + } else if (steps < 14000) { + impact = interpolate(steps, 11000, 14000, -0.55, -0.75); + } else { + impact = -0.75; + } + + return { + key: "steps", + + label: "Dagliga steg", + + valueLabel: `${Math.round(steps).toLocaleString("sv-SE")} steg`, + + note: + steps >= 9000 + ? "Stark vardagsaktivitet" + : steps >= 7000 + ? "Bra nivå" + : "Nästa mål: 7 000–9 000 steg", + + ageImpact: impact, + + available: true, + + confidence: "high", + }; +} + +function buildSleepDurationContributor(minutes: number | null): HealthspanContributor { + if (!isFiniteNumber(minutes)) { + return unavailable("sleepDuration", "Sömnlängd", "Ingen Garmin-data"); + } + + const hours = minutes / 60; + + /* + * Nonlinear sleep-duration curve. + * Moderate short sleep should matter without overpowering + * strong cardiovascular signals. + */ + + let impact = 0; + + if (hours < 5.5) { + impact = 1.2; + } else if (hours < 6) { + impact = interpolate(hours, 5.5, 6, 1.2, 0.8); + } else if (hours < 6.5) { + impact = interpolate(hours, 6, 6.5, 0.8, 0.35); + } else if (hours < 7) { + impact = interpolate(hours, 6.5, 7, 0.35, 0.1); + } else if (hours < 7.5) { + impact = interpolate(hours, 7, 7.5, 0.1, -0.1); + } else if (hours <= 8.5) { + impact = interpolate(hours, 7.5, 8.5, -0.25, -0.2); + } else if (hours <= 9.5) { + impact = interpolate(hours, 8.5, 9.5, -0.15, 0.1); + } else { + impact = 0.25; + } + + return { + key: "sleepDuration", + + label: "Sömnlängd", + + valueLabel: formatDuration(minutes), + + note: + hours >= 7 && hours <= 9 + ? "Bra långsiktig sömnlängd" + : hours < 7 + ? "Mer sömn kan hjälpa" + : "Följ trenden över tid", + + ageImpact: impact, + + available: true, + + confidence: "high", + }; +} + +function buildSleepConsistencyContributor(consistency: number | null): HealthspanContributor { + if (!isFiniteNumber(consistency)) { + return unavailable("sleepConsistency", "Sömnregularitet", "Behöver fler sömndagar"); + } + + let impact = 0; + + if (consistency >= 92) { + impact = -0.35; + } else if (consistency >= 85) { + impact = interpolate(consistency, 85, 92, -0.2, -0.35); + } else if (consistency >= 75) { + impact = 0; + } else if (consistency >= 65) { + impact = interpolate(consistency, 65, 75, 0.2, 0); + } else if (consistency >= 50) { + impact = interpolate(consistency, 50, 65, 0.55, 0.2); + } else { + impact = 0.75; + } + + return { + key: "sleepConsistency", + + label: "Sömnregularitet", + + valueLabel: `${Math.round(consistency)}%`, + + note: consistency >= 80 ? "Jämn sömn över tid" : "Jämnare sömntider kan hjälpa", + + ageImpact: impact, + + available: true, + + /* + * Medium because current Vyta API does not + * yet contain true sleep/wake timing. + */ + confidence: "medium", + }; +} + +function buildLowModerateContributor(minutes: number | null): HealthspanContributor { + if (!isFiniteNumber(minutes)) { + return unavailable("lowModerateIntensity", "Låg/medelintensitet", "Ingen Garmin-data"); + } + + let impact = 0; + + if (minutes < 30) { + impact = 0.6; + } else if (minutes < 90) { + impact = interpolate(minutes, 30, 90, 0.6, 0); + } else if (minutes < 180) { + impact = interpolate(minutes, 90, 180, 0, -0.55); + } else if (minutes < 300) { + impact = -0.7; + } else { + impact = -0.6; + } + + return { + key: "lowModerateIntensity", + + label: "Låg/medelintensitet", + + valueLabel: `${Math.round(minutes)} min/vecka`, + + note: "Aktivitetszoner 1-3 från Garmin", + + ageImpact: impact, + + available: true, + + confidence: "medium", + }; +} + +function buildHighIntensityContributor(minutes: number | null): HealthspanContributor { + if (!isFiniteNumber(minutes)) { + return unavailable("highIntensity", "Hög intensitet", "Ingen Garmin-data"); + } + + let impact = 0; + + if (minutes < 10) { + impact = 0.45; + } else if (minutes < 30) { + impact = interpolate(minutes, 10, 30, 0.45, 0); + } else if (minutes < 75) { + impact = interpolate(minutes, 30, 75, 0, -0.65); + } else if (minutes < 120) { + impact = -0.7; + } else { + impact = -0.55; + } + + return { + key: "highIntensity", + + label: "Hög intensitet", + + valueLabel: `${Math.round(minutes)} min/vecka`, + + note: "Aktivitetszoner 4-5 från Garmin", + + ageImpact: impact, + + available: true, + + confidence: "medium", + }; +} + +function buildStrengthContributor(minutes: number | null): HealthspanContributor { + if (!isFiniteNumber(minutes)) { + return unavailable("strengthTraining", "Styrketräning", "Utökad Garmin-aktivitetssync krävs"); + } + + let impact = 0; + + if (minutes < 30) { + impact = 0.4; + } else if (minutes < 90) { + impact = interpolate(minutes, 30, 90, 0.4, -0.45); + } else if (minutes <= 180) { + impact = -0.6; + } else { + impact = -0.45; + } + + return { + key: "strengthTraining", + + label: "Styrketräning", + + valueLabel: `${Math.round(minutes)} min/vecka`, + + note: "Genomsnitt över Healthspan-perioden", + + ageImpact: impact, + + available: true, + + confidence: "high", + }; +} + +function buildBodyCompositionContributor( + leanBodyMassPercent: number | null +): HealthspanContributor { + if (!isFiniteNumber(leanBodyMassPercent)) { + return unavailable( + "bodyComposition", + "Kroppssammansättning", + "Lean body mass saknas i Garmin-data" + ); + } + + let impact = 0; + + if (leanBodyMassPercent < 68) { + impact = 0.55; + } else if (leanBodyMassPercent < 74) { + impact = interpolate(leanBodyMassPercent, 68, 74, 0.55, 0.1); + } else if (leanBodyMassPercent < 82) { + impact = interpolate(leanBodyMassPercent, 74, 82, 0.1, -0.35); + } else if (leanBodyMassPercent <= 88) { + impact = -0.45; + } else { + impact = -0.25; + } + + return { + key: "bodyComposition", + + label: "Kroppssammansättning", + + valueLabel: formatOne(leanBodyMassPercent) + "% lean mass", + + note: "Lean body mass från Garmin", + + ageImpact: impact, + + available: true, + + confidence: "medium", + }; +} + +/* + * ========================================================= + * REFERENCES + * ========================================================= + */ + +function getVo2Reference(age: number) { + /* + * Internal age-relative Vyta reference. + * + * This is NOT presented as WHOOP's + * proprietary reference curve. + */ + + return clamp(46 - (age - 30) * 0.3, 34, 52); +} + +/* + * ========================================================= + * HELPERS + * ========================================================= + */ + +function unavailable( + key: HealthspanContributorKey, + + label: string, + + note: string +): HealthspanContributor { + return { + key, + + label, + + valueLabel: "—", + + note, + + ageImpact: 0, + + available: false, + + confidence: "low", + }; +} + +function interpolate( + value: number, + + inputMin: number, + + inputMax: number, + + outputMin: number, + + outputMax: number +) { + if (inputMin === inputMax) { + return outputMin; + } + + const progress = clamp((value - inputMin) / (inputMax - inputMin), 0, 1); + + return outputMin + (outputMax - outputMin) * progress; +} + +function clamp( + value: number, + + minimum: number, + + maximum: number +) { + return Math.max(minimum, Math.min(maximum, value)); +} + +function roundOne(value: number) { + return Math.round(value * 10) / 10; +} + +function roundTwo(value: number) { + return Math.round(value * 100) / 100; +} + +function formatOne(value: number) { + return value.toFixed(1).replace(".", ","); +} + +function formatDuration(minutes: number) { + const total = Math.round(minutes); + + const hours = Math.floor(total / 60); + + const remainder = total % 60; + + return `${hours} h ${remainder.toString().padStart(2, "0")} min`; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function getMaturityWeight(dataDays: number) { + return dataDays > 0 ? 1 : 0; +} + +function applyOverlapCorrection(rawImpact: number, contributors: HealthspanContributor[]) { + const cardiovascularKeys: HealthspanContributorKey[] = [ + "vo2max", + "restingHeartRate", + "hrv", + "steps", + "lowModerateIntensity", + "highIntensity", + ]; + + const cardiovascularImpact = contributors + .filter((contributor) => cardiovascularKeys.includes(contributor.key)) + .reduce((total, contributor) => total + contributor.ageImpact, 0); + + const excessBenefit = Math.min(0, cardiovascularImpact + 2.8); + const excessRisk = Math.max(0, cardiovascularImpact - 2.4); + + return rawImpact - excessBenefit * 0.22 - excessRisk * 0.18; +} diff --git a/app/vyta/src/engine/recovery.ts b/app/vyta/src/engine/recovery.ts new file mode 100644 index 0000000..5bfe0b3 --- /dev/null +++ b/app/vyta/src/engine/recovery.ts @@ -0,0 +1,41 @@ +type RecoveryInput = { + hrv: number; + hrvBaseline: number; + + restingHeartRate: number; + restingHeartRateBaseline: number; + + sleepScore: number; + + stress: number; + stressBaseline: number; +}; + +export function calculateRecovery(input: RecoveryInput) { + const hrvRatio = + input.hrv / input.hrvBaseline; + + const restingHeartRateRatio = + input.restingHeartRateBaseline / + input.restingHeartRate; + + const sleepRatio = + input.sleepScore / 100; + + const stressRatio = + input.stressBaseline / + Math.max(input.stress, 1); + + const rawScore = + hrvRatio * 35 + + restingHeartRateRatio * 25 + + sleepRatio * 30 + + stressRatio * 10; + + const score = Math.round(rawScore); + + return Math.max( + 0, + Math.min(100, score) + ); +} \ No newline at end of file diff --git a/app/vyta/src/engine/recoveryBreakdown.ts b/app/vyta/src/engine/recoveryBreakdown.ts new file mode 100644 index 0000000..5fb97c8 --- /dev/null +++ b/app/vyta/src/engine/recoveryBreakdown.ts @@ -0,0 +1,90 @@ +export type RecoveryBreakdownItem = { + key: string; + label: string; + value: string; + impact: number; + status: "positive" | "neutral" | "negative"; +}; + +type Input = { + hrv: number; + hrvBaseline: number; + restingHeartRate: number; + restingHeartRateBaseline: number; + sleepScore: number; + sleepBaseline: number; + stress: number; + stressBaseline: number; +}; + +export function getRecoveryBreakdown( + input: Input +): RecoveryBreakdownItem[] { + const hrvDelta = + ((input.hrv - input.hrvBaseline) / + input.hrvBaseline) * + 100; + + const rhrDelta = + ((input.restingHeartRateBaseline - + input.restingHeartRate) / + input.restingHeartRateBaseline) * + 100; + + const sleepDelta = + input.sleepScore - input.sleepBaseline; + + const stressDelta = + input.stressBaseline - input.stress; + + return [ + { + key: "hrv", + label: "HRV", + value: `${input.hrv} ms`, + impact: Math.round(hrvDelta), + status: + hrvDelta > 3 + ? "positive" + : hrvDelta < -3 + ? "negative" + : "neutral", + }, + { + key: "rhr", + label: "Vilopuls", + value: `${input.restingHeartRate} bpm`, + impact: Math.round(rhrDelta), + status: + rhrDelta > 3 + ? "positive" + : rhrDelta < -3 + ? "negative" + : "neutral", + }, + { + key: "sleep", + label: "Sömn", + value: `${input.sleepScore}%`, + impact: Math.round(sleepDelta), + status: + sleepDelta > 3 + ? "positive" + : sleepDelta < -3 + ? "negative" + : "neutral", + }, + { + key: "stress", + label: "Stress", + value: `${input.stress}`, + impact: Math.round(stressDelta), + status: + stressDelta > 3 + ? "positive" + : stressDelta < -3 + ? "negative" + : "neutral", + }, + ]; +} \ No newline at end of file diff --git a/app/vyta/src/engine/strainTarget.ts b/app/vyta/src/engine/strainTarget.ts new file mode 100644 index 0000000..03a74b8 --- /dev/null +++ b/app/vyta/src/engine/strainTarget.ts @@ -0,0 +1,53 @@ +export type StrainTarget = { + min: number; + max: number; + label: string; + message: string; +}; + +export function calculateStrainTarget( + recovery: number +): StrainTarget { + if (recovery >= 85) { + return { + min: 14, + max: 18, + label: "Hög", + message: "Bra dag för hög belastning.", + }; + } + + if (recovery >= 67) { + return { + min: 11, + max: 15, + label: "Medelhög", + message: "Du är redo för en bra träningsdag.", + }; + } + + if (recovery >= 50) { + return { + min: 8, + max: 12, + label: "Måttlig", + message: "Håll träningen balanserad idag.", + }; + } + + if (recovery >= 34) { + return { + min: 6, + max: 9, + label: "Låg", + message: "Prioritera lättare aktivitet.", + }; + } + + return { + min: 3, + max: 6, + label: "Mycket låg", + message: "Fokusera på återhämtning idag.", + }; +} \ No newline at end of file diff --git a/app/vyta/src/engine/vytaAgeRecommendations.ts b/app/vyta/src/engine/vytaAgeRecommendations.ts new file mode 100644 index 0000000..def7ef4 --- /dev/null +++ b/app/vyta/src/engine/vytaAgeRecommendations.ts @@ -0,0 +1,1208 @@ +import type { VytaApiData } from "@/types/vytaApi"; + +export type RecommendationPriority = + | "high" + | "medium" + | "good"; + +export type VytaAgeRecommendation = { + id: string; + title: string; + description: string; + + current: string; + target?: string; + + priority: RecommendationPriority; + + /** + * Negativt värde = möjlig förbättring av Vyta Age. + * + * Exempel: + * -0.6 betyder cirka 0,6 års förbättringspotential. + * + * Detta är en Vyta-modell och inte en medicinsk + * uppskattning av biologisk ålder. + */ + potentialYears: number; +}; + +export type VytaAgeRecommendationsResult = { + recommendations: VytaAgeRecommendation[]; + totalPotentialYears: number; + dataDays: number; +}; + +export function buildVytaAgeRecommendations( + data: VytaApiData +): VytaAgeRecommendationsResult { + const recommendations: VytaAgeRecommendation[] = []; + + /* + * ========================================================= + * DATAUNDERLAG + * ========================================================= + * + * Vi använder upp till 42 senaste dagar med användbar data. + * Det ger ungefär sex veckor och minskar påverkan från + * enstaka dåliga eller ovanligt bra dagar. + */ + + const usefulHistory = data.history.filter( + (day) => + day.heart.hrv !== null || + day.heart.restingHeartRate !== null || + day.sleep.score !== null || + day.activity.steps !== null + ); + + const recent = + usefulHistory.slice(-42); + + /* + * ========================================================= + * VO2MAX + * ========================================================= + * + * Kondition är en av de mest värdefulla långsiktiga + * signalerna vi har från Garmin. + * + * Målet här är individuellt: + * vi vill förbättra nuvarande VO2max snarare än att använda + * ett universellt absolut mål. + */ + + const vo2max = + safeNumber( + data.today.training?.vo2max + ); + + if (vo2max !== null) { + if (vo2max < 42) { + recommendations.push({ + id: "vo2max", + + title: + "Bygg upp konditionen", + + description: + "VO₂max är en stark signal för aerob kapacitet. Regelbunden konditionsträning med både lugna pass och högre intensitet kan förbättra din långsiktiga kapacitet.", + + current: + `${formatOne(vo2max)} ml/kg/min`, + + target: + `${formatOne(vo2max + 4)}+ ml/kg/min`, + + priority: + "high", + + potentialYears: + -0.9, + }); + } else if (vo2max < 50) { + recommendations.push({ + id: "vo2max", + + title: + "Höj VO₂max ytterligare", + + description: + "Din kondition är redan på en bra nivå. Några ytterligare VO₂max-punkter kan ändå vara en av de tydligaste påverkbara signalerna i Vyta Age.", + + current: + `${formatOne(vo2max)} ml/kg/min`, + + target: + `${formatOne(vo2max + 3)}+ ml/kg/min`, + + priority: + "medium", + + potentialYears: + -0.6, + }); + } else { + recommendations.push({ + id: "vo2max", + + title: + "Kondition", + + description: + "Din VO₂max ligger på en stark nivå. Fortsätt träna regelbundet för att behålla kapaciteten.", + + current: + `${formatOne(vo2max)} ml/kg/min`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * INTENSITETSMINUTER + * ========================================================= + */ + + const intensityPerWeek = + calculateWeeklyIntensity( + recent + ); + + if (intensityPerWeek !== null) { + if (intensityPerWeek < 30) { + recommendations.push({ + id: + "intensity-minutes", + + title: + "Öka intensiv aktivitet", + + description: + "Mer regelbunden aktivitet med högre intensitet kan förbättra konditionen och din långsiktiga belastningstolerans.", + + current: + `${Math.round( + intensityPerWeek + )} min/vecka`, + + target: + "45+ min/vecka", + + priority: + "high", + + potentialYears: + -0.7, + }); + } else if ( + intensityPerWeek < 60 + ) { + recommendations.push({ + id: + "intensity-minutes", + + title: + "Fortsätt bygga intensitet", + + description: + "Du får redan in intensiv aktivitet. Lite mer regelbunden högintensiv träning kan ge ytterligare effekt.", + + current: + `${Math.round( + intensityPerWeek + )} min/vecka`, + + target: + "60+ min/vecka", + + priority: + "medium", + + potentialYears: + -0.35, + }); + } else { + recommendations.push({ + id: + "intensity-minutes", + + title: + "Intensiv aktivitet", + + description: + "Din mängd intensiv aktivitet ligger på en stark nivå.", + + current: + `${Math.round( + intensityPerWeek + )} min/vecka`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * AKTIVA DAGAR + * ========================================================= + */ + + const activeDaysPerWeek = + calculateActiveDaysPerWeek( + data, + recent + ); + + if (activeDaysPerWeek !== null) { + if (activeDaysPerWeek < 2) { + recommendations.push({ + id: + "active-days", + + title: + "Öka antal aktiva dagar", + + description: + "En jämn träningsfrekvens ger bättre långsiktig kontinuitet än enstaka hårda träningsdagar.", + + current: + `${formatOne( + activeDaysPerWeek + )} dagar/vecka`, + + target: + "3–4 dagar/vecka", + + priority: + "high", + + potentialYears: + -0.55, + }); + } else if ( + activeDaysPerWeek < 3 + ) { + recommendations.push({ + id: + "active-days", + + title: + "Öka träningsfrekvensen", + + description: + "Du är redan aktiv flera dagar. En extra regelbunden träningsdag kan förbättra kontinuiteten.", + + current: + `${formatOne( + activeDaysPerWeek + )} dagar/vecka`, + + target: + "3–4 dagar/vecka", + + priority: + "medium", + + potentialYears: + -0.3, + }); + } else { + recommendations.push({ + id: + "active-days", + + title: + "Träningsfrekvens", + + description: + "Du har en bra och regelbunden träningsfrekvens.", + + current: + `${formatOne( + activeDaysPerWeek + )} dagar/vecka`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * STEG / VARDAGSAKTIVITET + * ========================================================= + */ + + const averageSteps = + average( + recent.map( + (day) => + day.activity.steps + ) + ); + + if (averageSteps !== null) { + if (averageSteps < 6000) { + const target = + Math.max( + 8000, + Math.round( + averageSteps + 2500 + ) + ); + + recommendations.push({ + id: + "steps", + + title: + "Öka vardagsrörelsen", + + description: + "Din dagliga rörelse är relativt låg. Fler steg är ett enkelt sätt att öka den totala aktivitetsnivån utan att varje dag behöver innehålla ett träningspass.", + + current: + `${Math.round( + averageSteps + ).toLocaleString( + "sv-SE" + )} steg/dag`, + + target: + `${target.toLocaleString( + "sv-SE" + )}+ steg/dag`, + + priority: + "high", + + potentialYears: + -0.45, + }); + } else if ( + averageSteps < 9000 + ) { + recommendations.push({ + id: + "steps", + + title: + "Bygg mer vardagsaktivitet", + + description: + "Din vardagsaktivitet är redan hygglig. Lite mer regelbunden rörelse kan ge ytterligare långsiktig effekt.", + + current: + `${Math.round( + averageSteps + ).toLocaleString( + "sv-SE" + )} steg/dag`, + + target: + "9 000–10 000 steg/dag", + + priority: + "medium", + + potentialYears: + -0.25, + }); + } else { + recommendations.push({ + id: + "steps", + + title: + "Vardagsaktivitet", + + description: + "Din genomsnittliga dagliga rörelse ligger på en stark nivå.", + + current: + `${Math.round( + averageSteps + ).toLocaleString( + "sv-SE" + )} steg/dag`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * SÖMN + * ========================================================= + */ + + const sleepAverage = + average( + recent.map( + (day) => + day.sleep.score + ) + ); + + if (sleepAverage !== null) { + if (sleepAverage < 70) { + recommendations.push({ + id: + "sleep", + + title: + "Prioritera sömnen", + + description: + "Din genomsnittliga sömnscore ligger lågt. Sömn påverkar återhämtning, HRV, vilopuls och förmågan att svara på träning.", + + current: + `Snitt ${Math.round( + sleepAverage + )}`, + + target: + "80+", + + priority: + "high", + + potentialYears: + -0.65, + }); + } else if ( + sleepAverage < 80 + ) { + recommendations.push({ + id: + "sleep", + + title: + "Förbättra sömnkvaliteten", + + description: + "Sömnen är relativt bra men här finns fortfarande tydlig förbättringspotential för återhämtningen.", + + current: + `Snitt ${Math.round( + sleepAverage + )}`, + + target: + "82+", + + priority: + "medium", + + potentialYears: + -0.4, + }); + } else if ( + sleepAverage < 85 + ) { + recommendations.push({ + id: + "sleep", + + title: + "Finjustera sömnen", + + description: + "Du ligger redan bra till. En något jämnare och högre sömnscore kan fortfarande förbättra återhämtningsprofilen.", + + current: + `Snitt ${Math.round( + sleepAverage + )}`, + + target: + "85+", + + priority: + "medium", + + potentialYears: + -0.2, + }); + } else { + recommendations.push({ + id: + "sleep", + + title: + "Sömn", + + description: + "Din genomsnittliga sömnscore ligger på en stark nivå.", + + current: + `Snitt ${Math.round( + sleepAverage + )}`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * VILOPULS + * ========================================================= + * + * Här tittar vi både på dagens värde och på det längre + * genomsnittet. En enstaka dålig natt ska inte väga för tungt. + */ + + const recentRhr = + average( + recent.map( + (day) => + day.heart + .restingHeartRate + ) + ); + + const rhrBaseline = + safeNumber( + data.baselines + .restingHeartRate + ); + + if ( + recentRhr !== null && + rhrBaseline !== null + ) { + const difference = + recentRhr - + rhrBaseline; + + if (difference >= 3) { + recommendations.push({ + id: + "resting-heart-rate", + + title: + "Förbättra vilopulstrenden", + + description: + "Din genomsnittliga vilopuls ligger över din personliga baseline. Kondition, sömn och återhämtning är viktiga faktorer att följa.", + + current: + `${formatOne( + recentRhr + )} bpm`, + + target: + `${formatOne( + rhrBaseline + )} bpm eller lägre`, + + priority: + "high", + + potentialYears: + -0.45, + }); + } else if ( + difference >= 1 + ) { + recommendations.push({ + id: + "resting-heart-rate", + + title: + "Följ vilopulsen", + + description: + "Vilopulsen ligger något över din normala nivå.", + + current: + `${formatOne( + recentRhr + )} bpm`, + + target: + `Baseline ${formatOne( + rhrBaseline + )} bpm`, + + priority: + "medium", + + potentialYears: + -0.2, + }); + } else { + recommendations.push({ + id: + "resting-heart-rate", + + title: + "Vilopuls", + + description: + "Din genomsnittliga vilopuls ligger i nivå med eller bättre än din personliga baseline.", + + current: + `${formatOne( + recentRhr + )} bpm`, + + target: + `Baseline ${formatOne( + rhrBaseline + )} bpm`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * HRV + * ========================================================= + */ + + const recentHrv = + average( + recent.map( + (day) => + day.heart.hrv + ) + ); + + const hrvBaseline = + safeNumber( + data.baselines.hrv + ); + + if ( + recentHrv !== null && + hrvBaseline !== null && + hrvBaseline > 0 + ) { + const ratio = + recentHrv / + hrvBaseline; + + if (ratio < 0.9) { + recommendations.push({ + id: + "hrv", + + title: + "Förbättra återhämtningsbalansen", + + description: + "Din genomsnittliga HRV ligger tydligt under din normala nivå. Sömn, stress och träningsbelastning är särskilt värda att följa.", + + current: + `${formatOne( + recentHrv + )} ms`, + + target: + `Baseline ${formatOne( + hrvBaseline + )} ms`, + + priority: + "high", + + potentialYears: + -0.4, + }); + } else if ( + ratio < 0.97 + ) { + recommendations.push({ + id: + "hrv", + + title: + "Följ HRV-trenden", + + description: + "HRV ligger något under din personliga baseline.", + + current: + `${formatOne( + recentHrv + )} ms`, + + target: + `Baseline ${formatOne( + hrvBaseline + )} ms`, + + priority: + "medium", + + potentialYears: + -0.2, + }); + } else { + recommendations.push({ + id: + "hrv", + + title: + "HRV", + + description: + "Din HRV ligger i nivå med eller över din personliga baseline.", + + current: + `${formatOne( + recentHrv + )} ms`, + + target: + `Baseline ${formatOne( + hrvBaseline + )} ms`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * STRESS + * ========================================================= + */ + + const stressAverage = + average( + recent.map( + (day) => + day.energy.stress + ) + ); + + if (stressAverage !== null) { + if (stressAverage > 40) { + recommendations.push({ + id: + "stress", + + title: + "Sänk vardagsstressen", + + description: + "Din genomsnittliga Garmin-stress ligger relativt högt. Mer återhämtning och bättre balans mellan aktivitet och vila kan förbättra de långsiktiga signalerna.", + + current: + `Snitt ${Math.round( + stressAverage + )}`, + + target: + "Under 30", + + priority: + "high", + + potentialYears: + -0.4, + }); + } else if ( + stressAverage > 30 + ) { + recommendations.push({ + id: + "stress", + + title: + "Förbättra stressbalansen", + + description: + "Stressnivån är måttlig men det finns utrymme att förbättra balansen mellan belastning och återhämtning.", + + current: + `Snitt ${Math.round( + stressAverage + )}`, + + target: + "Under 30", + + priority: + "medium", + + potentialYears: + -0.2, + }); + } else { + recommendations.push({ + id: + "stress", + + title: + "Stressbalans", + + description: + "Din genomsnittliga stressnivå ser bra ut.", + + current: + `Snitt ${Math.round( + stressAverage + )}`, + + priority: + "good", + + potentialYears: + 0, + }); + } + } + + /* + * ========================================================= + * SORTERING + * ========================================================= + * + * Först prioritet. + * + * Inom samma prioritet visas den faktor där modellen ser + * störst förbättringspotential först. + */ + + const order: Record< + RecommendationPriority, + number + > = { + high: 0, + medium: 1, + good: 2, + }; + + recommendations.sort( + (a, b) => { + const priorityDifference = + order[a.priority] - + order[b.priority]; + + if ( + priorityDifference !== 0 + ) { + return priorityDifference; + } + + return ( + a.potentialYears - + b.potentialYears + ); + } + ); + + /* + * ========================================================= + * TOTAL POTENTIAL + * ========================================================= + * + * Vi sätter ett tak så flera delvis överlappande faktorer + * inte ger ett orimligt stort "du kan bli X år yngre". + * + * Exempel: + * VO2max och intensitet påverkar delvis samma fysiologi. + */ + + const rawPotentialYears = + recommendations + .filter( + (item) => + item.potentialYears < 0 + ) + .reduce( + (sum, item) => + sum + + Math.abs( + item.potentialYears + ), + 0 + ); + + const totalPotentialYears = + clamp( + rawPotentialYears, + 0, + 3.5 + ); + + return { + recommendations, + + totalPotentialYears: + roundOne( + totalPotentialYears + ), + + dataDays: + recent.length, + }; +} + +/* + * ========================================================= + * VECKOVIS INTENSITET + * ========================================================= + */ + +function calculateWeeklyIntensity( + history: + VytaApiData["history"] +) { + if ( + history.length === 0 + ) { + return null; + } + + const total = + history.reduce( + (sum, day) => { + const moderate = + day.activity + .moderateIntensityMinutes ?? + 0; + + const vigorous = + day.activity + .vigorousIntensityMinutes ?? + 0; + + /* + * Garmin-princip: + * intensiv aktivitet räknas dubbelt. + */ + + return ( + sum + + moderate + + vigorous * 2 + ); + }, + 0 + ); + + const weeks = + history.length / 7; + + if ( + weeks <= 0 + ) { + return null; + } + + return total / weeks; +} + +/* + * ========================================================= + * AKTIVA DAGAR PER VECKA + * ========================================================= + */ + +function calculateActiveDaysPerWeek( + data: VytaApiData, + history: + VytaApiData["history"] +) { + if ( + history.length === 0 + ) { + return null; + } + + const dates = + new Set( + history.map( + (day) => + day.date + ) + ); + + const activeDates = + new Set( + data.activities + .map( + (activity) => { + if ( + !activity.startTime + ) { + return null; + } + + return ( + activity.startTime.slice( + 0, + 10 + ) + ); + } + ) + .filter( + ( + activityDate + ): activityDate is string => + activityDate !== null && + dates.has( + activityDate + ) + ) + ); + + const weeks = + history.length / 7; + + if ( + weeks <= 0 + ) { + return null; + } + + return ( + activeDates.size / + weeks + ); +} + +/* + * ========================================================= + * AVERAGE + * ========================================================= + */ + +function average( + values: + Array< + number | + null | + undefined + > +) { + const valid = + values.filter( + ( + value + ): value is number => + typeof value === + "number" && + Number.isFinite( + value + ) + ); + + if ( + valid.length === 0 + ) { + return null; + } + + return ( + valid.reduce( + ( + sum, + value + ) => + sum + value, + 0 + ) / + valid.length + ); +} + +/* + * ========================================================= + * SAFE NUMBER + * ========================================================= + */ + +function safeNumber( + value: unknown +): number | null { + if ( + typeof value === + "number" && + Number.isFinite(value) + ) { + return value; + } + + if ( + typeof value === + "string" + ) { + const parsed = + Number(value); + + if ( + Number.isFinite( + parsed + ) + ) { + return parsed; + } + } + + return null; +} + +/* + * ========================================================= + * HELPERS + * ========================================================= + */ + +function clamp( + value: number, + minimum: number, + maximum: number +) { + return Math.max( + minimum, + Math.min( + maximum, + value + ) + ); +} + +function roundOne( + value: number +) { + return ( + Math.round( + value * 10 + ) / 10 + ); +} + +function formatOne( + value: number +) { + return value + .toFixed(1) + .replace( + ".", + "," + ); +} \ No newline at end of file diff --git a/app/vyta/src/lib/auth.ts b/app/vyta/src/lib/auth.ts new file mode 100644 index 0000000..d6d99a8 --- /dev/null +++ b/app/vyta/src/lib/auth.ts @@ -0,0 +1,78 @@ +import { + SignJWT, + jwtVerify, +} from "jose"; + +import { + cookies, +} from "next/headers"; + +export const SESSION_COOKIE = + "vyta_session"; + +export const SESSION_MAX_AGE = + 60 * 60 * 24 * 30; + +function getSessionSecret() { + const secret = + process.env.VYTA_SESSION_SECRET; + + if (!secret) { + throw new Error( + "VYTA_SESSION_SECRET saknas" + ); + } + + return new TextEncoder().encode( + secret + ); +} + +export async function createSessionToken( + username: string +) { + return new SignJWT({ + username, + }) + .setProtectedHeader({ + alg: "HS256", + }) + .setIssuedAt() + .setExpirationTime("30d") + .sign( + getSessionSecret() + ); +} + +export async function verifySessionToken( + token?: string +) { + if (!token) { + return false; + } + + try { + await jwtVerify( + token, + getSessionSecret() + ); + + return true; + } catch { + return false; + } +} + +export async function isAuthenticated() { + const cookieStore = + await cookies(); + + const token = + cookieStore.get( + SESSION_COOKIE + )?.value; + + return verifySessionToken( + token + ); +} \ No newline at end of file diff --git a/app/vyta/src/lib/energy.ts b/app/vyta/src/lib/energy.ts new file mode 100644 index 0000000..736ebd5 --- /dev/null +++ b/app/vyta/src/lib/energy.ts @@ -0,0 +1,346 @@ +export type EnergyDay = { + date: string; + + activity?: { + totalCalories?: + | number + | null; + }; +}; + +export type VytaTdeeResult = { + tdee: number | null; + + daysUsed: number; + + periodDays: number; + + confidence: + | "insufficient" + | "preliminary" + | "good" + | "stable"; + + dailyValues: Array<{ + date: string; + calories: number; + }>; +}; + +export function calculateBmi( + weightKg: number | null, + heightCm: number | null +) { + if ( + weightKg === null || + heightCm === null || + weightKg <= 0 || + heightCm <= 0 + ) { + return null; + } + + const heightMeters = + heightCm / 100; + + return roundOne( + weightKg / + ( + heightMeters * + heightMeters + ) + ); +} + +export function getBmiLabel( + bmi: number | null +) { + if (bmi === null) { + return null; + } + + if (bmi < 18.5) { + return "Undervikt"; + } + + if (bmi < 25) { + return "Normalvikt"; + } + + if (bmi < 30) { + return "Övervikt"; + } + + return "Fetma"; +} + +export function calculateVytaTdee( + history: EnergyDay[], + latestDataDate: string, + periodDays = 28 +): VytaTdeeResult { + /* + * Exkludera aktuell Garmin-dag. + * + * Om klockan är 10 på morgonen kanske + * dagens kalorier bara hunnit bli ~900. + * Den dagen får därför inte dra ner TDEE. + */ + const completeDays = + history + .filter( + (day) => + day.date !== + latestDataDate + ) + .sort( + (a, b) => + a.date.localeCompare( + b.date + ) + ) + .slice( + -periodDays + ); + + const valid = + completeDays + .map( + (day) => { + const calories = + day.activity + ?.totalCalories; + + if ( + typeof calories !== + "number" || + !Number.isFinite( + calories + ) || + calories <= 0 + ) { + return null; + } + + return { + date: + day.date, + + calories, + }; + } + ) + .filter( + ( + item + ): item is { + date: string; + calories: number; + } => + item !== null + ); + + if ( + valid.length < 7 + ) { + return { + tdee: null, + + daysUsed: + valid.length, + + periodDays, + + confidence: + "insufficient", + + dailyValues: + valid, + }; + } + + /* + * Medianbaserad outlier-filtrering. + * + * Garmin kan ibland få en konstig dag, + * exempelvis om klockan inte bars hela + * dagen eller synken varit ofullständig. + */ + const values = + valid.map( + (item) => + item.calories + ); + + const median = + calculateMedian( + values + ); + + const filtered = + valid.filter( + (item) => { + if ( + median === null + ) { + return true; + } + + return ( + item.calories >= + median * 0.6 && + item.calories <= + median * 1.4 + ); + } + ); + + /* + * Om filtreringen skulle göra datamängden + * för liten faller vi tillbaka på originalet. + */ + const source = + filtered.length >= 7 + ? filtered + : valid; + + const average = + source.reduce( + ( + sum, + item + ) => + sum + + item.calories, + 0 + ) / + source.length; + + const daysUsed = + source.length; + + let confidence: + VytaTdeeResult["confidence"]; + + if ( + daysUsed >= 28 + ) { + confidence = + "stable"; + } else if ( + daysUsed >= 14 + ) { + confidence = + "good"; + } else { + confidence = + "preliminary"; + } + + return { + tdee: + Math.round( + average + ), + + daysUsed, + + periodDays, + + confidence, + + dailyValues: + source, + }; +} + +export function calculatePlannedDeficit( + tdee: number | null, + calorieTarget: number | null +) { + if ( + tdee === null || + calorieTarget === null + ) { + return null; + } + + return ( + tdee - + calorieTarget + ); +} + +export function getTdeeConfidenceLabel( + confidence: + VytaTdeeResult["confidence"] +) { + switch ( + confidence + ) { + case "stable": + return "Stabilt underlag"; + + case "good": + return "Bra underlag"; + + case "preliminary": + return "Preliminärt"; + + default: + return "För lite data"; + } +} + +function calculateMedian( + values: number[] +) { + if ( + values.length === 0 + ) { + return null; + } + + const sorted = + [...values].sort( + (a, b) => + a - b + ); + + const middle = + Math.floor( + sorted.length / + 2 + ); + + if ( + sorted.length % 2 === + 0 + ) { + return ( + ( + sorted[ + middle - 1 + ] + + sorted[ + middle + ] + ) / + 2 + ); + } + + return sorted[ + middle + ]; +} + +function roundOne( + value: number +) { + return ( + Math.round( + value * 10 + ) / 10 + ); +} \ No newline at end of file diff --git a/app/vyta/src/lib/format.ts b/app/vyta/src/lib/format.ts new file mode 100644 index 0000000..d12d752 --- /dev/null +++ b/app/vyta/src/lib/format.ts @@ -0,0 +1,14 @@ +export function formatDecimal(value: number) { + return value.toFixed(1).replace(".", ","); +} + +export function formatMinutes(minutes: number) { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (hours === 0) { + return `${mins} min`; + } + + return `${hours} h ${mins} min`; +} \ No newline at end of file diff --git a/app/vyta/src/lib/garminConnection.ts b/app/vyta/src/lib/garminConnection.ts new file mode 100755 index 0000000..0e52ca7 --- /dev/null +++ b/app/vyta/src/lib/garminConnection.ts @@ -0,0 +1,101 @@ +import fs from "fs/promises"; +import path from "path"; + +export type GarminConnectionStatus = { + connected: boolean; + email: string | null; + updatedAt: string | null; +}; + +type GarminCredentialsFile = { + email?: unknown; + password?: unknown; + updatedAt?: unknown; +}; + +function getCredentialsPath() { + return ( + process.env.VYTA_GARMIN_CREDENTIALS_PATH || + process.env.GARMIN_CREDENTIALS_PATH || + "/data/garmin-credentials.json" + ); +} + +export async function getGarminConnectionStatus(): Promise { + try { + const raw = await fs.readFile(getCredentialsPath(), "utf-8"); + const parsed = JSON.parse(raw) as GarminCredentialsFile; + const email = typeof parsed.email === "string" ? parsed.email : null; + const password = typeof parsed.password === "string" ? parsed.password : null; + + return { + connected: Boolean(email && password), + email, + updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : null, + }; + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ) { + return { + connected: false, + email: null, + updatedAt: null, + }; + } + + console.error("Kunde inte läsa Garmin-koppling:", error); + + return { + connected: false, + email: null, + updatedAt: null, + }; + } +} + +export async function saveGarminCredentials(email: string, password: string) { + const filePath = getCredentialsPath(); + + await fs.mkdir(path.dirname(filePath), { + recursive: true, + }); + + const temporaryPath = `${filePath}.tmp`; + + await fs.writeFile( + temporaryPath, + JSON.stringify( + { + email, + password, + updatedAt: new Date().toISOString(), + }, + null, + 2 + ), + { + encoding: "utf-8", + mode: 0o600, + } + ); + + await fs.rename(temporaryPath, filePath); + + return getGarminConnectionStatus(); +} + +export async function deleteGarminCredentials() { + try { + await fs.rm(getCredentialsPath(), { + force: true, + }); + } catch (error) { + console.error("Kunde inte ta bort Garmin-koppling:", error); + } + + return getGarminConnectionStatus(); +} diff --git a/app/vyta/src/lib/getVytaData.ts b/app/vyta/src/lib/getVytaData.ts new file mode 100644 index 0000000..60d15b9 --- /dev/null +++ b/app/vyta/src/lib/getVytaData.ts @@ -0,0 +1,43 @@ +import fs from "fs/promises"; +import path from "path"; + +import type { + VytaApiData, +} from "@/types/vytaApi"; + +function getDataPath() { + return ( + process.env.VYTA_DATA_PATH || + path.join( + process.cwd(), + "private-data", + "vyta-data.json" + ) + ); +} + +export async function getVytaData(): Promise { + const filePath = + getDataPath(); + + try { + const raw = + await fs.readFile( + filePath, + "utf-8" + ); + + return JSON.parse( + raw + ) as VytaApiData; + } catch (error) { + console.error( + `Kunde inte läsa VYTA-data från ${filePath}:`, + error + ); + + throw new Error( + `VYTA-data kunde inte läsas från ${filePath}` + ); + } +} diff --git a/app/vyta/src/lib/notificationEngine.ts b/app/vyta/src/lib/notificationEngine.ts new file mode 100644 index 0000000..9b6ea86 --- /dev/null +++ b/app/vyta/src/lib/notificationEngine.ts @@ -0,0 +1,480 @@ +import fs from "fs/promises"; +import path from "path"; + +import { getVytaData } from "@/lib/getVytaData"; +import { calculateVytaIntelligence } from "@/lib/vytaIntelligence"; +import { getVytaSettings } from "@/lib/vytaSettings"; +import { sendPush } from "@/lib/sendPush"; + +type NotificationState = { + sentKeys: string[]; + knownActivityIds: string[]; + initializedActivities: boolean; + + lastVo2max: number | null; + lastRecommendationLevel: string | null; + + updatedAt: string | null; +}; + +const DEFAULT_STATE: NotificationState = { + sentKeys: [], + knownActivityIds: [], + initializedActivities: false, + + lastVo2max: null, + lastRecommendationLevel: null, + + updatedAt: null, +}; + +const STATE_PATH = process.env.VYTA_NOTIFICATION_STATE_PATH || "/data/notification-state.json"; + +export async function evaluateNotifications() { + const [data, settings, state] = await Promise.all([ + getVytaData(), + getVytaSettings(), + readState(), + ]); + + if (!settings.notifications.enabled) { + return { + ok: true, + skipped: "notifications-disabled", + sent: 0, + }; + } + + const today = data.today; + const date = today.date; + + const healthMessages: string[] = []; + const newSentKeys: string[] = []; + + const addDaily = (key: string, message: string) => { + const fullKey = `${key}:${date}`; + + if (state.sentKeys.includes(fullKey)) { + return; + } + + healthMessages.push(message); + newSentKeys.push(fullKey); + }; + + /* + * ======================================================== + * SÖMN + * ======================================================== + */ + + if ( + settings.notifications.sleepDebt && + today.sleep.debtMinutes !== null && + today.sleep.debtMinutes >= 120 + ) { + addDaily("sleep-debt", `Sömnskuld ${formatMinutes(today.sleep.debtMinutes)}`); + } + + if ( + settings.notifications.lowSleepScore && + today.sleep.score !== null && + today.sleep.score < 65 + ) { + addDaily("low-sleep", `Sömnscore ${Math.round(today.sleep.score)}`); + } + + /* + * ======================================================== + * RECOVERY + * ======================================================== + */ + + if (settings.notifications.recovery && today.recovery !== null && today.recovery < 40) { + addDaily("low-recovery", `Recovery endast ${Math.round(today.recovery)} %`); + } + + /* + * ======================================================== + * HRV + * ======================================================== + */ + + const hrvDelta = percentDelta(today.heart.hrv, data.baselines.hrv); + + if (settings.notifications.hrvDeviation && hrvDelta !== null && hrvDelta <= -10) { + addDaily("hrv-low", `HRV ${Math.abs(hrvDelta)} % under baseline`); + } + + /* + * ======================================================== + * VILOPULS + * ======================================================== + */ + + const rhrDelta = difference(today.heart.restingHeartRate, data.baselines.restingHeartRate); + + if (settings.notifications.elevatedRestingHeartRate && rhrDelta !== null && rhrDelta >= 5) { + addDaily("rhr-high", `Vilopuls +${formatNumber(rhrDelta)} bpm mot baseline`); + } + + /* + * ======================================================== + * BODY BATTERY + * ======================================================== + */ + + if ( + settings.notifications.lowBodyBattery && + today.energy.bodyBattery !== null && + today.energy.bodyBattery <= 20 + ) { + addDaily("body-battery-low", `Body Battery nere på ${Math.round(today.energy.bodyBattery)}`); + } + + /* + * ======================================================== + * STRESS + * ======================================================== + */ + + const stressDelta = difference(today.energy.stress, data.baselines.stress); + + if ( + settings.notifications.highStress && + today.energy.stress !== null && + stressDelta !== null && + today.energy.stress >= 35 && + stressDelta >= 10 + ) { + addDaily("stress-high", `Stress ${Math.round(today.energy.stress)}, tydligt över normalt`); + } + + /* + * ======================================================== + * STEGMÅL + * ======================================================== + */ + + if ( + settings.notifications.stepGoalReached && + settings.stepGoal !== null && + today.activity.steps !== null && + today.activity.steps >= settings.stepGoal + ) { + addDaily( + "step-goal", + `${Math.round(today.activity.steps).toLocaleString("sv-SE")} steg – dagens mål är nått` + ); + } + + /* + * ======================================================== + * VYTA REKOMMENDATION + * ======================================================== + */ + + const intelligence = calculateVytaIntelligence({ + recovery: today.recovery, + strain: today.strain, + + hrv: today.heart.hrv, + hrvBaseline: data.baselines.hrv, + + restingHeartRate: today.heart.restingHeartRate, + + restingHeartRateBaseline: data.baselines.restingHeartRate, + + sleepScore: today.sleep.score, + sleepBaseline: data.baselines.sleepScore, + + stress: today.energy.stress, + stressBaseline: data.baselines.stress, + }); + + if ( + settings.notifications.newRecommendation && + state.lastRecommendationLevel !== null && + intelligence.status.level !== state.lastRecommendationLevel + ) { + const key = `recommendation:${date}:${intelligence.status.level}`; + + if (!state.sentKeys.includes(key)) { + healthMessages.push(intelligence.status.title); + + newSentKeys.push(key); + } + } + + /* + * ======================================================== + * VO2 MAX + * ======================================================== + */ + + const vo2max = typeof today.training.vo2max === "number" ? today.training.vo2max : null; + + if ( + settings.notifications.vo2maxChanged && + state.lastVo2max !== null && + vo2max !== null && + Math.abs(vo2max - state.lastVo2max) >= 0.5 + ) { + const key = `vo2max:${date}:${vo2max}`; + + if (!state.sentKeys.includes(key)) { + healthMessages.push(`VO₂max är nu ${formatNumber(vo2max)}`); + + newSentKeys.push(key); + } + } + + /* + * ======================================================== + * NYA AKTIVITETER + * + * Första körningen registrerar bara befintliga aktiviteter. + * Vi vill inte skicka 17 gamla aktiviteter på en gång. + * ======================================================== + */ + + const activityIds = data.activities + .map((activity) => activity.id) + .filter((id): id is string => typeof id === "string" && id.length > 0); + + let newActivities = data.activities.filter( + (activity) => typeof activity.id === "string" && !state.knownActivityIds.includes(activity.id) + ); + + if (!state.initializedActivities) { + newActivities = []; + } + + /* + * ======================================================== + * STÖR EJ + * ======================================================== + */ + + const quiet = + settings.notifications.quietHoursEnabled && + isQuietHours(settings.notifications.quietHoursStart, settings.notifications.quietHoursEnd); + + let sent = 0; + + /* + * ======================================================== + * HEALTH DIGEST + * + * Max en hälsopush per evaluation. + * ======================================================== + */ + + if (healthMessages.length > 0 && !quiet) { + const body = healthMessages.slice(0, 3).join(" · "); + + await sendPush({ + title: healthMessages.length === 1 ? "VYTA" : `VYTA · ${healthMessages.length} nya signaler`, + + body, + + url: "/", + + tag: `vyta-health-${date}`, + }); + + sent += 1; + + state.sentKeys.push(...newSentKeys); + } + + /* + * ======================================================== + * NY AKTIVITET + * ======================================================== + */ + + if (settings.notifications.newActivity && newActivities.length > 0 && !quiet) { + const latest = newActivities[newActivities.length - 1]; + + const name = latest.name || latest.type || "Garmin-aktivitet"; + + await sendPush({ + title: "Ny aktivitet synkad", + + body: newActivities.length === 1 ? name : `${newActivities.length} nya Garmin-aktiviteter`, + + url: "/traning", + + tag: `vyta-activity-${latest.id}`, + }); + + sent += 1; + } + + /* + * Vi registrerar aktiviteter även under quiet hours. + * + * Aktivitetspush är "instant"-karaktär och ska inte + * dyka upp många timmar senare. + * + * Hälsovarningar däremot lämnas oskickade så att de + * kan skickas efter Stör ej-perioden. + */ + state.knownActivityIds = activityIds.slice(-200); + + state.initializedActivities = true; + + state.lastVo2max = vo2max; + + state.lastRecommendationLevel = intelligence.status.level; + + state.sentKeys = state.sentKeys.slice(-500); + + state.updatedAt = new Date().toISOString(); + + await writeState(state); + + return { + ok: true, + + quietHours: quiet, + + candidates: healthMessages.length + newActivities.length, + + healthSignals: healthMessages, + + newActivities: newActivities.length, + + sent, + }; +} + +async function readState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, "utf-8"); + + const parsed = JSON.parse(raw); + + return { + ...DEFAULT_STATE, + ...parsed, + + sentKeys: Array.isArray(parsed.sentKeys) ? parsed.sentKeys : [], + + knownActivityIds: Array.isArray(parsed.knownActivityIds) ? parsed.knownActivityIds : [], + }; + } catch { + return { + ...DEFAULT_STATE, + }; + } +} + +async function writeState(state: NotificationState) { + await fs.mkdir(path.dirname(STATE_PATH), { + recursive: true, + }); + + const tmp = `${STATE_PATH}.tmp`; + + await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8"); + + await fs.rename(tmp, STATE_PATH); +} + +function percentDelta(value: number | null | undefined, baseline: number | null | undefined) { + if (typeof value !== "number" || typeof baseline !== "number" || baseline === 0) { + return null; + } + + return Math.round(((value - baseline) / baseline) * 100); +} + +function difference(value: number | null | undefined, baseline: number | null | undefined) { + if (typeof value !== "number" || typeof baseline !== "number") { + return null; + } + + return Math.round((value - baseline) * 10) / 10; +} + +function formatNumber(value: number) { + return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(".", ","); +} + +function formatMinutes(minutes: number) { + const rounded = Math.round(minutes); + + const hours = Math.floor(rounded / 60); + + const rest = rounded % 60; + + if (hours === 0) { + return `${rest} min`; + } + + if (rest === 0) { + return `${hours} h`; + } + + return `${hours} h ${rest} min`; +} + +function isQuietHours(start: string, end: string) { + const now = getStockholmMinutes(); + + const startMinutes = parseClock(start); + + const endMinutes = parseClock(end); + + if (startMinutes === null || endMinutes === null) { + return false; + } + + if (startMinutes === endMinutes) { + return false; + } + + if (startMinutes < endMinutes) { + return now >= startMinutes && now < endMinutes; + } + + return now >= startMinutes || now < endMinutes; +} + +function getStockholmMinutes() { + const parts = new Intl.DateTimeFormat("sv-SE", { + timeZone: "Europe/Stockholm", + + hour: "2-digit", + + minute: "2-digit", + + hourCycle: "h23", + }).formatToParts(new Date()); + + const hour = Number(parts.find((part) => part.type === "hour")?.value); + + const minute = Number(parts.find((part) => part.type === "minute")?.value); + + return hour * 60 + minute; +} + +function parseClock(value: string) { + const match = /^(\d{2}):(\d{2})$/.exec(value); + + if (!match) { + return null; + } + + const hours = Number(match[1]); + + const minutes = Number(match[2]); + + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + return null; + } + + return hours * 60 + minutes; +} diff --git a/app/vyta/src/lib/pocketId.ts b/app/vyta/src/lib/pocketId.ts new file mode 100644 index 0000000..842f42a --- /dev/null +++ b/app/vyta/src/lib/pocketId.ts @@ -0,0 +1,56 @@ +import * as oidc from "openid-client"; + +let cachedConfig: + oidc.Configuration | + null = + null; + +export async function getPocketIdConfig() { + if (cachedConfig) { + return cachedConfig; + } + + const issuer = + process.env.POCKET_ID_ISSUER; + + const clientId = + process.env.POCKET_ID_CLIENT_ID; + + const clientSecret = + process.env.POCKET_ID_CLIENT_SECRET; + + if ( + !issuer || + !clientId || + !clientSecret + ) { + throw new Error( + "Pocket ID-konfiguration saknas" + ); + } + + cachedConfig = + await oidc.discovery( + new URL(issuer), + clientId, + clientSecret + ); + + return cachedConfig; +} + +export function getPocketIdRedirectUri() { + const publicUrl = + process.env.VYTA_PUBLIC_URL; + + if (!publicUrl) { + throw new Error( + "VYTA_PUBLIC_URL saknas" + ); + } + + return new URL( + "/api/auth/callback/pocketid", + publicUrl + ).toString(); +} diff --git a/app/vyta/src/lib/pushSubscriptions.ts b/app/vyta/src/lib/pushSubscriptions.ts new file mode 100644 index 0000000..ef51d91 --- /dev/null +++ b/app/vyta/src/lib/pushSubscriptions.ts @@ -0,0 +1,163 @@ +import fs from "fs/promises"; +import path from "path"; + +import type { + PushSubscription, +} from "web-push"; + +type StoredPushSubscription = + PushSubscription & { + createdAt: string; + }; + +function getStoragePath() { + return ( + process.env + .VYTA_PUSH_SUBSCRIPTIONS_PATH || + path.join( + process.cwd(), + "private-data", + "push-subscriptions.json" + ) + ); +} + +async function readSubscriptions(): Promise< + StoredPushSubscription[] +> { + const filePath = + getStoragePath(); + + try { + const raw = + await fs.readFile( + filePath, + "utf-8" + ); + + const parsed = + JSON.parse( + raw + ); + + return Array.isArray( + parsed + ) + ? parsed + : []; + } catch ( + error: unknown + ) { + if ( + typeof error === + "object" && + error !== null && + "code" in error && + ( + error as { + code?: string; + } + ).code === "ENOENT" + ) { + return []; + } + + console.error( + "Kunde inte läsa push-subscriptions:", + error + ); + + return []; + } +} + +async function writeSubscriptions( + subscriptions: + StoredPushSubscription[] +) { + const filePath = + getStoragePath(); + + const directory = + path.dirname( + filePath + ); + + await fs.mkdir( + directory, + { + recursive: true, + } + ); + + const temporaryPath = + `${filePath}.tmp`; + + await fs.writeFile( + temporaryPath, + JSON.stringify( + subscriptions, + null, + 2 + ), + "utf-8" + ); + + await fs.rename( + temporaryPath, + filePath + ); +} + +export async function savePushSubscription( + subscription: + PushSubscription +) { + const subscriptions = + await readSubscriptions(); + + const filtered = + subscriptions.filter( + ( + existing + ) => + existing.endpoint !== + subscription.endpoint + ); + + filtered.push({ + ...subscription, + + createdAt: + new Date() + .toISOString(), + }); + + await writeSubscriptions( + filtered + ); +} + +export async function getPushSubscriptions() { + return readSubscriptions(); +} + +export async function removePushSubscription( + endpoint: string +) { + const subscriptions = + await readSubscriptions(); + + const filtered = + subscriptions.filter( + ( + subscription + ) => + subscription.endpoint !== + endpoint + ); + + await writeSubscriptions( + filtered + ); +} diff --git a/app/vyta/src/lib/sendPush.ts b/app/vyta/src/lib/sendPush.ts new file mode 100644 index 0000000..64770eb --- /dev/null +++ b/app/vyta/src/lib/sendPush.ts @@ -0,0 +1,169 @@ +import webpush from "web-push"; + +import { + getPushSubscriptions, + removePushSubscription, +} from "@/lib/pushSubscriptions"; + +type VytaPushPayload = { + title: string; + body: string; + url?: string; + tag?: string; +}; + +function configureWebPush() { + const publicKey = + process.env + .NEXT_PUBLIC_VAPID_PUBLIC_KEY; + + const privateKey = + process.env + .VAPID_PRIVATE_KEY; + + const subject = + process.env + .VAPID_SUBJECT; + + if ( + !publicKey || + !privateKey || + !subject + ) { + throw new Error( + "VAPID är inte konfigurerat" + ); + } + + webpush.setVapidDetails( + subject, + publicKey, + privateKey + ); +} + +export async function sendPush( + payload: VytaPushPayload +) { + configureWebPush(); + + const subscriptions = + await getPushSubscriptions(); + + console.log( + "Push subscriptions:", + subscriptions.length + ); + + if ( + subscriptions.length === + 0 + ) { + throw new Error( + "Ingen push-subscription finns registrerad" + ); + } + + let sent = 0; + + const errors: unknown[] = + []; + + for ( + const subscription + of subscriptions + ) { + try { + const result = + await webpush.sendNotification( + subscription, + JSON.stringify( + payload + ), + { + TTL: 3600, + urgency: + "normal", + } + ); + + console.log( + "Push sent:", + result.statusCode + ); + + sent += 1; + } catch ( + error: unknown + ) { + console.error( + "Push send failed:", + error + ); + + const statusCode = + getStatusCode( + error + ); + + if ( + statusCode === 404 || + statusCode === 410 + ) { + await removePushSubscription( + subscription.endpoint + ); + } + + errors.push( + error + ); + } + } + + if ( + sent === 0 && + errors.length > 0 + ) { + throw errors[0]; + } + + return { + total: + subscriptions.length, + sent, + failed: + errors.length, + }; +} + +function getStatusCode( + error: unknown +) { + if ( + typeof error !== + "object" || + error === null || + !( + "statusCode" in + error + ) + ) { + return null; + } + + const statusCode = + ( + error as { + statusCode?: + unknown; + } + ).statusCode; + + return ( + typeof statusCode === + "number" + ? statusCode + : null + ); +} \ No newline at end of file diff --git a/app/vyta/src/lib/strainScale.ts b/app/vyta/src/lib/strainScale.ts new file mode 100644 index 0000000..04f3ea6 --- /dev/null +++ b/app/vyta/src/lib/strainScale.ts @@ -0,0 +1,49 @@ +export type StrainScale = "21" | "100"; + +export function convertStrain( + strain21: number | null | undefined, + scale: StrainScale +): number | null { + if ( + strain21 === null || + strain21 === undefined || + !Number.isFinite(strain21) + ) { + return null; + } + + if (scale === "21") { + return Math.round(strain21 * 10) / 10; + } + + return Math.round((strain21 / 21) * 100); +} + +export function formatStrain( + strain21: number | null | undefined, + scale: StrainScale +): string { + const value = convertStrain(strain21, scale); + + if (value === null) { + return "—"; + } + + if (scale === "21") { + return value.toFixed(1).replace(".", ","); + } + + return String(value); +} + +export function getStrainMax( + scale: StrainScale +): number { + return scale === "100" ? 100 : 21; +} + +export function getStrainSuffix( + scale: StrainScale +): string { + return scale === "100" ? "/100" : "/21"; +} diff --git a/app/vyta/src/lib/vytaIntelligence.ts b/app/vyta/src/lib/vytaIntelligence.ts new file mode 100644 index 0000000..845e8ee --- /dev/null +++ b/app/vyta/src/lib/vytaIntelligence.ts @@ -0,0 +1,318 @@ +export type NullableNumber = number | null | undefined; + +export type RecoveryContributors = { + hrv: { + score: number; + deltaPercent: number | null; + label: string; + }; + + restingHeartRate: { + score: number; + delta: number | null; + label: string; + }; + + sleep: { + score: number; + delta: number | null; + label: string; + }; + + stress: { + score: number; + delta: number | null; + label: string; + }; +}; + +export type DailyStatus = { + level: "great" | "good" | "moderate" | "low"; + title: string; + text: string; +}; + +export type StrainRecommendation = { + min: number; + max: number; + label: string; +}; + +type IntelligenceInput = { + recovery: NullableNumber; + + strain: NullableNumber; + + hrv: NullableNumber; + hrvBaseline: NullableNumber; + + restingHeartRate: NullableNumber; + restingHeartRateBaseline: NullableNumber; + + sleepScore: NullableNumber; + sleepBaseline: NullableNumber; + + stress: NullableNumber; + stressBaseline: NullableNumber; +}; + +export function calculateVytaIntelligence(input: IntelligenceInput) { + const contributors = calculateRecoveryContributors(input); + + const status = calculateDailyStatus({ + recovery: input.recovery, + strain: input.strain, + contributors, + }); + + const strainRecommendation = calculateRecommendedStrain(input.recovery); + + return { + contributors, + status, + strainRecommendation, + }; +} + +export function calculateRecommendedStrain(recovery: NullableNumber): StrainRecommendation { + const value = safeNumber(recovery); + + if (value === null) { + return { + min: 6, + max: 10, + label: "Balanserad", + }; + } + + if (value >= 85) { + return { + min: 13, + max: 17, + label: "Hög belastning möjlig", + }; + } + + if (value >= 70) { + return { + min: 11, + max: 15, + label: "Bra träningsläge", + }; + } + + if (value >= 50) { + return { + min: 8, + max: 12, + label: "Måttlig belastning", + }; + } + + if (value >= 35) { + return { + min: 5, + max: 9, + label: "Lugnare dag", + }; + } + + return { + min: 2, + max: 6, + label: "Prioritera återhämtning", + }; +} + +function calculateDailyStatus({ + recovery, + strain, + contributors, +}: { + recovery: NullableNumber; + strain: NullableNumber; + contributors: RecoveryContributors; +}): DailyStatus { + const recoveryValue = safeNumber(recovery) ?? 0; + + const strainValue = safeNumber(strain) ?? 0; + + const negativeSignals = [ + contributors.hrv.score, + contributors.restingHeartRate.score, + contributors.sleep.score, + contributors.stress.score, + ].filter((value) => value < 0).length; + + if (recoveryValue >= 80 && strainValue < 10 && negativeSignals <= 1) { + return { + level: "great", + title: "Redo att prestera", + text: "Din återhämtning är stark och dagens belastning är fortfarande relativt låg.", + }; + } + + if (recoveryValue >= 65 && negativeSignals <= 2) { + return { + level: "good", + title: "Bra dag att bygga", + text: "Kroppens signaler ser stabila ut. Ett måttligt till hårt pass passar bra idag.", + }; + } + + if (recoveryValue >= 45) { + return { + level: "moderate", + title: "Balanserad dag", + text: "Återhämtningen är måttlig. Anpassa belastningen efter hur kroppen känns under dagen.", + }; + } + + return { + level: "low", + title: "Prioritera återhämtning", + text: "Flera signaler pekar mot lägre återhämtning. Håll dagens belastning låg.", + }; +} + +function calculateRecoveryContributors(input: IntelligenceInput): RecoveryContributors { + const hrvDeltaPercent = percentDelta(input.hrv, input.hrvBaseline); + + const rhrDelta = absoluteDelta(input.restingHeartRate, input.restingHeartRateBaseline); + + const sleepDelta = absoluteDelta(input.sleepScore, input.sleepBaseline); + + const stressDelta = absoluteDelta(input.stress, input.stressBaseline); + + return { + hrv: { + score: + hrvDeltaPercent === null + ? 0 + : hrvDeltaPercent >= 8 + ? 2 + : hrvDeltaPercent >= 2 + ? 1 + : hrvDeltaPercent <= -10 + ? -2 + : hrvDeltaPercent <= -4 + ? -1 + : 0, + + deltaPercent: hrvDeltaPercent, + + label: formatPercentDelta(hrvDeltaPercent), + }, + + restingHeartRate: { + score: + rhrDelta === null + ? 0 + : rhrDelta <= -3 + ? 2 + : rhrDelta <= -1 + ? 1 + : rhrDelta >= 5 + ? -2 + : rhrDelta >= 2 + ? -1 + : 0, + + delta: rhrDelta, + + label: formatAbsoluteDelta(rhrDelta, " bpm"), + }, + + sleep: { + score: + sleepDelta === null + ? 0 + : sleepDelta >= 8 + ? 2 + : sleepDelta >= 3 + ? 1 + : sleepDelta <= -12 + ? -2 + : sleepDelta <= -5 + ? -1 + : 0, + + delta: sleepDelta, + + label: formatAbsoluteDelta(sleepDelta), + }, + + stress: { + score: + stressDelta === null + ? 0 + : stressDelta <= -8 + ? 2 + : stressDelta <= -3 + ? 1 + : stressDelta >= 10 + ? -2 + : stressDelta >= 5 + ? -1 + : 0, + + delta: stressDelta, + + label: formatAbsoluteDelta(stressDelta), + }, + }; +} + +function percentDelta(value: NullableNumber, baseline: NullableNumber) { + const current = safeNumber(value); + + const reference = safeNumber(baseline); + + if (current === null || reference === null || reference === 0) { + return null; + } + + return Math.round(((current - reference) / reference) * 100); +} + +function absoluteDelta(value: NullableNumber, baseline: NullableNumber) { + const current = safeNumber(value); + + const reference = safeNumber(baseline); + + if (current === null || reference === null) { + return null; + } + + return Math.round((current - reference) * 10) / 10; +} + +function formatPercentDelta(value: number | null) { + if (value === null) { + return "—"; + } + + if (value > 0) { + return `+${value} %`; + } + + return `${value} %`; +} + +function formatAbsoluteDelta(value: number | null, suffix = "") { + if (value === null) { + return "—"; + } + + const formatted = Number.isInteger(value) ? String(value) : value.toFixed(1).replace(".", ","); + + if (value > 0) { + return `+${formatted}${suffix}`; + } + + return `${formatted}${suffix}`; +} + +function safeNumber(value: NullableNumber): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/app/vyta/src/lib/vytaSettings.ts b/app/vyta/src/lib/vytaSettings.ts new file mode 100644 index 0000000..f886649 --- /dev/null +++ b/app/vyta/src/lib/vytaSettings.ts @@ -0,0 +1,421 @@ +import fs from "fs/promises"; +import path from "path"; + +export type WeightEntry = { + date: string; + weightKg: number; + recordedAt: string; +}; + +export type NotificationSettings = { + enabled: boolean; + + sleepSummary: boolean; + lowSleepScore: boolean; + sleepDebt: boolean; + + recovery: boolean; + hrvDeviation: boolean; + elevatedRestingHeartRate: boolean; + lowBodyBattery: boolean; + highStress: boolean; + + newActivity: boolean; + stepGoalReached: boolean; + intensityGoalReached: boolean; + vo2maxChanged: boolean; + + newRecommendation: boolean; + vytaAgeChanged: boolean; + weeklySummary: boolean; + + quietHoursEnabled: boolean; + quietHoursStart: string; + quietHoursEnd: string; +}; + +export type StrainScale = "21" | "100"; + +export type VytaSettings = { + heightCm: number | null; + weightKg: number | null; + weightTargetKg: number | null; + + calorieTarget: number | null; + + stepGoal: number | null; + intensityMinutesGoal: number | null; + highIntensityMinutesGoal: number | null; + sleepGoalMinutes: number | null; + + strainScale: StrainScale; + + notifications: NotificationSettings; + + weightHistory: WeightEntry[]; + + updatedAt: string | null; +}; + +const DEFAULT_NOTIFICATIONS: NotificationSettings = { + enabled: true, + + sleepSummary: true, + lowSleepScore: true, + sleepDebt: false, + + recovery: true, + hrvDeviation: true, + elevatedRestingHeartRate: true, + lowBodyBattery: false, + highStress: false, + + newActivity: true, + stepGoalReached: true, + intensityGoalReached: false, + vo2maxChanged: true, + + newRecommendation: true, + vytaAgeChanged: true, + weeklySummary: true, + + quietHoursEnabled: true, + quietHoursStart: "22:00", + quietHoursEnd: "07:00", +}; + +const DEFAULT_SETTINGS: VytaSettings = { + heightCm: null, + weightKg: null, + weightTargetKg: null, + + calorieTarget: null, + + stepGoal: null, + intensityMinutesGoal: null, + highIntensityMinutesGoal: null, + sleepGoalMinutes: null, + + strainScale: "21", + + notifications: DEFAULT_NOTIFICATIONS, + + weightHistory: [], + + updatedAt: null, +}; + +function getSettingsPath() { + return ( + process.env.VYTA_SETTINGS_PATH || + path.join(process.cwd(), "private-data", "settings.json") + ); +} + +export async function getVytaSettings(): Promise { + const filePath = getSettingsPath(); + + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw); + + const heightCm = validNumber(parsed.heightCm); + const weightKg = validNumber(parsed.weightKg); + const weightTargetKg = validNumber(parsed.weightTargetKg); + + const calorieTarget = validNumber(parsed.calorieTarget); + + const stepGoal = validNumber(parsed.stepGoal); + const intensityMinutesGoal = validNumber(parsed.intensityMinutesGoal); + const highIntensityMinutesGoal = validNumber( + parsed.highIntensityMinutesGoal + ); + const sleepGoalMinutes = validNumber(parsed.sleepGoalMinutes); + + const strainScale: StrainScale = + parsed.strainScale === "100" ? "100" : "21"; + + let weightHistory = parseWeightHistory(parsed.weightHistory); + + if (weightKg !== null && weightHistory.length === 0) { + const sourceDate = + typeof parsed.updatedAt === "string" + ? new Date(parsed.updatedAt) + : new Date(); + + const validDate = Number.isNaN(sourceDate.getTime()) + ? new Date() + : sourceDate; + + weightHistory = [ + { + date: formatLocalDate(validDate), + weightKg, + recordedAt: validDate.toISOString(), + }, + ]; + } + + const notifications = parseNotifications(parsed.notifications); + + return { + heightCm, + weightKg, + weightTargetKg, + + calorieTarget, + + stepGoal, + intensityMinutesGoal, + highIntensityMinutesGoal, + sleepGoalMinutes, + + strainScale, + + notifications, + + weightHistory, + + updatedAt: + typeof parsed.updatedAt === "string" + ? parsed.updatedAt + : null, + }; + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ) { + return DEFAULT_SETTINGS; + } + + console.error("Kunde inte läsa Vyta-inställningar:", error); + + return DEFAULT_SETTINGS; + } +} + +export async function saveVytaSettings(settings: { + heightCm: number | null; + weightKg: number | null; + weightTargetKg: number | null; + + calorieTarget: number | null; + + stepGoal: number | null; + intensityMinutesGoal: number | null; + highIntensityMinutesGoal: number | null; + sleepGoalMinutes: number | null; + + strainScale?: StrainScale; + + notifications?: Partial; +}): Promise { + const existing = await getVytaSettings(); + + let weightHistory = [...existing.weightHistory]; + + if ( + settings.weightKg !== null && + (existing.weightKg === null || + settings.weightKg !== existing.weightKg) + ) { + const now = new Date(); + const date = formatLocalDate(now); + + const entry: WeightEntry = { + date, + weightKg: settings.weightKg, + recordedAt: now.toISOString(), + }; + + weightHistory = weightHistory.filter( + (item) => item.date !== date + ); + + weightHistory.push(entry); + + weightHistory.sort((a, b) => + a.date.localeCompare(b.date) + ); + } + + if (settings.weightKg !== null && weightHistory.length === 0) { + const now = new Date(); + + weightHistory.push({ + date: formatLocalDate(now), + weightKg: settings.weightKg, + recordedAt: now.toISOString(), + }); + } + + const notifications: NotificationSettings = { + ...existing.notifications, + ...settings.notifications, + }; + + const result: VytaSettings = { + heightCm: settings.heightCm, + weightKg: settings.weightKg, + weightTargetKg: settings.weightTargetKg, + + calorieTarget: settings.calorieTarget, + + stepGoal: settings.stepGoal, + intensityMinutesGoal: settings.intensityMinutesGoal, + highIntensityMinutesGoal: settings.highIntensityMinutesGoal, + sleepGoalMinutes: settings.sleepGoalMinutes, + + strainScale: + settings.strainScale ?? + existing.strainScale, + + notifications, + + weightHistory, + + updatedAt: new Date().toISOString(), + }; + + const filePath = getSettingsPath(); + + await fs.mkdir(path.dirname(filePath), { + recursive: true, + }); + + const temporaryPath = `${filePath}.tmp`; + + await fs.writeFile( + temporaryPath, + JSON.stringify(result, null, 2), + "utf-8" + ); + + await fs.rename(temporaryPath, filePath); + + return result; +} + +function parseNotifications(value: unknown): NotificationSettings { + if (typeof value !== "object" || value === null) { + return DEFAULT_NOTIFICATIONS; + } + + const object = value as Record; + + return { + enabled: booleanValue(object.enabled, true), + + sleepSummary: booleanValue(object.sleepSummary, true), + lowSleepScore: booleanValue(object.lowSleepScore, true), + sleepDebt: booleanValue(object.sleepDebt, false), + + recovery: booleanValue(object.recovery, true), + hrvDeviation: booleanValue(object.hrvDeviation, true), + elevatedRestingHeartRate: booleanValue( + object.elevatedRestingHeartRate, + true + ), + lowBodyBattery: booleanValue(object.lowBodyBattery, false), + highStress: booleanValue(object.highStress, false), + + newActivity: booleanValue(object.newActivity, true), + stepGoalReached: booleanValue(object.stepGoalReached, true), + intensityGoalReached: booleanValue( + object.intensityGoalReached, + false + ), + vo2maxChanged: booleanValue(object.vo2maxChanged, true), + + newRecommendation: booleanValue(object.newRecommendation, true), + vytaAgeChanged: booleanValue(object.vytaAgeChanged, true), + weeklySummary: booleanValue(object.weeklySummary, true), + + quietHoursEnabled: booleanValue(object.quietHoursEnabled, true), + + quietHoursStart: stringValue( + object.quietHoursStart, + "22:00" + ), + + quietHoursEnd: stringValue( + object.quietHoursEnd, + "07:00" + ), + }; +} + +function booleanValue(value: unknown, fallback: boolean) { + return typeof value === "boolean" ? value : fallback; +} + +function stringValue(value: unknown, fallback: string) { + return typeof value === "string" ? value : fallback; +} + +function parseWeightHistory(value: unknown): WeightEntry[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => { + if (typeof entry !== "object" || entry === null) { + return null; + } + + const object = entry as Record; + const weightKg = validNumber(object.weightKg); + + if ( + weightKg === null || + typeof object.date !== "string" + ) { + return null; + } + + return { + date: object.date, + weightKg, + recordedAt: + typeof object.recordedAt === "string" + ? object.recordedAt + : object.date, + }; + }) + .filter( + (entry): entry is WeightEntry => + entry !== null + ) + .sort((a, b) => + a.date.localeCompare(b.date) + ); +} + +function validNumber(value: unknown): number | null { + if ( + typeof value === "number" && + Number.isFinite(value) + ) { + return value; + } + + return null; +} + +function formatLocalDate(date: Date) { + const year = date.getFullYear(); + + const month = String( + date.getMonth() + 1 + ).padStart(2, "0"); + + const day = String( + date.getDate() + ).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} \ No newline at end of file diff --git a/app/vyta/src/proxy.ts b/app/vyta/src/proxy.ts new file mode 100644 index 0000000..ae00248 --- /dev/null +++ b/app/vyta/src/proxy.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { SESSION_COOKIE, verifySessionToken } from "./lib/auth"; + +export async function proxy(request: NextRequest) { + const pathname = request.nextUrl.pathname; + + /* + * Publika routes som måste fungera + * innan användaren har en VYTA-session. + */ + const publicRoutes = [ + "/login", + + /* + * Legacy-login behålls tillfälligt + * tills Pocket ID är verifierat. + */ + "/api/login", + + /* + * Pocket ID / OIDC + */ + "/api/auth/login", + "/api/auth/callback/pocketid", + + /* + * Logout måste kunna nås även om + * sessionen redan hunnit bli ogiltig. + */ + "/api/logout", + "/api/notifications/evaluate", + + /* + * PWA / icons + */ + "/manifest.webmanifest", + "/favicon.ico", + "/icon", + "/apple-icon", + "/sw.js", + ]; + + const isPublic = publicRoutes.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ); + + if (isPublic) { + return NextResponse.next(); + } + + /* + * Läs VYTA-sessionen. + */ + const token = request.cookies.get(SESSION_COOKIE)?.value; + + const session = await verifySessionToken(token); + + const authenticated = session !== null && session !== false; + + if (authenticated) { + return NextResponse.next(); + } + + /* + * API-anrop ska aldrig redirectas till HTML-login. + * De ska få ett rent 401-svar. + */ + if (pathname.startsWith("/api/")) { + return NextResponse.json( + { + error: "Unauthorized", + }, + { + status: 401, + } + ); + } + + /* + * Vanliga sidor skickas till login. + * + * Vi använder VYTA_PUBLIC_URL när den finns, + * så redirecten fungerar korrekt bakom + * Pangolin/reverse proxy. + */ + const publicUrl = process.env.VYTA_PUBLIC_URL; + + const loginUrl = publicUrl ? new URL("/login", publicUrl) : request.nextUrl.clone(); + + if (!publicUrl) { + loginUrl.pathname = "/login"; + + loginUrl.search = ""; + } + + return NextResponse.redirect(loginUrl); +} + +export const config = { + matcher: [ + /* + * Skydda allt utom Next.js statiska assets + * och vanliga bildfiler. + */ + "/((?!_next/static|_next/image|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico)$).*)", + ], +}; diff --git a/app/vyta/src/types/garmin.ts b/app/vyta/src/types/garmin.ts new file mode 100644 index 0000000..d652708 --- /dev/null +++ b/app/vyta/src/types/garmin.ts @@ -0,0 +1,98 @@ +export type GarminDailySummary = { + calendarDate: string; + + steps?: number; + + distanceMeters?: number; + + activeKilocalories?: number; + totalKilocalories?: number; + + restingHeartRate?: number; + minHeartRate?: number; + maxHeartRate?: number; + + averageStressLevel?: number; + + moderateIntensityMinutes?: number; + vigorousIntensityMinutes?: number; + + bodyBatteryCurrent?: number; + bodyBatteryHighest?: number; + bodyBatteryLowest?: number; + + averageRespirationRate?: number; + + averageSpo2?: number; +}; + +export type GarminSleepSummary = { + calendarDate: string; + + sleepScore?: number; + + durationSeconds?: number; + + deepSleepSeconds?: number; + lightSleepSeconds?: number; + remSleepSeconds?: number; + awakeSeconds?: number; + + sleepEfficiency?: number; + + averageRespirationRate?: number; + + averageSpo2?: number; + lowestSpo2?: number; +}; + +export type GarminHrvSummary = { + calendarDate: string; + + nightlyAverage?: number; +}; + +export type GarminHealthStatus = { + calendarDate: string; + + skinTemperatureDeviation?: number; + + respirationAverage?: number; + + spo2Average?: number; +}; + +export type GarminFitnessSummary = { + calendarDate: string; + + vo2max?: number; + + trainingReadiness?: number; + + recoveryTimeHours?: number; +}; + +export type GarminActivity = { + activityId: string; + + activityName?: string; + activityType: string; + + startTime: string; + + durationSeconds: number; + + distanceMeters?: number; + + calories?: number; + + averageHeartRate?: number; + maxHeartRate?: number; + + aerobicTrainingEffect?: number; + anaerobicTrainingEffect?: number; + + elevationGainMeters?: number; + + averageSpeedMetersPerSecond?: number; +}; \ No newline at end of file diff --git a/app/vyta/src/types/vyta.ts b/app/vyta/src/types/vyta.ts new file mode 100644 index 0000000..ebd6ca8 --- /dev/null +++ b/app/vyta/src/types/vyta.ts @@ -0,0 +1,98 @@ +export type VytaSleep = { + score: number | null; + + durationMinutes: number | null; + sleepNeedMinutes: number | null; + sleepDebtMinutes: number | null; + + deepMinutes: number | null; + lightMinutes: number | null; + remMinutes: number | null; + awakeMinutes: number | null; + + efficiency: number | null; + + respirationAverage: number | null; + spo2Average: number | null; + spo2Lowest: number | null; + + skinTemperatureDeviation: number | null; +}; + +export type VytaCardio = { + restingHeartRate: number | null; + + averageHeartRate: number | null; + maxHeartRate: number | null; + + hrvNightAverage: number | null; + + respirationRate: number | null; + spo2: number | null; + + vo2max: number | null; +}; + +export type VytaEnergy = { + stressAverage: number | null; + + bodyBatteryCurrent: number | null; + bodyBatteryHighest: number | null; + bodyBatteryLowest: number | null; +}; + +export type VytaActivitySummary = { + steps: number | null; + distanceMeters: number | null; + + caloriesTotal: number | null; + caloriesActive: number | null; + + intensityMinutesModerate: number | null; + intensityMinutesVigorous: number | null; +}; + +export type VytaTraining = { + trainingReadiness: number | null; + recoveryTimeHours: number | null; +}; + +export type VytaDailyData = { + date: string; + + sleep: VytaSleep; + + cardio: VytaCardio; + + energy: VytaEnergy; + + activity: VytaActivitySummary; + + training: VytaTraining; +}; + +export type VytaActivity = { + id: string; + + type: string; + name: string | null; + + startTime: string; + durationSeconds: number; + + distanceMeters: number | null; + + calories: number | null; + + averageHeartRate: number | null; + maxHeartRate: number | null; + + aerobicTrainingEffect: number | null; + anaerobicTrainingEffect: number | null; + + elevationGainMeters: number | null; + + averageSpeedMetersPerSecond: number | null; + + rawFitAvailable: boolean; +}; \ No newline at end of file diff --git a/app/vyta/src/types/vytaApi.ts b/app/vyta/src/types/vytaApi.ts new file mode 100644 index 0000000..1bb41da --- /dev/null +++ b/app/vyta/src/types/vytaApi.ts @@ -0,0 +1,288 @@ +export type VytaApiData = { + meta: { + generatedAt: string; + source: string; + provider: string; + latestDataDate: string; + daysAvailable: number; + }; + + baselines: { + hrv: number | null; + restingHeartRate: number | null; + sleepScore: number | null; + stress: number | null; + bodyBattery: number | null; + spo2: number | null; + }; + + today: { + date: string; + + recovery: number | null; + strain: number; + + heart: { + restingHeartRate: number | null; + minHeartRate: number | null; + maxHeartRate: number | null; + hrv: number | null; + }; + + sleep: { + score: number | null; + + durationMinutes: number | null; + needMinutes: number | null; + debtMinutes: number | null; + + deepMinutes: number | null; + lightMinutes: number | null; + remMinutes: number | null; + awakeMinutes: number | null; + + sleepStartTime?: string | null; + sleepEndTime?: string | null; + }; + + energy: { + stress: number | null; + + bodyBattery: number | null; + bodyBatteryHighest: number | null; + bodyBatteryLowest: number | null; + bodyBatteryCharged: number | null; + bodyBatteryDrained: number | null; + }; + + health: { + spo2Average: number | null; + spo2Lowest: number | null; + + respirationSleep: number | null; + respirationAwake: number | null; + }; + + activity: { + steps: number | null; + distanceMeters: number | null; + + totalCalories: number | null; + activeCalories: number | null; + + moderateIntensityMinutes: number | null; + + vigorousIntensityMinutes: number | null; + }; + + training: { + readiness: number | null; + + vo2max: unknown; + + status: unknown; + + loadBalance: unknown; + + fitnessAge?: number | null; + }; + }; + + todayActivities: Array<{ + id: string; + + name: string | null; + type: string | null; + + startTime: string | null; + + durationMinutes: number | null; + + distanceMeters: number | null; + + calories: number | null; + + averageHeartRate: number | null; + maxHeartRate: number | null; + + aerobicTrainingEffect: number | null; + + anaerobicTrainingEffect: number | null; + + garminTrainingLoad: number | null; + + trainingEffectLabel: string | null; + + bodyBatteryImpact: number | null; + + strain: number; + + heartRateZones: { + zone1Minutes: number | null; + zone2Minutes: number | null; + zone3Minutes: number | null; + zone4Minutes: number | null; + zone5Minutes: number | null; + }; + }>; + + history: Array<{ + date: string; + + recovery: number | null; + strain: number; + + heart: { + restingHeartRate: number | null; + minHeartRate?: number | null; + maxHeartRate?: number | null; + hrv: number | null; + }; + + sleep: { + score: number | null; + + durationMinutes?: number | null; + needMinutes?: number | null; + debtMinutes?: number | null; + + deepMinutes?: number | null; + lightMinutes?: number | null; + remMinutes?: number | null; + awakeMinutes?: number | null; + + sleepStartTime?: string | null; + sleepEndTime?: string | null; + }; + + energy: { + stress: number | null; + + bodyBattery?: number | null; + + bodyBatteryHighest?: number | null; + + bodyBatteryLowest?: number | null; + }; + + health?: { + spo2Average?: number | null; + + spo2Lowest?: number | null; + + respirationSleep?: number | null; + + respirationAwake?: number | null; + }; + + activity: { + steps: number | null; + + distanceMeters?: number | null; + + totalCalories?: number | null; + + activeCalories?: number | null; + + moderateIntensityMinutes: number | null; + + vigorousIntensityMinutes: number | null; + }; + + training?: { + readiness?: number | null; + + vo2max?: unknown; + + status?: unknown; + + loadBalance?: unknown; + + fitnessAge?: number | null; + }; + }>; + + activities: Array<{ + id: string; + + name: string | null; + type: string | null; + + startTime: string | null; + + durationMinutes: number | null; + + distanceMeters: number | null; + + calories: number | null; + + averageHeartRate: number | null; + maxHeartRate: number | null; + + averageSpeed: number | null; + + aerobicTrainingEffect: number | null; + + anaerobicTrainingEffect: number | null; + + garminTrainingLoad: number | null; + + trainingEffectLabel: string | null; + + bodyBatteryImpact: number | null; + + strain: number; + + heartRateZones: { + zone1Minutes: number | null; + zone2Minutes: number | null; + zone3Minutes: number | null; + zone4Minutes: number | null; + zone5Minutes: number | null; + }; + + details?: { + route: Array<{ + lat: number; + lon: number; + + time?: number | null; + speedMps?: number | null; + altitudeMeters?: number | null; + }>; + + series: Array<{ + timestamp: number | null; + heartRate: number | null; + cadence: number | null; + distanceMeters: number | null; + speedMps: number | null; + elevationMeters: number | null; + lat: number | null; + lon: number | null; + }>; + + bounds: { + minLat: number | null; + maxLat: number | null; + minLon: number | null; + maxLon: number | null; + }; + } | null; + + laps?: Array<{ + index: number | null; + distanceMeters: number | null; + durationSeconds: number | null; + movingDurationSeconds: number | null; + averageSpeed: number | null; + maxSpeed: number | null; + averageHeartRate: number | null; + maxHeartRate: number | null; + averageCadence: number | null; + maxCadence: number | null; + elevationGain: number | null; + elevationLoss: number | null; + }> | null; + }>; +}; diff --git a/app/vyta/tsconfig.json b/app/vyta/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/app/vyta/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/sync/vyta-sync/.gitignore b/sync/vyta-sync/.gitignore new file mode 100644 index 0000000..21b8c09 --- /dev/null +++ b/sync/vyta-sync/.gitignore @@ -0,0 +1,4 @@ +.garmin_tokens/ +garmin-credentials.json +*.tmp +logs/ diff --git a/sync/vyta-sync/Dockerfile b/sync/vyta-sync/Dockerfile new file mode 100644 index 0000000..a4d8d02 --- /dev/null +++ b/sync/vyta-sync/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "sync.py"] diff --git a/sync/vyta-sync/collector.py b/sync/vyta-sync/collector.py new file mode 100644 index 0000000..ca1b34f --- /dev/null +++ b/sync/vyta-sync/collector.py @@ -0,0 +1,1641 @@ +import json +import os +from datetime import date, timedelta + +from garminconnect import Garmin + + +BASE_DIR = os.path.dirname(__file__) + +TOKENSTORE = os.environ.get( + "VYTA_GARMIN_TOKENSTORE", + "/data/.garmin_tokens", +) + +CREDENTIALS_FILE = os.environ.get( + "VYTA_GARMIN_CREDENTIALS_PATH", + "/data/garmin-credentials.json", +) + +OUTPUT_FILE = os.path.join( + BASE_DIR, + "vyta-garmin.json", +) + +INITIAL_HISTORY_DAYS = 30 +REFRESH_RECENT_DAYS = 2 +ACTIVITY_FETCH_COUNT = 50 + + +# --------------------------------------------------------- +# GENERELLA HJÄLPFUNKTIONER +# --------------------------------------------------------- + +def safe_call(fn, default=None): + try: + return fn() + except Exception as e: + print( + f" Varning: " + f"{type(e).__name__}: {e}" + ) + + if default is not None: + return default + + return { + "_error": + f"{type(e).__name__}: {e}" + } + + +def get_nested(data, *keys, default=None): + current = data + + for key in keys: + if not isinstance( + current, + dict + ): + return default + + current = current.get( + key + ) + + if current is None: + return default + + return current + + +def seconds_to_minutes(value): + if value is None: + return None + + try: + return round( + float(value) / 60 + ) + except ( + TypeError, + ValueError, + ): + return None + + +# --------------------------------------------------------- +# LOGIN +# --------------------------------------------------------- + +def load_credentials(): + email = os.environ.get( + "GARMIN_EMAIL", + "", + ).strip() + + password = os.environ.get( + "GARMIN_PASSWORD", + "", + ) + + if email and password: + return email, password + + try: + with open( + CREDENTIALS_FILE, + "r", + encoding="utf-8", + ) as file: + credentials = json.load(file) + except FileNotFoundError: + return None, None + + if not isinstance(credentials, dict): + return None, None + + email = credentials.get("email") + password = credentials.get("password") + + if isinstance(email, str) and isinstance(password, str) and email and password: + return email, password + + return None, None + + +def login(): + os.makedirs( + TOKENSTORE, + exist_ok=True, + ) + + print( + "Försöker använda sparad Garmin-session..." + ) + + try: + garmin = Garmin() + + garmin.login( + TOKENSTORE + ) + + print( + "Garmin-session återställd." + ) + + return garmin + + except Exception as token_error: + print( + "Sparad Garmin-session kunde inte användas." + ) + + print( + f"{type(token_error).__name__}: " + f"{token_error}" + ) + + email, password = load_credentials() + + if not email or not password: + raise RuntimeError( + "Garmin-konto saknas. " + "Koppla Garmin i VYTA-inställningarna." + ) + + garmin = Garmin( + email=email, + password=password, + ) + + print() + print( + "Loggar in mot Garmin Connect med sparad VYTA-koppling..." + ) + + garmin.login( + TOKENSTORE + ) + + print( + "Inloggning OK." + ) + + return garmin + + +# --------------------------------------------------------- +# BEFINTLIG DATA +# --------------------------------------------------------- + +def load_existing_data(): + if not os.path.exists( + OUTPUT_FILE + ): + return { + "generatedAt": None, + "days": [], + "activities": [], + } + + try: + with open( + OUTPUT_FILE, + "r", + encoding="utf-8", + ) as file: + data = json.load( + file + ) + + print( + f"Befintlig data hittad: " + f"{len(data.get('days', []))} dagar, " + f"{len(data.get('activities', []))} aktiviteter." + ) + + return data + + except Exception as e: + print( + f"Kunde inte läsa befintlig fil: " + f"{type(e).__name__}: {e}" + ) + + return { + "generatedAt": None, + "days": [], + "activities": [], + } + + +def build_existing_day_map( + existing_data +): + result = {} + + for day in existing_data.get( + "days", + [] + ): + day_date = day.get( + "date" + ) + + if day_date: + result[ + day_date + ] = day + + return result + + +# --------------------------------------------------------- +# VILKA DATUM SKA HÄMTAS? +# --------------------------------------------------------- + +def determine_dates_to_fetch( + existing_data +): + today = date.today() + + existing_days = existing_data.get( + "days", + [] + ) + + existing_dates = { + day.get("date") + for day in existing_days + if day.get("date") + } + + dates_to_fetch = set() + + # Första körningen + if not existing_dates: + start = ( + today - + timedelta( + days= + INITIAL_HISTORY_DAYS - + 1 + ) + ) + + current = start + + while current <= today: + dates_to_fetch.add( + current + ) + + current += timedelta( + days=1 + ) + + return sorted( + dates_to_fetch + ) + + # Senaste datum vi redan känner till + parsed_dates = sorted( + date.fromisoformat( + value + ) + for value in existing_dates + ) + + first_saved_date = ( + parsed_dates[0] + ) + + latest_saved_date = ( + parsed_dates[-1] + ) + + # ----------------------------------------------------- + # 1. Fyll eventuella hål i sparad historik + # ----------------------------------------------------- + + current = first_saved_date + + while current <= today: + if ( + current.isoformat() + not in existing_dates + ): + dates_to_fetch.add( + current + ) + + current += timedelta( + days=1 + ) + + # ----------------------------------------------------- + # 2. Allt efter senast sparade datum + # ----------------------------------------------------- + + current = ( + latest_saved_date + + timedelta(days=1) + ) + + while current <= today: + dates_to_fetch.add( + current + ) + + current += timedelta( + days=1 + ) + + # ----------------------------------------------------- + # 3. Uppdatera idag + igår + # + # Garmin kan komplettera data i efterhand. + # ----------------------------------------------------- + + for offset in range( + REFRESH_RECENT_DAYS + ): + recent_date = ( + today - + timedelta( + days=offset + ) + ) + + dates_to_fetch.add( + recent_date + ) + + return sorted( + dates_to_fetch + ) + + +# --------------------------------------------------------- +# HÄMTA EN DAG +# --------------------------------------------------------- + +def collect_day( + garmin, + day +): + day_str = ( + day.isoformat() + ) + + print( + f"Hämtar {day_str}..." + ) + + stats = safe_call( + lambda: + garmin.get_stats( + day_str + ), + {}, + ) + + heart_rate = safe_call( + lambda: + garmin.get_heart_rates( + day_str + ), + {}, + ) + + sleep = safe_call( + lambda: + garmin.get_sleep_data( + day_str + ), + {}, + ) + + hrv = safe_call( + lambda: + garmin.get_hrv_data( + day_str + ), + {}, + ) + + stress = safe_call( + lambda: + garmin.get_stress_data( + day_str + ), + {}, + ) + + body_battery = safe_call( + lambda: + garmin.get_body_battery( + day_str + ), + [], + ) + + respiration = safe_call( + lambda: + garmin.get_respiration_data( + day_str + ), + {}, + ) + + spo2 = safe_call( + lambda: + garmin.get_spo2_data( + day_str + ), + {}, + ) + + training_readiness = safe_call( + lambda: + garmin.get_training_readiness( + day_str + ), + [], + ) + + training_status = safe_call( + lambda: + garmin.get_training_status( + day_str + ), + {}, + ) + + sleep_dto = ( + sleep.get( + "dailySleepDTO", + {}, + ) + if isinstance( + sleep, + dict + ) + else {} + ) + + sleep_need = ( + sleep_dto.get( + "sleepNeed", + {}, + ) + if isinstance( + sleep_dto, + dict + ) + else {} + ) + + latest_body_battery = None + + if ( + isinstance( + body_battery, + list + ) + and body_battery + ): + latest_body_battery = ( + body_battery[-1] + ) + + hrv_value = None + + if isinstance( + hrv, + dict + ): + hrv_value = ( + hrv.get( + "lastNightAvg" + ) + or + hrv.get( + "weeklyAvg" + ) + or + hrv.get( + "hrvSummary", + {}, + ).get( + "lastNightAvg" + ) + ) + + readiness_value = None + + if ( + isinstance( + training_readiness, + list + ) + and + training_readiness + ): + first = ( + training_readiness[ + 0 + ] + ) + + if isinstance( + first, + dict + ): + readiness_value = ( + first.get( + "score" + ) + or + first.get( + "trainingReadinessScore" + ) + ) + + return { + "date": + day_str, + + "daily": { + "steps": + stats.get( + "totalSteps" + ), + + "distanceMeters": + stats.get( + "totalDistanceMeters" + ), + + "totalCalories": + stats.get( + "totalKilocalories" + ), + + "activeCalories": + stats.get( + "activeKilocalories" + ), + + "restingHeartRate": + stats.get( + "restingHeartRate" + ), + + "minHeartRate": + stats.get( + "minHeartRate" + ), + + "maxHeartRate": + stats.get( + "maxHeartRate" + ), + + "averageStress": + stats.get( + "averageStressLevel" + ), + + "moderateIntensityMinutes": + stats.get( + "moderateIntensityMinutes" + ), + + "vigorousIntensityMinutes": + stats.get( + "vigorousIntensityMinutes" + ), + + "bodyBatteryHighest": + stats.get( + "bodyBatteryHighestValue" + ), + + "bodyBatteryLowest": + stats.get( + "bodyBatteryLowestValue" + ), + + "bodyBatteryCurrent": + stats.get( + "bodyBatteryMostRecentValue" + ), + + "averageSpo2": + stats.get( + "averageSpo2" + ), + }, + + "heartRate": { + "resting": + heart_rate.get( + "restingHeartRate" + ), + + "min": + heart_rate.get( + "minHeartRate" + ), + + "max": + heart_rate.get( + "maxHeartRate" + ), + + "values": + heart_rate.get( + "heartRateValues" + ), + }, + + "sleep": { + "score": + get_nested( + sleep_dto, + "sleepScores", + "overall", + "value", + ), + + "durationMinutes": + seconds_to_minutes( + sleep_dto.get( + "sleepTimeSeconds" + ) + ), + + "deepMinutes": + seconds_to_minutes( + sleep_dto.get( + "deepSleepSeconds" + ) + ), + + "lightMinutes": + seconds_to_minutes( + sleep_dto.get( + "lightSleepSeconds" + ) + ), + + "remMinutes": + seconds_to_minutes( + sleep_dto.get( + "remSleepSeconds" + ) + ), + + "awakeMinutes": + seconds_to_minutes( + sleep_dto.get( + "awakeSleepSeconds" + ) + ), + + "sleepNeedMinutes": + sleep_need.get( + "actual" + ), + + "sleepNeedBaselineMinutes": + sleep_need.get( + "baseline" + ), + + "recommendedBedtimeStartMinutes": + sleep_need.get( + "recommendedBedtimeStartMins" + ), + + "recommendedBedtimeEndMinutes": + sleep_need.get( + "recommendedBedtimeEndMins" + ), + }, + + "hrv": { + "nightAverage": + hrv_value, + + "raw": + hrv, + }, + + "stress": { + "average": + stress.get( + "avgStressLevel" + ), + + "max": + stress.get( + "maxStressLevel" + ), + + "values": + stress.get( + "stressValuesArray" + ), + }, + + "bodyBattery": { + "charged": + latest_body_battery.get( + "charged" + ) + if isinstance( + latest_body_battery, + dict + ) + else None, + + "drained": + latest_body_battery.get( + "drained" + ) + if isinstance( + latest_body_battery, + dict + ) + else None, + + "values": + latest_body_battery.get( + "bodyBatteryValuesArray" + ) + if isinstance( + latest_body_battery, + dict + ) + else None, + }, + + "respiration": { + "sleepAverage": + respiration.get( + "avgSleepRespirationValue" + ), + + "awakeAverage": + respiration.get( + "avgWakingRespirationValue" + ), + + "lowest": + respiration.get( + "lowestRespirationValue" + ), + + "highest": + respiration.get( + "highestRespirationValue" + ), + }, + + "spo2": { + "average": + spo2.get( + "averageSpO2" + ), + + "lowest": + spo2.get( + "lowestSpO2" + ), + + "latest": + spo2.get( + "latestSpO2" + ), + + "sleepAverage": + spo2.get( + "avgSleepSpO2" + ), + }, + + "training": { + "readiness": + readiness_value, + + "vo2max": + training_status.get( + "mostRecentVO2Max" + ), + + "status": + training_status.get( + "mostRecentTrainingStatus" + ), + + "loadBalance": + training_status.get( + "mostRecentTrainingLoadBalance" + ), + }, + } + + +# --------------------------------------------------------- +# AKTIVITETER +# --------------------------------------------------------- + +def normalize_raw_activity( + activity +): + activity_type = ( + activity.get( + "activityType", + {}, + ) + ) + + return { + "id": + activity.get( + "activityId" + ), + + "name": + activity.get( + "activityName" + ), + + "type": + activity_type.get( + "typeKey" + ) + if isinstance( + activity_type, + dict + ) + else None, + + "startTimeLocal": + activity.get( + "startTimeLocal" + ), + + "durationSeconds": + activity.get( + "duration" + ), + + "movingDurationSeconds": + activity.get( + "movingDuration" + ), + + "distanceMeters": + activity.get( + "distance" + ), + + "calories": + activity.get( + "calories" + ), + + "averageHeartRate": + activity.get( + "averageHR" + ), + + "maxHeartRate": + activity.get( + "maxHR" + ), + + "averageSpeed": + activity.get( + "averageSpeed" + ), + + "maxSpeed": + activity.get( + "maxSpeed" + ), + + "aerobicTrainingEffect": + activity.get( + "aerobicTrainingEffect" + ), + + "anaerobicTrainingEffect": + activity.get( + "anaerobicTrainingEffect" + ), + + "activityTrainingLoad": + activity.get( + "activityTrainingLoad" + ), + + "trainingEffectLabel": + activity.get( + "trainingEffectLabel" + ), + + "bodyBatteryImpact": + activity.get( + "differenceBodyBattery" + ), + + "moderateIntensityMinutes": + activity.get( + "moderateIntensityMinutes" + ), + + "vigorousIntensityMinutes": + activity.get( + "vigorousIntensityMinutes" + ), + + "hrZones": { + "zone1Seconds": + activity.get( + "hrTimeInZone_1" + ), + + "zone2Seconds": + activity.get( + "hrTimeInZone_2" + ), + + "zone3Seconds": + activity.get( + "hrTimeInZone_3" + ), + + "zone4Seconds": + activity.get( + "hrTimeInZone_4" + ), + + "zone5Seconds": + activity.get( + "hrTimeInZone_5" + ), + }, + } + + +RICH_ACTIVITY_TYPES = { + "running", + "walking", + "hiking", + "cycling", + "road_biking", + "mountain_biking", + "gravel_cycling", +} + + +def should_collect_rich_activity( + activity +): + activity_type = ( + activity.get("type") + if isinstance(activity, dict) + else None + ) + + return ( + isinstance(activity_type, str) + and activity_type in RICH_ACTIVITY_TYPES + ) + + +def build_metric_index( + descriptors +): + result = {} + + if not isinstance(descriptors, list): + return result + + for descriptor in descriptors: + if not isinstance(descriptor, dict): + continue + + key = descriptor.get("key") + index = descriptor.get("metricsIndex") + + if ( + isinstance(key, str) + and isinstance(index, int) + ): + result[key] = index + + return result + + +def metric_value( + metrics, + indexes, + key, +): + index = indexes.get(key) + + if ( + index is None + or not isinstance(metrics, list) + or index >= len(metrics) + ): + return None + + value = metrics[index] + + return ( + value + if isinstance(value, (int, float)) + else None + ) + + +def normalize_activity_details( + details +): + if not isinstance(details, dict): + return None + + indexes = build_metric_index( + details.get("metricDescriptors") + ) + + series = [] + + raw_series = details.get( + "activityDetailMetrics" + ) + + if isinstance(raw_series, list): + for point in raw_series: + if not isinstance(point, dict): + continue + + metrics = point.get("metrics") + + series.append({ + "timestamp": + metric_value( + metrics, + indexes, + "directTimestamp", + ), + + "heartRate": + metric_value( + metrics, + indexes, + "directHeartRate", + ), + + "cadence": + metric_value( + metrics, + indexes, + "directRunCadence", + ), + + "distanceMeters": + metric_value( + metrics, + indexes, + "sumDistance", + ), + + "speedMps": + metric_value( + metrics, + indexes, + "directSpeed", + ), + + "elevationMeters": + metric_value( + metrics, + indexes, + "directCorrectedElevation", + ), + + "lat": + metric_value( + metrics, + indexes, + "directLatitude", + ), + + "lon": + metric_value( + metrics, + indexes, + "directLongitude", + ), + }) + + geo = details.get( + "geoPolylineDTO" + ) + + route = [] + + if isinstance(geo, dict): + polyline = geo.get( + "polyline" + ) + + if isinstance(polyline, list): + for point in polyline: + if not isinstance(point, dict): + continue + + lat = point.get("lat") + lon = point.get("lon") + + if not isinstance( + lat, + (int, float), + ): + continue + + if not isinstance( + lon, + (int, float), + ): + continue + + route.append({ + "lat": lat, + "lon": lon, + "time": + point.get("time"), + "speedMps": + point.get("speed"), + "altitudeMeters": + point.get("altitude"), + }) + + return { + "route": route, + "series": series, + + "bounds": { + "minLat": + geo.get("minLat") + if isinstance(geo, dict) + else None, + + "maxLat": + geo.get("maxLat") + if isinstance(geo, dict) + else None, + + "minLon": + geo.get("minLon") + if isinstance(geo, dict) + else None, + + "maxLon": + geo.get("maxLon") + if isinstance(geo, dict) + else None, + }, + } + + +def normalize_activity_laps( + splits +): + if not isinstance(splits, dict): + return [] + + laps = splits.get("lapDTOs") + + if not isinstance(laps, list): + return [] + + result = [] + + for lap in laps: + if not isinstance(lap, dict): + continue + + result.append({ + "index": + lap.get("lapIndex"), + + "distanceMeters": + lap.get("distance"), + + "durationSeconds": + lap.get("duration"), + + "movingDurationSeconds": + lap.get("movingDuration"), + + "averageSpeed": + lap.get("averageSpeed"), + + "maxSpeed": + lap.get("maxSpeed"), + + "averageHeartRate": + lap.get("averageHR"), + + "maxHeartRate": + lap.get("maxHR"), + + "averageCadence": + lap.get("averageRunCadence"), + + "maxCadence": + lap.get("maxRunCadence"), + + "elevationGain": + lap.get("elevationGain"), + + "elevationLoss": + lap.get("elevationLoss"), + }) + + return result + + +def collect_rich_activity( + garmin, + activity, +): + activity_id = activity.get("id") + + if activity_id is None: + return activity + + if not should_collect_rich_activity( + activity + ): + return activity + + print( + f"Hämtar rich details för aktivitet " + f"{activity_id}..." + ) + + details = safe_call( + lambda: + garmin.get_activity_details( + activity_id, + maxchart=2000, + maxpoly=4000, + ), + None, + ) + + splits = safe_call( + lambda: + garmin.get_activity_splits( + activity_id + ), + None, + ) + + normalized_details = ( + normalize_activity_details( + details + ) + ) + + if normalized_details is not None: + activity["details"] = ( + normalized_details + ) + + activity["laps"] = ( + normalize_activity_laps( + splits + ) + ) + + return activity + + + +def collect_recent_activities( + garmin, + existing_activities=None, +): + print() + print( + "Kontrollerar nya/uppdaterade aktiviteter..." + ) + + activities = safe_call( + lambda: + garmin.get_activities( + 0, + ACTIVITY_FETCH_COUNT, + ), + [], + ) + + result = [] + + existing_map = {} + + for existing in ( + existing_activities or [] + ): + if not isinstance(existing, dict): + continue + + existing_id = existing.get("id") + + if existing_id is not None: + existing_map[ + str(existing_id) + ] = existing + + for activity in activities: + if not isinstance( + activity, + dict + ): + continue + + normalized = ( + normalize_raw_activity( + activity + ) + ) + + activity_id = normalized.get("id") + + existing = ( + existing_map.get( + str(activity_id) + ) + if activity_id is not None + else None + ) + + if isinstance(existing, dict): + if existing.get("details"): + normalized["details"] = ( + existing["details"] + ) + + if existing.get("laps"): + normalized["laps"] = ( + existing["laps"] + ) + + if ( + should_collect_rich_activity( + normalized + ) + and not normalized.get( + "details" + ) + ): + normalized = collect_rich_activity( + garmin, + normalized, + ) + + result.append( + normalized + ) + + print( + f"Garmin returnerade " + f"{len(result)} senaste aktiviteter." + ) + + return result + + +def merge_activities( + existing, + incoming +): + activity_map = {} + + # Befintliga först + for activity in existing: + activity_id = activity.get( + "id" + ) + + if activity_id is not None: + activity_map[ + str(activity_id) + ] = activity + + # Nya skriver över äldre version + for activity in incoming: + activity_id = activity.get( + "id" + ) + + if activity_id is not None: + activity_map[ + str(activity_id) + ] = activity + + merged = list( + activity_map.values() + ) + + merged.sort( + key=lambda item: + item.get( + "startTimeLocal" + ) + or "", + reverse=True, + ) + + return merged + + +# --------------------------------------------------------- +# MAIN +# --------------------------------------------------------- + +def main(): + garmin = login() + + existing_data = ( + load_existing_data() + ) + + existing_day_map = ( + build_existing_day_map( + existing_data + ) + ) + + dates_to_fetch = ( + determine_dates_to_fetch( + existing_data + ) + ) + + print() + print( + "=" * 70 + ) + + print( + "INKREMENTELL GARMIN-SYNC" + ) + + print( + "=" * 70 + ) + + if dates_to_fetch: + print( + f"Datum att hämta: " + f"{len(dates_to_fetch)}" + ) + + for day in dates_to_fetch: + print( + f" {day.isoformat()}" + ) + + else: + print( + "Inga nya datum behöver hämtas." + ) + + print() + + for day in dates_to_fetch: + try: + new_data = collect_day( + garmin, + day, + ) + + existing_day_map[ + day.isoformat() + ] = new_data + + except Exception as e: + print( + f"Fel vid hämtning av " + f"{day}: " + f"{type(e).__name__}: " + f"{e}" + ) + + merged_days = list( + existing_day_map.values() + ) + + merged_days.sort( + key=lambda item: + item.get( + "date" + ) + or "" + ) + + incoming_activities = ( + collect_recent_activities( + garmin, + existing_data.get( + "activities", + [], + ), + ) + ) + + merged_activities = ( + merge_activities( + existing_data.get( + "activities", + [], + ), + incoming_activities, + ) + ) + + output = { + "generatedAt": + date.today().isoformat(), + + "days": + merged_days, + + "activities": + merged_activities, + } + + with open( + OUTPUT_FILE, + "w", + encoding="utf-8", + ) as file: + json.dump( + output, + file, + indent=2, + ensure_ascii=False, + default=str, + ) + + print() + print( + "=" * 70 + ) + + print( + "KLART" + ) + + print( + "=" * 70 + ) + + print( + f"Sparad till: " + f"{OUTPUT_FILE}" + ) + + print( + f"Dagar totalt: " + f"{len(merged_days)}" + ) + + print( + f"Aktiviteter totalt: " + f"{len(merged_activities)}" + ) + + print( + f"Datum hämtade denna körning: " + f"{len(dates_to_fetch)}" + ) + + +if __name__ == "__main__": + main() + + diff --git a/sync/vyta-sync/normalizer.py b/sync/vyta-sync/normalizer.py new file mode 100644 index 0000000..f89c3e7 --- /dev/null +++ b/sync/vyta-sync/normalizer.py @@ -0,0 +1,1252 @@ +import json +import math +import os +from datetime import datetime + + +BASE_DIR = os.path.dirname(__file__) + +SOURCE_FILE = os.path.join( + BASE_DIR, + "vyta-garmin.json", +) + +# Docker/Unraid kan ange VYTA_OUTPUT_PATH=/data/vyta-data.json. +# Utan miljövariabel behålls den gamla lokala fallbacken. +OUTPUT_FILE = os.environ.get( + "VYTA_OUTPUT_PATH", + os.path.abspath( + os.path.join( + BASE_DIR, + "..", + "vyta", + "private-data", + "vyta-data.json", + ) + ), +) + +OUTPUT_DIR = os.path.dirname( + OUTPUT_FILE +) + + +# --------------------------------------------------------- +# GENERELLA HJÄLPFUNKTIONER +# --------------------------------------------------------- + +def safe_number(value): + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + return None + + +def round_or_none(value, digits=0): + value = safe_number(value) + + if value is None: + return None + + return round(value, digits) + + +def extract_vo2max(value): + """ + Plocka ut Garmins VO2 max som ett enkelt numeriskt värde. + + Garmin kan returnera antingen ett tal eller ett objekt med + generic.vo2MaxPreciseValue / generic.vo2MaxValue. + """ + numeric = safe_number(value) + + if numeric is not None: + return numeric + + if not isinstance(value, dict): + return None + + generic = value.get( + "generic" + ) + + if isinstance(generic, dict): + precise = safe_number( + generic.get( + "vo2MaxPreciseValue" + ) + ) + + if precise is not None: + return precise + + regular = safe_number( + generic.get( + "vo2MaxValue" + ) + ) + + if regular is not None: + return regular + + for key in ( + "vo2MaxPreciseValue", + "vo2MaxValue", + "vo2max", + "value", + ): + candidate = safe_number( + value.get(key) + ) + + if candidate is not None: + return candidate + + return None + + +def average(values): + valid = [ + float(value) + for value in values + if value is not None + ] + + if not valid: + return None + + return sum(valid) / len(valid) + + +def clamp(value, minimum, maximum): + return max( + minimum, + min(maximum, value) + ) + + +def minutes(seconds): + value = safe_number(seconds) + + if value is None: + return None + + return round(value / 60) + + +def date_from_activity(activity): + start = activity.get("startTimeLocal") + + if not start: + return None + + return start[:10] + + +# --------------------------------------------------------- +# AVGÖR OM EN DAG HAR RIKTIG DATA +# --------------------------------------------------------- + +def has_meaningful_data(day): + daily = day.get("daily", {}) + sleep = day.get("sleep", {}) + hrv = day.get("hrv", {}) + stress = day.get("stress", {}) + + values = [ + daily.get("restingHeartRate"), + daily.get("steps"), + daily.get("bodyBatteryCurrent"), + sleep.get("score"), + sleep.get("durationMinutes"), + hrv.get("nightAverage"), + stress.get("average"), + ] + + count = sum( + 1 + for value in values + if value is not None + ) + + return count >= 2 + + +def has_recovery_data(day): + daily = day.get("daily", {}) + sleep = day.get("sleep", {}) + hrv = day.get("hrv", {}) + + return ( + daily.get("restingHeartRate") is not None + and sleep.get("score") is not None + and hrv.get("nightAverage") is not None + ) + + +# --------------------------------------------------------- +# BASELINES +# --------------------------------------------------------- + +def calculate_baselines(days): + valid_days = [ + day + for day in days + if has_meaningful_data(day) + ] + + # Max senaste 30 dagar som faktiskt innehåller data. + valid_days = valid_days[-30:] + + return { + "hrv": round_or_none( + average([ + day.get("hrv", {}).get("nightAverage") + for day in valid_days + ]), + 1, + ), + + "restingHeartRate": round_or_none( + average([ + day.get("daily", {}).get( + "restingHeartRate" + ) + for day in valid_days + ]), + 1, + ), + + "sleepScore": round_or_none( + average([ + day.get("sleep", {}).get("score") + for day in valid_days + ]), + 1, + ), + + "stress": round_or_none( + average([ + day.get("stress", {}).get("average") + or day.get("daily", {}).get( + "averageStress" + ) + for day in valid_days + ]), + 1, + ), + + "bodyBattery": round_or_none( + average([ + day.get("daily", {}).get( + "bodyBatteryCurrent" + ) + for day in valid_days + ]), + 1, + ), + + "spo2": round_or_none( + average([ + day.get("spo2", {}).get("average") + or day.get("daily", {}).get( + "averageSpo2" + ) + for day in valid_days + ]), + 1, + ), + } + + +# --------------------------------------------------------- +# RECOVERY +# +# 0–100 +# +# Vi använder relativa avvikelser mot personlig baseline. +# --------------------------------------------------------- + +def calculate_recovery(day, baselines): + daily = day.get("daily", {}) + sleep = day.get("sleep", {}) + hrv = day.get("hrv", {}) + stress = day.get("stress", {}) + + hrv_value = safe_number( + hrv.get("nightAverage") + ) + + rhr_value = safe_number( + daily.get("restingHeartRate") + ) + + sleep_score = safe_number( + sleep.get("score") + ) + + stress_value = safe_number( + stress.get("average") + or daily.get("averageStress") + ) + + body_battery = safe_number( + daily.get("bodyBatteryCurrent") + ) + + hrv_baseline = safe_number( + baselines.get("hrv") + ) + + rhr_baseline = safe_number( + baselines.get("restingHeartRate") + ) + + stress_baseline = safe_number( + baselines.get("stress") + ) + + components = [] + + # HRV: 35 % + if hrv_value is not None and hrv_baseline: + ratio = hrv_value / hrv_baseline + + score = 50 + ((ratio - 1) * 250) + + components.append( + ( + clamp(score, 0, 100), + 0.35 + ) + ) + + # Vilopuls: 20 % + if rhr_value is not None and rhr_baseline: + difference = ( + rhr_baseline - rhr_value + ) + + score = 50 + difference * 8 + + components.append( + ( + clamp(score, 0, 100), + 0.20 + ) + ) + + # Sömn: 25 % + if sleep_score is not None: + components.append( + ( + clamp(sleep_score, 0, 100), + 0.25 + ) + ) + + # Stress: 10 % + if ( + stress_value is not None + and stress_baseline is not None + ): + difference = ( + stress_baseline - stress_value + ) + + score = 50 + difference * 3 + + components.append( + ( + clamp(score, 0, 100), + 0.10 + ) + ) + + # Body Battery: 10 % + if body_battery is not None: + components.append( + ( + clamp(body_battery, 0, 100), + 0.10 + ) + ) + + if not components: + return None + + total_weight = sum( + weight + for _, weight in components + ) + + weighted_score = sum( + score * weight + for score, weight in components + ) + + recovery = ( + weighted_score / total_weight + ) + + return round( + clamp(recovery, 0, 100) + ) + + +# --------------------------------------------------------- +# STRAIN +# +# 0–21, inspirerat av Whoop men egen Vyta-modell. +# +# Garmin Training Load är primär signal. +# HR-zoner används som extra påverkan. +# --------------------------------------------------------- + +def calculate_activity_strain(activity): + training_load = safe_number( + activity.get("activityTrainingLoad") + ) or 0 + + duration_seconds = safe_number( + activity.get("durationSeconds") + ) or 0 + + zones = activity.get("hrZones", {}) + + z1 = safe_number( + zones.get("zone1Seconds") + ) or 0 + + z2 = safe_number( + zones.get("zone2Seconds") + ) or 0 + + z3 = safe_number( + zones.get("zone3Seconds") + ) or 0 + + z4 = safe_number( + zones.get("zone4Seconds") + ) or 0 + + z5 = safe_number( + zones.get("zone5Seconds") + ) or 0 + + # Viktad HR-belastning + hr_load = ( + z1 * 1 + + z2 * 2 + + z3 * 3 + + z4 * 4 + + z5 * 5 + ) / 60 + + # Garmin Training Load bidrar mest. + # + # Logaritmisk kurva gör att strain blir + # svårare att öka ju högre den redan är. + base = ( + training_load + + hr_load * 0.18 + + (duration_seconds / 60) * 0.12 + ) + + if base <= 0: + return 0 + + strain = ( + 6.2 * math.log10(1 + base) + ) + + return round( + clamp(strain, 0, 21), + 1, + ) + + +def calculate_daily_strain( + date_string, + activities +): + strains = [] + + for activity in activities: + if date_from_activity(activity) != date_string: + continue + + strain = calculate_activity_strain( + activity + ) + + if strain > 0: + strains.append(strain) + + if not strains: + return 0.0 + + # Flera aktiviteter samma dag ska inte bara + # summeras rakt av eftersom skalan är 0–21. + # + # Kombinerad exponentiell modell: + remaining = 1.0 + + for strain in strains: + fraction = clamp( + strain / 21, + 0, + 1 + ) + + remaining *= ( + 1 - fraction + ) + + total = 21 * ( + 1 - remaining + ) + + return round( + clamp(total, 0, 21), + 1, + ) + + +# --------------------------------------------------------- +# NORMALISERA AKTIVITETER +# --------------------------------------------------------- + +def normalize_activity(activity): + duration_seconds = safe_number( + activity.get("durationSeconds") + ) + + distance = safe_number( + activity.get("distanceMeters") + ) + + avg_speed = safe_number( + activity.get("averageSpeed") + ) + + strain = calculate_activity_strain( + activity + ) + + return { + "id": str( + activity.get("id") + ), + + "name": + activity.get("name"), + + "type": + activity.get("type"), + + "startTime": + activity.get("startTimeLocal"), + + "durationMinutes": + minutes(duration_seconds), + + "distanceMeters": + round_or_none(distance), + + "calories": + round_or_none( + activity.get("calories") + ), + + "averageHeartRate": + round_or_none( + activity.get( + "averageHeartRate" + ) + ), + + "maxHeartRate": + round_or_none( + activity.get( + "maxHeartRate" + ) + ), + + "averageSpeed": + round_or_none( + avg_speed, + 2 + ), + + "aerobicTrainingEffect": + round_or_none( + activity.get( + "aerobicTrainingEffect" + ), + 1, + ), + + "anaerobicTrainingEffect": + round_or_none( + activity.get( + "anaerobicTrainingEffect" + ), + 1, + ), + + "garminTrainingLoad": + round_or_none( + activity.get( + "activityTrainingLoad" + ), + 1, + ), + + "trainingEffectLabel": + activity.get( + "trainingEffectLabel" + ), + + "bodyBatteryImpact": + round_or_none( + activity.get( + "bodyBatteryImpact" + ) + ), + + "strain": + strain, + + "details": + activity.get( + "details" + ), + + "laps": + activity.get( + "laps" + ), + + "details": + activity.get( + "details" + ), + + "laps": + activity.get( + "laps" + ), + + "heartRateZones": { + "zone1Minutes": + round_or_none( + minutes( + activity + .get("hrZones", {}) + .get("zone1Seconds") + ), + 1, + ), + + "zone2Minutes": + round_or_none( + minutes( + activity + .get("hrZones", {}) + .get("zone2Seconds") + ), + 1, + ), + + "zone3Minutes": + round_or_none( + minutes( + activity + .get("hrZones", {}) + .get("zone3Seconds") + ), + 1, + ), + + "zone4Minutes": + round_or_none( + minutes( + activity + .get("hrZones", {}) + .get("zone4Seconds") + ), + 1, + ), + + "zone5Minutes": + round_or_none( + minutes( + activity + .get("hrZones", {}) + .get("zone5Seconds") + ), + 1, + ), + }, + } + + +# --------------------------------------------------------- +# NORMALISERA DAG +# --------------------------------------------------------- + +def normalize_day( + day, + activities, + baselines +): + date_string = day.get("date") + + daily = day.get("daily", {}) + sleep = day.get("sleep", {}) + hrv = day.get("hrv", {}) + stress = day.get("stress", {}) + body_battery = day.get( + "bodyBattery", + {} + ) + respiration = day.get( + "respiration", + {} + ) + spo2 = day.get( + "spo2", + {} + ) + training = day.get( + "training", + {} + ) + + recovery = calculate_recovery( + day, + baselines + ) + + strain = calculate_daily_strain( + date_string, + activities + ) + + sleep_duration = safe_number( + sleep.get("durationMinutes") + ) + + sleep_need = safe_number( + sleep.get("sleepNeedMinutes") + ) + + sleep_debt = None + + if ( + sleep_duration is not None + and sleep_need is not None + ): + sleep_debt = max( + 0, + round( + sleep_need - + sleep_duration + ) + ) + + return { + "date": + date_string, + + "recovery": + recovery, + + "strain": + strain, + + "heart": { + "restingHeartRate": + round_or_none( + daily.get( + "restingHeartRate" + ) + ), + + "minHeartRate": + round_or_none( + daily.get( + "minHeartRate" + ) + ), + + "maxHeartRate": + round_or_none( + daily.get( + "maxHeartRate" + ) + ), + + "hrv": + round_or_none( + hrv.get( + "nightAverage" + ) + ), + }, + + "sleep": { + "score": + round_or_none( + sleep.get("score") + ), + + "durationMinutes": + round_or_none( + sleep_duration + ), + + "needMinutes": + round_or_none( + sleep_need + ), + + "debtMinutes": + sleep_debt, + + "deepMinutes": + round_or_none( + sleep.get( + "deepMinutes" + ) + ), + + "lightMinutes": + round_or_none( + sleep.get( + "lightMinutes" + ) + ), + + "remMinutes": + round_or_none( + sleep.get( + "remMinutes" + ) + ), + + "awakeMinutes": + round_or_none( + sleep.get( + "awakeMinutes" + ) + ), + + "sleepStartTime": + sleep.get( + "sleepStartTimestampLocal" + ), + + "sleepEndTime": + sleep.get( + "sleepEndTimestampLocal" + ), + }, + + "energy": { + "stress": + round_or_none( + stress.get("average") + or daily.get( + "averageStress" + ) + ), + + "bodyBattery": + round_or_none( + daily.get( + "bodyBatteryCurrent" + ) + ), + + "bodyBatteryHighest": + round_or_none( + daily.get( + "bodyBatteryHighest" + ) + ), + + "bodyBatteryLowest": + round_or_none( + daily.get( + "bodyBatteryLowest" + ) + ), + + "bodyBatteryCharged": + round_or_none( + body_battery.get( + "charged" + ) + ), + + "bodyBatteryDrained": + round_or_none( + body_battery.get( + "drained" + ) + ), + }, + + "health": { + "spo2Average": + round_or_none( + spo2.get("average") + or daily.get( + "averageSpo2" + ), + 1, + ), + + "spo2Lowest": + round_or_none( + spo2.get( + "lowest" + ), + 1, + ), + + "respirationSleep": + round_or_none( + respiration.get( + "sleepAverage" + ), + 1, + ), + + "respirationAwake": + round_or_none( + respiration.get( + "awakeAverage" + ), + 1, + ), + }, + + "activity": { + "steps": + round_or_none( + daily.get("steps") + ), + + "distanceMeters": + round_or_none( + daily.get( + "distanceMeters" + ) + ), + + "totalCalories": + round_or_none( + daily.get( + "totalCalories" + ) + ), + + "activeCalories": + round_or_none( + daily.get( + "activeCalories" + ) + ), + + "moderateIntensityMinutes": + round_or_none( + daily.get( + "moderateIntensityMinutes" + ) + ), + + "vigorousIntensityMinutes": + round_or_none( + daily.get( + "vigorousIntensityMinutes" + ) + ), + }, + + "training": { + "readiness": + round_or_none( + training.get( + "readiness" + ) + ), + + "vo2max": + extract_vo2max( + training.get( + "vo2max" + ) + ), + + "status": + training.get( + "status" + ), + + "loadBalance": + training.get( + "loadBalance" + ), + + "fitnessAge": + round_or_none( + training.get( + "fitnessAge" + ), + 1, + ), + }, + } + + +# --------------------------------------------------------- +# MAIN +# --------------------------------------------------------- + +def main(): + if not os.path.exists( + SOURCE_FILE + ): + raise FileNotFoundError( + f"Hittar inte {SOURCE_FILE}" + ) + + print( + "Läser Garmin-data..." + ) + + with open( + SOURCE_FILE, + "r", + encoding="utf-8" + ) as file: + source = json.load(file) + + raw_days = source.get( + "days", + [] + ) + + raw_activities = source.get( + "activities", + [] + ) + + print( + f"Garmin-dagar: " + f"{len(raw_days)}" + ) + + print( + f"Garmin-aktiviteter: " + f"{len(raw_activities)}" + ) + + # Ignorera dagar som helt saknar data. + meaningful_days = [ + day + for day in raw_days + if has_meaningful_data(day) + ] + + if not meaningful_days: + raise RuntimeError( + "Ingen användbar Garmin-data hittades." + ) + + baselines = calculate_baselines( + meaningful_days + ) + + normalized_days = [ + normalize_day( + day, + raw_activities, + baselines + ) + for day in meaningful_days + ] + + normalized_activities = [ + normalize_activity(activity) + for activity in raw_activities + ] + + # Senaste dagen där vi åtminstone har + # användbar recovery-information. + recovery_candidates = [ + day + for day in meaningful_days + if has_recovery_data(day) + ] + + if recovery_candidates: + latest_raw_day = ( + recovery_candidates[-1] + ) + else: + latest_raw_day = ( + meaningful_days[-1] + ) + + latest_date = latest_raw_day.get( + "date" + ) + + today = next( + ( + day + for day in normalized_days + if day.get("date") + == latest_date + ), + normalized_days[-1], + ) + + today_activities = [ + activity + for activity in normalized_activities + if ( + activity.get( + "startTime", + "" + )[:10] + == today["date"] + ) + ] + + output = { + "meta": { + "generatedAt": + datetime.now().isoformat(), + + "source": + "Garmin Connect", + + "provider": + "python-garminconnect", + + "latestDataDate": + today["date"], + + "daysAvailable": + len( + normalized_days + ), + }, + + "baselines": + baselines, + + "today": + today, + + "todayActivities": + today_activities, + + "history": + normalized_days, + + "activities": + normalized_activities, + } + + os.makedirs( + OUTPUT_DIR, + exist_ok=True + ) + + with open( + OUTPUT_FILE, + "w", + encoding="utf-8" + ) as file: + json.dump( + output, + file, + indent=2, + ensure_ascii=False, + default=str, + ) + + print() + print( + "=" * 70 + ) + print( + "VYTA NORMALIZER KLAR" + ) + print( + "=" * 70 + ) + + print( + f"Senaste dag: " + f"{today['date']}" + ) + + print( + f"Recovery: " + f"{today.get('recovery')}" + ) + + print( + f"Strain: " + f"{today.get('strain')}" + ) + + print( + f"HRV: " + f"{today.get('heart', {}).get('hrv')}" + ) + + print( + f"Vilopuls: " + f"{today.get('heart', {}).get('restingHeartRate')}" + ) + + print( + f"Sömnscore: " + f"{today.get('sleep', {}).get('score')}" + ) + + print() + print( + "Baselines:" + ) + + for key, value in baselines.items(): + print( + f" {key}: {value}" + ) + + print() + print( + f"Sparad till:" + ) + + print( + OUTPUT_FILE + ) + + +if __name__ == "__main__": + main() + + diff --git a/sync/vyta-sync/requirements.txt b/sync/vyta-sync/requirements.txt new file mode 100644 index 0000000..04b3488 --- /dev/null +++ b/sync/vyta-sync/requirements.txt @@ -0,0 +1,3 @@ +garminconnect +python-dotenv +bleak diff --git a/sync/vyta-sync/run_sync.cmd b/sync/vyta-sync/run_sync.cmd new file mode 100755 index 0000000..1d88b26 --- /dev/null +++ b/sync/vyta-sync/run_sync.cmd @@ -0,0 +1,8 @@ +@echo off + +cd /d "C:\Users\Silicon Solder\Documents\Codex\vyta-sync" + +"C:\Users\Silicon Solder\Documents\Codex\vyta-sync\.venv\Scripts\python.exe" ^ +"C:\Users\Silicon Solder\Documents\Codex\vyta-sync\sync.py" + +exit /b %errorlevel% \ No newline at end of file diff --git a/sync/vyta-sync/scheduler.py b/sync/vyta-sync/scheduler.py new file mode 100644 index 0000000..23ee521 --- /dev/null +++ b/sync/vyta-sync/scheduler.py @@ -0,0 +1,288 @@ +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone + + +BASE_DIR = os.path.dirname( + os.path.abspath(__file__) +) + +SYNC_SCRIPT = os.path.join( + BASE_DIR, + "sync.py", +) + +SYNC_INTERVAL = int( + os.environ.get( + "VYTA_SYNC_INTERVAL_SECONDS", + "300", + ) +) + +POLL_INTERVAL = int( + os.environ.get( + "VYTA_SYNC_POLL_SECONDS", + "2", + ) +) + +REQUEST_FILE = os.environ.get( + "VYTA_SYNC_REQUEST_PATH", + "/data/sync-request.json", +) + +STATUS_FILE = os.environ.get( + "VYTA_SYNC_STATUS_PATH", + "/data/sync-status.json", +) + + +def utc_now(): + return datetime.now( + timezone.utc + ).isoformat() + + +def read_status(): + try: + with open( + STATUS_FILE, + "r", + encoding="utf-8", + ) as file: + loaded = json.load(file) + + return ( + loaded + if isinstance( + loaded, + dict, + ) + else {} + ) + + except ( + FileNotFoundError, + json.JSONDecodeError, + ): + return {} + + +def write_status( + **updates, +): + existing = read_status() + + result = { + **existing, + **updates, + } + + directory = os.path.dirname( + STATUS_FILE + ) + + if directory: + os.makedirs( + directory, + exist_ok=True, + ) + + temporary_file = ( + STATUS_FILE + + ".tmp" + ) + + with open( + temporary_file, + "w", + encoding="utf-8", + ) as file: + json.dump( + result, + file, + ensure_ascii=False, + indent=2, + ) + + os.replace( + temporary_file, + STATUS_FILE, + ) + + +def consume_manual_request(): + if not os.path.exists( + REQUEST_FILE + ): + return False + + try: + os.remove( + REQUEST_FILE + ) + + print( + "Manuell sync begärd.", + flush=True, + ) + + return True + + except FileNotFoundError: + return False + + +def run_sync( + trigger: str, +): + print( + "", + flush=True, + ) + + print( + "=" * 60, + flush=True, + ) + + print( + f"VYTA sync trigger: {trigger}", + flush=True, + ) + + print( + f"Tid: {utc_now()}", + flush=True, + ) + + print( + "=" * 60, + flush=True, + ) + + write_status( + trigger=trigger, + ) + + result = subprocess.run( + [ + sys.executable, + SYNC_SCRIPT, + ], + cwd=BASE_DIR, + ) + + if result.returncode != 0: + print( + "Sync slutade med exit code " + f"{result.returncode}", + flush=True, + ) + + return result.returncode + + +def update_countdown( + seconds: int, +): + write_status( + nextAutomaticSyncInSeconds=max( + 0, + int(seconds), + ), + syncIntervalSeconds= + SYNC_INTERVAL, + ) + + +def main(): + print( + "VYTA scheduler startad", + flush=True, + ) + + print( + "Automatisk sync: " + f"var {SYNC_INTERVAL} sekund", + flush=True, + ) + + print( + "Manuell trigger: " + f"{REQUEST_FILE}", + flush=True, + ) + + # Kör direkt när containern startar. + run_sync( + "startup" + ) + + last_run = time.monotonic() + + while True: + now = time.monotonic() + + elapsed = ( + now - + last_run + ) + + remaining = ( + SYNC_INTERVAL - + elapsed + ) + + if consume_manual_request(): + run_sync( + "manual" + ) + + last_run = ( + time.monotonic() + ) + + update_countdown( + SYNC_INTERVAL + ) + + time.sleep( + POLL_INTERVAL + ) + + continue + + if remaining <= 0: + run_sync( + "scheduled" + ) + + last_run = ( + time.monotonic() + ) + + update_countdown( + SYNC_INTERVAL + ) + + time.sleep( + POLL_INTERVAL + ) + + continue + + update_countdown( + remaining + ) + + time.sleep( + POLL_INTERVAL + ) + + +if __name__ == "__main__": + main() diff --git a/sync/vyta-sync/sync.py b/sync/vyta-sync/sync.py new file mode 100644 index 0000000..73b46df --- /dev/null +++ b/sync/vyta-sync/sync.py @@ -0,0 +1,379 @@ +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone + + +BASE_DIR = os.path.dirname( + os.path.abspath(__file__) +) + +COLLECTOR = os.path.join( + BASE_DIR, + "collector.py", +) + +NORMALIZER = os.path.join( + BASE_DIR, + "normalizer.py", +) + +UPLOADER = os.path.join( + BASE_DIR, + "upload_to_blob.py", +) + +LOG_DIR = os.path.join( + BASE_DIR, + "logs", +) + +LOG_FILE = os.path.join( + LOG_DIR, + "sync.log", +) + +STATUS_FILE = os.environ.get( + "VYTA_SYNC_STATUS_PATH", + "/data/sync-status.json", +) + + +def utc_now(): + return datetime.now( + timezone.utc + ).isoformat() + + +def log(message: str): + os.makedirs( + LOG_DIR, + exist_ok=True, + ) + + timestamp = datetime.now().strftime( + "%Y-%m-%d %H:%M:%S" + ) + + line = f"[{timestamp}] {message}" + + print( + line, + flush=True, + ) + + with open( + LOG_FILE, + "a", + encoding="utf-8", + ) as file: + file.write( + line + "\n" + ) + + +def read_status(): + try: + with open( + STATUS_FILE, + "r", + encoding="utf-8", + ) as file: + value = json.load( + file + ) + + return ( + value + if isinstance( + value, + dict, + ) + else {} + ) + + except ( + FileNotFoundError, + json.JSONDecodeError, + ): + return {} + + +def write_status( + **updates, +): + existing = read_status() + + result = { + **existing, + **updates, + } + + directory = os.path.dirname( + STATUS_FILE + ) + + if directory: + os.makedirs( + directory, + exist_ok=True, + ) + + temporary_file = ( + STATUS_FILE + + ".tmp" + ) + + with open( + temporary_file, + "w", + encoding="utf-8", + ) as file: + json.dump( + result, + file, + ensure_ascii=False, + indent=2, + ) + + os.replace( + temporary_file, + STATUS_FILE, + ) + + +def run_script( + script_path: str +): + name = os.path.basename( + script_path + ) + + log( + f"Startar {name}" + ) + + result = subprocess.run( + [ + sys.executable, + script_path, + ], + cwd=BASE_DIR, + stdin=None, + stdout=None, + stderr=None, + ) + + if result.returncode != 0: + raise RuntimeError( + f"{name} misslyckades " + f"med exit code " + f"{result.returncode}" + ) + + log( + f"{name} klar" + ) + + +def should_upload_to_blob(): + value = os.environ.get( + "VYTA_UPLOAD_TO_BLOB", + "0", + ) + + return ( + value + .strip() + .lower() + in ( + "1", + "true", + "yes", + "on", + ) + ) + + + +def notify_vyta(): + web_url = os.environ.get( + "VYTA_WEB_URL", + "", + ).strip().rstrip("/") + + secret = os.environ.get( + "VYTA_INTERNAL_SYNC_SECRET", + "", + ).strip() + + if not web_url: + log( + "Notifiering hoppas över: " + "VYTA_WEB_URL saknas" + ) + return + + if not secret: + log( + "Notifiering hoppas över: " + "VYTA_INTERNAL_SYNC_SECRET saknas" + ) + return + + url = ( + web_url + + "/api/notifications/evaluate" + ) + + request = urllib.request.Request( + url, + data=b"{}", + method="POST", + headers={ + "Authorization": + f"Bearer {secret}", + "Content-Type": + "application/json", + }, + ) + + try: + with urllib.request.urlopen( + request, + timeout=15, + ) as response: + body = ( + response + .read() + .decode( + "utf-8", + errors="replace", + ) + ) + + log( + "Notifieringsutvärdering klar " + f"(HTTP {response.status}): " + f"{body}" + ) + + except urllib.error.HTTPError as error: + body = ( + error + .read() + .decode( + "utf-8", + errors="replace", + ) + ) + + log( + "VARNING: " + "Notifieringsutvärdering " + f"gav HTTP {error.code}: " + f"{body}" + ) + + except Exception as error: + log( + "VARNING: " + "Notifieringsutvärdering " + f"misslyckades: {error}" + ) + + +def main(): + started_at = utc_now() + + write_status( + status="syncing", + startedAt=started_at, + error=None, + ) + + log( + "=" * 60 + ) + + log( + "VYTA SYNC START" + ) + + try: + run_script( + COLLECTOR + ) + + run_script( + NORMALIZER + ) + + if should_upload_to_blob(): + run_script( + UPLOADER + ) + else: + log( + "Hoppar över Vercel Blob-upload" + ) + + notify_vyta() + + completed_at = utc_now() + + write_status( + status="idle", + startedAt=started_at, + completedAt=completed_at, + lastSuccessfulSync=completed_at, + error=None, + ) + + log( + "VYTA SYNC KLAR" + ) + + except KeyboardInterrupt: + write_status( + status="idle", + error="Sync avbruten", + ) + + log( + "VYTA SYNC AVBRUTEN" + ) + + sys.exit( + 130 + ) + + except Exception as error: + write_status( + status="error", + completedAt=utc_now(), + error=str( + error + ), + ) + + log( + f"SYNC MISSLYCKADES: " + f"{error}" + ) + + sys.exit( + 1 + ) + + finally: + log( + "=" * 60 + ) + + +if __name__ == "__main__": + main() diff --git a/sync/vyta-sync/test_garmin.py b/sync/vyta-sync/test_garmin.py new file mode 100644 index 0000000..23ed146 --- /dev/null +++ b/sync/vyta-sync/test_garmin.py @@ -0,0 +1,70 @@ +import json +import os +from datetime import date +from getpass import getpass + +from garminconnect import Garmin + + +TOKENSTORE = os.path.join(os.path.dirname(__file__), ".garmin_tokens") + + +def dump(title, data): + print() + print("=" * 70) + print(title) + print("=" * 70) + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + + +def main(): + email = input("Garmin email: ").strip() + password = getpass("Garmin password: ") + + garmin = Garmin( + email=email, + password=password, + ) + + os.makedirs(TOKENSTORE, exist_ok=True) + + print("\nLoggar in mot Garmin Connect...") + + result = garmin.login(TOKENSTORE) + + print("Inloggning OK.") + print("Display name:", garmin.display_name) + + today = date.today().isoformat() + + print(f"\nHämtar Garmin-data för {today}...") + + calls = [ + ("Daily stats", lambda: garmin.get_stats(today)), + ("Heart rate", lambda: garmin.get_heart_rates(today)), + ("Sleep", lambda: garmin.get_sleep_data(today)), + ("HRV", lambda: garmin.get_hrv_data(today)), + ("Stress", lambda: garmin.get_stress_data(today)), + ("Body Battery", lambda: garmin.get_body_battery(today)), + ("Respiration", lambda: garmin.get_respiration_data(today)), + ("SpO2", lambda: garmin.get_spo2_data(today)), + ("Training readiness", lambda: garmin.get_training_readiness(today)), + ("Training status", lambda: garmin.get_training_status(today)), + ("Activities", lambda: garmin.get_activities(0, 10)), + ("Devices", lambda: garmin.get_devices()), + ] + + for title, fn in calls: + try: + data = fn() + dump(title, data) + except Exception as e: + print() + print("=" * 70) + print(title) + print("=" * 70) + print(f"ERROR: {type(e).__name__}: {e}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sync/vyta-sync/upload_to_blob.py b/sync/vyta-sync/upload_to_blob.py new file mode 100644 index 0000000..07213a9 --- /dev/null +++ b/sync/vyta-sync/upload_to_blob.py @@ -0,0 +1,166 @@ +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from dotenv import load_dotenv + + +# ------------------------------------------------------------ +# CONFIG +# ------------------------------------------------------------ + +SCRIPT_DIR = Path(__file__).resolve().parent + +VYTA_PROJECT = Path( + r"C:\Users\Silicon Solder\Documents\Codex\vyta" +) + +DATA_FILE = ( + VYTA_PROJECT + / "private-data" + / "vyta-data.json" +) + +BLOB_PATH = "vyta/vyta-data.json" + + +# Läs vyta-sync\.env +load_dotenv(SCRIPT_DIR / ".env") + + +# ------------------------------------------------------------ +# HELPERS +# ------------------------------------------------------------ + +def find_vercel_cli() -> str: + """ + Hitta Vercel CLI. + + På Windows ligger global npm CLI normalt som vercel.cmd. + """ + + candidates = [ + "vercel.cmd", + "vercel", + ] + + for candidate in candidates: + executable = shutil.which(candidate) + + if executable: + return executable + + # Vanlig npm global-bin på Windows + appdata = os.environ.get("APPDATA") + + if appdata: + fallback = Path(appdata) / "npm" / "vercel.cmd" + + if fallback.exists(): + return str(fallback) + + raise FileNotFoundError( + "Kunde inte hitta Vercel CLI. " + "Kontrollera att 'vercel --version' fungerar i PowerShell." + ) + + +def main(): + print("=" * 60) + print("VYTA → VERCEL PRIVATE BLOB") + print("=" * 60) + + # -------------------------------------------------------- + # Kontrollera datafil + # -------------------------------------------------------- + + if not DATA_FILE.exists(): + print() + print("ERROR: Vyta-datafilen saknas:") + print(DATA_FILE) + sys.exit(1) + + print() + print(f"Datafil: {DATA_FILE}") + print(f"Storlek: {DATA_FILE.stat().st_size:,} bytes") + + # -------------------------------------------------------- + # Blob-token + # -------------------------------------------------------- + + token = os.environ.get( + "BLOB_READ_WRITE_TOKEN" + ) + + if not token: + print() + print( + "ERROR: BLOB_READ_WRITE_TOKEN saknas i vyta-sync\\.env" + ) + sys.exit(1) + + print("Blob-token: OK") + + # -------------------------------------------------------- + # Vercel CLI + # -------------------------------------------------------- + + try: + vercel_cli = find_vercel_cli() + except FileNotFoundError as exc: + print() + print(f"ERROR: {exc}") + sys.exit(1) + + print(f"Vercel CLI: {vercel_cli}") + + # -------------------------------------------------------- + # Upload + # -------------------------------------------------------- + + command = [ + vercel_cli, + "blob", + "put", + str(DATA_FILE), + "--pathname", + BLOB_PATH, + "--access", + "private", + "--allow-overwrite", + "--content-type", + "application/json", + "--rw-token", + token, + ] + + print() + print( + f"Laddar upp till Private Blob: {BLOB_PATH}" + ) + print() + + result = subprocess.run( + command, + cwd=str(VYTA_PROJECT), + text=True, + ) + + if result.returncode != 0: + print() + print( + f"ERROR: Blob-upload misslyckades " + f"(exit code {result.returncode})" + ) + sys.exit(result.returncode) + + print() + print("=" * 60) + print("VYTA BLOB-UPLOAD KLAR") + print("=" * 60) + + +if __name__ == "__main__": + main() \ No newline at end of file