Public Access
Initial clean VYTA import
This commit is contained in:
commit
3ea1c3f889
92 files changed
+25789
No files matched your search
+33
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# 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.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7318
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
node_modules
|
||||
.vercel
|
||||
private-data
|
||||
public/data
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+91
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#080a0c",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 142,
|
||||
height: 142,
|
||||
borderRadius: 38,
|
||||
background: "#111418",
|
||||
border: "2px solid #24282e",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 92,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
V
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#080a0c",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 360,
|
||||
height: 360,
|
||||
borderRadius: 100,
|
||||
background: "#111418",
|
||||
border: "4px solid #24282e",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 210,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-18px",
|
||||
transform: "translateX(-8px)",
|
||||
}}
|
||||
>
|
||||
V
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-[#080a0c] text-white">
|
||||
<div className="vyta-page vyta-bottom-safe mx-auto min-h-screen max-w-md px-5">
|
||||
<header>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="text-sm text-zinc-500 transition hover:text-white">
|
||||
← Idag
|
||||
</Link>
|
||||
|
||||
<p className="text-sm font-bold tracking-[0.32em]">VYTA</p>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-6 text-3xl font-semibold tracking-tight">Inställningar</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-500">Kropp, energi, notiser och personliga mål.</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-7">
|
||||
<SettingsForm
|
||||
initialHeightCm={settings.heightCm}
|
||||
initialWeightKg={settings.weightKg}
|
||||
initialWeightTargetKg={settings.weightTargetKg}
|
||||
initialCalorieTarget={settings.calorieTarget}
|
||||
initialStepGoal={settings.stepGoal}
|
||||
initialIntensityMinutesGoal={settings.intensityMinutesGoal}
|
||||
initialHighIntensityMinutesGoal={settings.highIntensityMinutesGoal}
|
||||
initialSleepGoalMinutes={settings.sleepGoalMinutes}
|
||||
initialStrainScale={settings.strainScale}
|
||||
|
||||
suggestedStepGoal={suggestedStepGoal}
|
||||
suggestedIntensityGoal={suggestedIntensityGoal}
|
||||
suggestedHighIntensityGoal={suggestedHighIntensityGoal}
|
||||
suggestedSleepGoalMinutes={suggestedSleepGoalMinutes}
|
||||
|
||||
averageSteps={averageSteps}
|
||||
averageIntensityMinutes={averageIntensityMinutes}
|
||||
averageHighIntensityMinutes={averageHighIntensityMinutes}
|
||||
averageSleepMinutes={averageSleepMinutes}
|
||||
|
||||
tdee={tdee.tdee}
|
||||
tdeeDays={tdee.daysUsed}
|
||||
tdeeConfidence={getTdeeConfidenceLabel(tdee.confidence)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">VIKTTREND</p>
|
||||
|
||||
<p className="mt-4 text-3xl font-semibold tracking-tight">
|
||||
{settings.weightKg !== null ? `${formatWeight(settings.weightKg)} kg` : "—"}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-600">Senaste registrerade vikt</p>
|
||||
</div>
|
||||
|
||||
{weightTrend !== null && (
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
weightTrend < 0
|
||||
? "text-emerald-400"
|
||||
: weightTrend > 0
|
||||
? "text-amber-300"
|
||||
: "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{weightTrend > 0 ? "+" : ""}
|
||||
{formatWeight(weightTrend)} kg
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-[11px] text-zinc-600">sedan första mätningen</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentWeights.length > 0 ? (
|
||||
<div className="mt-6 space-y-3">
|
||||
{recentWeights.map((entry) => (
|
||||
<div
|
||||
key={entry.date}
|
||||
className="flex items-center justify-between border-t border-white/[0.04] pt-3 first:border-0 first:pt-0"
|
||||
>
|
||||
<span className="text-xs text-zinc-600">{formatDate(entry.date)}</span>
|
||||
|
||||
<span className="text-sm font-medium">{formatWeight(entry.weightKg)} kg</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-5 text-sm leading-6 text-zinc-500">
|
||||
Spara din vikt för att börja bygga en vikttrend.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<GarminConnectionSettings
|
||||
initialConnected={garminConnection.connected}
|
||||
initialEmail={garminConnection.email}
|
||||
initialUpdatedAt={garminConnection.updatedAt}
|
||||
latestDataDate={data.meta.latestDataDate}
|
||||
tdeeDays={tdee.daysUsed}
|
||||
/>
|
||||
|
||||
<SyncStatus />
|
||||
<PushNotifications />
|
||||
<NotificationSettings />
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">VYTA</p>
|
||||
|
||||
<p className="mt-4 text-sm leading-6 text-zinc-400">
|
||||
Dina personliga mål sparas separat i Vyta. Garmin-data ändras aldrig när du ändrar
|
||||
inställningarna här.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">KONTO</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium">Pocket ID</p>
|
||||
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-600">
|
||||
Din inloggning hanteras säkert via Pocket ID.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/api/logout" className="mt-5">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm font-semibold text-red-400 transition hover:bg-red-500/15"
|
||||
>
|
||||
Logga ut
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="mt-3 flex justify-between gap-4 text-xs first:mt-0">
|
||||
<span className="text-zinc-600">{label}</span>
|
||||
|
||||
<span className="text-zinc-400">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<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((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",
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="sv">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
}>;
|
||||
}) {
|
||||
const params =
|
||||
await searchParams;
|
||||
|
||||
const hasOidcError =
|
||||
params.error ===
|
||||
"oidc";
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[#080a0c] px-5 text-white">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-sm font-bold tracking-[0.32em]">
|
||||
VYTA
|
||||
</p>
|
||||
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight">
|
||||
Välkommen tillbaka
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
Logga in säkert med Pocket ID.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="flex w-full items-center justify-center rounded-2xl bg-white px-4 py-3 font-semibold !text-black"
|
||||
style={{
|
||||
color: "#000000",
|
||||
}}
|
||||
>
|
||||
Fortsätt med Pocket ID
|
||||
</a>
|
||||
|
||||
{hasOidcError && (
|
||||
<p className="mt-4 text-center text-sm text-red-400">
|
||||
Pocket ID-inloggningen misslyckades.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[#080a0c] text-white">
|
||||
Kunde inte hitta data för dagen.
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* =======================================================
|
||||
* 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 (
|
||||
<main className="min-h-screen bg-[#080a0c] text-white">
|
||||
{/*
|
||||
* Extra top-padding endast på Idag
|
||||
* för att undvika Dynamic Island.
|
||||
*/}
|
||||
<div className="vyta-bottom-safe mx-auto min-h-screen max-w-md px-5 pt-[calc(env(safe-area-inset-top,0px)+3.75rem)]">
|
||||
{/*
|
||||
* =================================================
|
||||
* HEADER
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold tracking-[0.32em]">VYTA</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
|
||||
<span className="text-[10px] font-medium tracking-wide text-zinc-600">GARMIN</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/installningar"
|
||||
aria-label="Inställningar"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/[0.05] text-zinc-400 transition active:scale-95"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight">Översikt</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-500">Din kropp och återhämtning.</p>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* DATE NAV
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<DateNavigator
|
||||
date={selectedDate}
|
||||
previousDate={previousDate}
|
||||
nextDate={nextDate}
|
||||
latestDate={latestDate}
|
||||
basePath="/"
|
||||
/>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* RECOVERY
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-5 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">ÅTERHÄMTNING</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<span className="text-6xl font-semibold tracking-tight text-emerald-400">
|
||||
{recovery ?? "—"}
|
||||
</span>
|
||||
|
||||
{recovery !== null && <span className="ml-1 text-xl text-zinc-600">%</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recovery !== null && (
|
||||
<span className="rounded-full bg-emerald-400/10 px-3 py-1.5 text-xs font-medium text-emerald-400">
|
||||
{recoveryStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-2 overflow-hidden rounded-full bg-white/[0.05]">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-400"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, recovery ?? 0))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs leading-5 text-zinc-600">
|
||||
Baserat på HRV, vilopuls, sömn och återhämtningssignaler.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* STRAIN
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">BELASTNING</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-semibold tracking-tight text-sky-400">
|
||||
{formatStrain(strain, strainScale)}
|
||||
</span>
|
||||
|
||||
<span className="ml-1 text-sm text-zinc-600">{getStrainSuffix(strainScale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="rounded-full bg-sky-400/10 px-3 py-1.5 text-xs font-medium text-sky-400">
|
||||
{strainStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 h-2 overflow-hidden rounded-full bg-white/[0.05]">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-400"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, (strain / 21) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLatest && (
|
||||
<VytaInsightCard
|
||||
title={intelligence.status.title}
|
||||
text={intelligence.status.text}
|
||||
level={intelligence.status.level}
|
||||
recovery={recovery}
|
||||
currentStrain={strain}
|
||||
recommendedMin={intelligence.strainRecommendation.min}
|
||||
recommendedMax={intelligence.strainRecommendation.max}
|
||||
strainScale={strainScale}
|
||||
contributors={insightContributors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLatest && (
|
||||
<WeeklySummaryCard
|
||||
recoveryAverage={weeklyRecovery}
|
||||
sleepAverage={weeklySleep}
|
||||
hrvAverage={weeklyHrv}
|
||||
strainAverage={weeklyStrain}
|
||||
activities={weeklyActivities}
|
||||
insight={weeklyInsight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* AKTIVITET
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">AKTIVITET</p>
|
||||
|
||||
<p className="mt-4 text-5xl font-semibold tracking-tight">
|
||||
{steps !== null ? steps.toLocaleString("sv-SE") : "—"}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-sm text-zinc-500">steg</p>
|
||||
</div>
|
||||
|
||||
{steps !== null && (
|
||||
<div className="pt-1 text-right">
|
||||
<p className="text-2xl font-semibold text-emerald-400">{stepProgress}%</p>
|
||||
|
||||
<p className="mt-1 text-[11px] text-zinc-600">
|
||||
av {stepGoal.toLocaleString("sv-SE")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-2 overflow-hidden rounded-full bg-white/[0.05]">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-400 transition-[width]"
|
||||
style={{
|
||||
width: `${stepProgress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid grid-cols-2 gap-x-6 gap-y-6">
|
||||
<ActivityMetric
|
||||
label="Aktiva kcal"
|
||||
value={
|
||||
activeCalories !== null ? `${activeCalories.toLocaleString("sv-SE")} kcal` : "—"
|
||||
}
|
||||
/>
|
||||
|
||||
<ActivityMetric
|
||||
label="Distans"
|
||||
value={
|
||||
distanceKm !== null
|
||||
? `${distanceKm.toLocaleString("sv-SE", {
|
||||
minimumFractionDigits: 1,
|
||||
|
||||
maximumFractionDigits: 1,
|
||||
})} km`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
|
||||
<ActivityMetric
|
||||
label="Intensitet"
|
||||
value={`${intensityMinutes.toLocaleString("sv-SE")} min`}
|
||||
/>
|
||||
|
||||
<ActivityMetric
|
||||
label="Totala kcal"
|
||||
value={totalCalories !== null ? `${totalCalories.toLocaleString("sv-SE")} kcal` : "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-white/[0.05] pt-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="text-xs text-zinc-600">Intensitetsminuter</span>
|
||||
|
||||
<span className="text-right text-xs text-zinc-400">
|
||||
{moderateIntensityMinutes.toLocaleString("sv-SE")} måttliga ·{" "}
|
||||
{vigorousIntensityMinutes.toLocaleString("sv-SE")} intensiva
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* QUICK METRICS
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-4 grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
label="HRV"
|
||||
value={hrv !== null ? `${formatDecimal(hrv)} ms` : "—"}
|
||||
subtitle="Nattvärde"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Vilopuls"
|
||||
value={restingHeartRate !== null ? `${formatDecimal(restingHeartRate)} bpm` : "—"}
|
||||
subtitle="Garmin"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Sömn"
|
||||
value={sleepScore !== null ? formatDecimal(sleepScore) : "—"}
|
||||
subtitle={sleepDuration !== null ? formatDuration(sleepDuration) : "Sömnscore"}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Stress"
|
||||
value={stress !== null ? formatDecimal(stress) : "—"}
|
||||
subtitle="Dagsgenomsnitt"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Body Battery"
|
||||
value={bodyBattery !== null ? `${formatDecimal(bodyBattery)}%` : "—"}
|
||||
subtitle="Energi"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Steg"
|
||||
value={steps !== null ? steps.toLocaleString("sv-SE") : "—"}
|
||||
subtitle="Totalt"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* SÖMN
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">SÖMN</p>
|
||||
|
||||
<p className="text-2xl font-semibold text-violet-400">{sleepScore ?? "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-4">
|
||||
<SmallMetric
|
||||
label="Total sömn"
|
||||
value={sleepDuration !== null ? formatDuration(sleepDuration) : "—"}
|
||||
/>
|
||||
|
||||
<SmallMetric
|
||||
label="Effektivitet"
|
||||
value={sleepEfficiency !== null ? `${sleepEfficiency}%` : "—"}
|
||||
/>
|
||||
|
||||
<SmallMetric
|
||||
label="Djupsömn"
|
||||
value={deepSleep !== null ? formatDuration(deepSleep) : "—"}
|
||||
/>
|
||||
|
||||
<SmallMetric label="REM" value={remSleep !== null ? formatDuration(remSleep) : "—"} />
|
||||
|
||||
<SmallMetric
|
||||
label="Sömnbehov"
|
||||
value={sleepNeed !== null ? formatDuration(sleepNeed) : "—"}
|
||||
/>
|
||||
|
||||
<SmallMetric
|
||||
label="Sömnskuld"
|
||||
value={sleepDebt !== null ? formatDuration(sleepDebt) : "—"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* HEALTH
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-4 grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
label="SpO₂"
|
||||
value={spo2 !== null ? `${formatDecimal(spo2)}%` : "—"}
|
||||
subtitle="Genomsnitt"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Andning"
|
||||
value={respiration !== null ? formatDecimal(respiration) : "—"}
|
||||
subtitle="andetag/min"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* =================================================
|
||||
* VYTA INSIGHT
|
||||
* =================================================
|
||||
*/}
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">VYTA INSIGHT</p>
|
||||
|
||||
<p className="mt-4 text-sm leading-6 text-zinc-300">
|
||||
{buildInsight({
|
||||
recovery,
|
||||
strain,
|
||||
sleepScore,
|
||||
hrv,
|
||||
steps,
|
||||
intensityMinutes,
|
||||
})}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* =========================================================
|
||||
* SETTINGS ICON
|
||||
* =========================================================
|
||||
*/
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 15.25A3.25 3.25 0 1 0 12 8.75a3.25 3.25 0 0 0 0 6.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
|
||||
<path
|
||||
d="M19.4 13a7.8 7.8 0 0 0 .05-1 7.8 7.8 0 0 0-.05-1l2-1.55-2-3.45-2.45.98a8.3 8.3 0 0 0-1.72-1L14.9 3.35h-4l-.33 2.63a8.3 8.3 0 0 0-1.72 1L6.4 6l-2 3.45L6.4 11a7.8 7.8 0 0 0-.05 1c0 .34.02.67.05 1l-2 1.55 2 3.45 2.45-.98c.53.4 1.1.73 1.72 1l.33 2.63h4l.33-2.63a8.3 8.3 0 0 0 1.72-1l2.45.98 2-3.45L19.4 13Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* =========================================================
|
||||
* COMPONENTS
|
||||
* =========================================================
|
||||
*/
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/[0.06] bg-[#111418] p-5">
|
||||
<p className="text-xs text-zinc-500">{label}</p>
|
||||
|
||||
<p className="mt-3 text-2xl font-semibold tracking-tight">{value}</p>
|
||||
|
||||
<p className="mt-2 text-[11px] text-zinc-600">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-2 text-lg font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-2 text-lg font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* =========================================================
|
||||
* 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<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;
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[#080a0c] text-white">
|
||||
Kunde inte hitta sömndata för dagen.
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen bg-[#080a0c] text-white">
|
||||
<div className="vyta-page vyta-bottom-safe mx-auto min-h-screen max-w-md px-5">
|
||||
<header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold tracking-[0.32em]">VYTA</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
|
||||
<span className="text-[10px] font-medium tracking-wide text-zinc-600">GARMIN</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/installningar"
|
||||
aria-label="Inställningar"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/[0.05] text-zinc-400 transition hover:bg-white/[0.08] active:scale-95"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight">Sömn</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-500">Din sömn och återhämtning från Garmin.</p>
|
||||
</header>
|
||||
|
||||
<DateNavigator
|
||||
date={selectedDate}
|
||||
previousDate={previousDate}
|
||||
nextDate={nextDate}
|
||||
latestDate={latestDate}
|
||||
basePath="/somn"
|
||||
/>
|
||||
|
||||
<section className="mt-5 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
SÖMNPRESTATION
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<span className="text-6xl font-semibold tracking-tight text-violet-400">
|
||||
{sleepPerformance}
|
||||
</span>
|
||||
|
||||
<span className="ml-1 text-xl text-zinc-600">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-zinc-600">Garmin score</p>
|
||||
|
||||
<p className="mt-1 text-xl font-semibold">{sleep.score ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-400"
|
||||
style={{
|
||||
width: `${sleepPerformance}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<Metric label="Faktisk sömn" value={formatMinutes(actualSleep)} />
|
||||
|
||||
<Metric label="Sömnbehov" value={formatMinutes(sleepNeed)} />
|
||||
|
||||
<Metric label="Sömnskuld" value={formatMinutes(sleep.debtMinutes ?? 0)} />
|
||||
|
||||
<Metric
|
||||
label="Effektivitet"
|
||||
value={sleepEfficiency !== null ? `${sleepEfficiency}%` : "—"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLatest && (
|
||||
<SleepPlanner
|
||||
sleepNeedMinutes={sleepNeed}
|
||||
sleepDebtMinutes={sleep.debtMinutes ?? 0}
|
||||
actualSleepMinutes={actualSleep}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">SÖMNSTADIER</p>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<SleepStage label="Djupsömn" value={sleep.deepMinutes ?? null} total={actualSleep} />
|
||||
|
||||
<SleepStage label="REM" value={sleep.remMinutes ?? null} total={actualSleep} />
|
||||
|
||||
<SleepStage label="Lätt sömn" value={sleep.lightMinutes ?? null} total={actualSleep} />
|
||||
|
||||
<SleepStage
|
||||
label="Vaken"
|
||||
value={awakeMinutes}
|
||||
total={actualSleep + (awakeMinutes ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sleepScores.length > 1 && (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">SÖMNTREND</p>
|
||||
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{sleep.score ?? "—"}
|
||||
|
||||
<span className="ml-1 text-sm text-zinc-500">poäng</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-5 text-violet-400">
|
||||
<TrendChart values={sleepScores} labels={labels} min={0} max={100} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-4 grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
label="HRV"
|
||||
value={day.heart.hrv !== null ? `${day.heart.hrv} ms` : "—"}
|
||||
subtitle="Nattgenomsnitt"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Vilopuls"
|
||||
value={day.heart.restingHeartRate !== null ? `${day.heart.restingHeartRate} bpm` : "—"}
|
||||
subtitle="Vilopuls"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="SpO₂"
|
||||
value={day.health?.spo2Average != null ? `${day.health.spo2Average}%` : "—"}
|
||||
subtitle="Genomsnitt"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Andning"
|
||||
value={day.health?.respirationSleep != null ? `${day.health.respirationSleep}` : "—"}
|
||||
subtitle="andetag/min"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-600">Natt</span>
|
||||
|
||||
<span className="text-sm text-zinc-400">{formatLongDate(selectedDate)}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-zinc-400">{label}</span>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-medium">{value !== null ? formatMinutes(value) : "—"}</span>
|
||||
|
||||
{value !== null && <span className="ml-2 text-xs text-zinc-600">{percentage}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-violet-400"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-2 text-xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/[0.06] bg-[#111418] p-5">
|
||||
<p className="text-xs text-zinc-500">{label}</p>
|
||||
|
||||
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
||||
|
||||
<p className="mt-2 text-[11px] text-zinc-600">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 15.25A3.25 3.25 0 1 0 12 8.75a3.25 3.25 0 0 0 0 6.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
|
||||
<path
|
||||
d="M19.4 13a7.8 7.8 0 0 0 .05-1 7.8 7.8 0 0 0-.05-1l2-1.55-2-3.45-2.45.98a8.3 8.3 0 0 0-1.72-1L14.9 3.35h-4l-.33 2.63a8.3 8.3 0 0 0-1.72 1L6.4 6l-2 3.45L6.4 11a7.8 7.8 0 0 0-.05 1c0 .34.02.67.05 1l-2 1.55 2 3.45 2.45-.98c.53.4 1.1.73 1.72 1l.33 2.63h4l.33-2.63a8.3 8.3 0 0 0 1.72-1l2.45.98 2-3.45L19.4 13Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-[#080a0c] text-white">
|
||||
<div className="mx-auto max-w-md px-5 pt-8">
|
||||
<Link href="/traning" className="text-sm text-zinc-500">
|
||||
← Tillbaka
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-8 text-2xl font-semibold">Aktiviteten hittades inte</h1>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#080a0c] text-white">
|
||||
<div className="mx-auto min-h-screen max-w-md px-5 pb-28 pt-7">
|
||||
<Link href="/traning" className="text-sm text-zinc-500">
|
||||
← Träning
|
||||
</Link>
|
||||
|
||||
<header className="mt-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-600">GARMIN-AKTIVITET</p>
|
||||
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight">
|
||||
{activity.name ?? activity.type ?? "Aktivitet"}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-500">{formatActivityDate(activity.startTime)}</p>
|
||||
</header>
|
||||
|
||||
{activity.details?.route && activity.details.route.length > 1 && (
|
||||
<ActivityRouteMap route={activity.details.route} />
|
||||
)}
|
||||
|
||||
<section className="mt-7 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">VYTA STRAIN</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<span className="text-6xl font-semibold text-sky-400">
|
||||
{formatStrain(activity.strain, strainScale)}
|
||||
</span>
|
||||
|
||||
<span className="ml-2 text-xl text-zinc-600">{getStrainSuffix(strainScale)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-400"
|
||||
style={{
|
||||
width: `${Math.min(100, (activity.strain / 21) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
label="Tid"
|
||||
value={
|
||||
activity.durationMinutes !== null ? formatMinutes(activity.durationMinutes) : "—"
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Kalorier"
|
||||
value={activity.calories !== null ? `${activity.calories}` : "—"}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Snittpuls"
|
||||
value={activity.averageHeartRate !== null ? `${activity.averageHeartRate} bpm` : "—"}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Maxpuls"
|
||||
value={activity.maxHeartRate !== null ? `${activity.maxHeartRate} bpm` : "—"}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Garmin Load"
|
||||
value={
|
||||
activity.garminTrainingLoad !== null
|
||||
? formatDecimal(activity.garminTrainingLoad)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Distans"
|
||||
value={activity.distanceMeters !== null ? formatDistance(activity.distanceMeters) : "—"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">TRAINING EFFECT</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-5">
|
||||
<MetricBlock
|
||||
label="Aerob"
|
||||
value={
|
||||
activity.aerobicTrainingEffect !== null
|
||||
? formatDecimal(activity.aerobicTrainingEffect)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricBlock
|
||||
label="Anaerob"
|
||||
value={
|
||||
activity.anaerobicTrainingEffect !== null
|
||||
? formatDecimal(activity.anaerobicTrainingEffect)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activity.trainingEffectLabel && (
|
||||
<p className="mt-5 text-sm text-zinc-500">
|
||||
{formatTrainingEffect(activity.trainingEffectLabel)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{activity.heartRateZones && (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">PULSZONER</p>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<ZoneRow
|
||||
label="Zon 1"
|
||||
value={activity.heartRateZones.zone1Minutes}
|
||||
total={activity.durationMinutes}
|
||||
/>
|
||||
|
||||
<ZoneRow
|
||||
label="Zon 2"
|
||||
value={activity.heartRateZones.zone2Minutes}
|
||||
total={activity.durationMinutes}
|
||||
/>
|
||||
|
||||
<ZoneRow
|
||||
label="Zon 3"
|
||||
value={activity.heartRateZones.zone3Minutes}
|
||||
total={activity.durationMinutes}
|
||||
/>
|
||||
|
||||
<ZoneRow
|
||||
label="Zon 4"
|
||||
value={activity.heartRateZones.zone4Minutes}
|
||||
total={activity.durationMinutes}
|
||||
/>
|
||||
|
||||
<ZoneRow
|
||||
label="Zon 5"
|
||||
value={activity.heartRateZones.zone5Minutes}
|
||||
total={activity.durationMinutes}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activity.bodyBatteryImpact !== null && (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">KROPPSPÅVERKAN</p>
|
||||
|
||||
<div className="mt-4 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-semibold">{activity.bodyBatteryImpact}</p>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-600">Body Battery</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/[0.06] bg-[#111418] p-5">
|
||||
<p className="text-xs text-zinc-500">{label}</p>
|
||||
|
||||
<p className="mt-3 text-xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricBlock({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-2 text-3xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-zinc-500">{label}</span>
|
||||
|
||||
<span className="text-sm text-zinc-300">
|
||||
{value !== null ? formatMinutes(Math.round(value)) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className="h-full rounded-full bg-sky-400"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
RECOVERY: "Återhämtning",
|
||||
AEROBIC_BASE: "Aerob bas",
|
||||
TEMPO: "Tempo",
|
||||
LACTATE_THRESHOLD: "Mjölksyratröskel",
|
||||
ANAEROBIC_CAPACITY: "Anaerob kapacitet",
|
||||
};
|
||||
|
||||
return names[label] ?? label.replaceAll("_", " ");
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="flex min-h-screen items-center justify-center bg-[#080a0c] text-zinc-500">
|
||||
Hämtar träningsdata…
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<TrainingPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function TrainingPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [data, setData] = useState<VytaApiData | null>(null);
|
||||
|
||||
const [range, setRange] = useState<7 | 30>(7);
|
||||
|
||||
const [strainScale, setStrainScale] = useState<StrainScale>("21");
|
||||
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[#080a0c] px-6 text-white">
|
||||
<div className="max-w-sm text-center">
|
||||
<p className="text-sm font-bold tracking-[0.32em]">VYTA</p>
|
||||
|
||||
<h1 className="mt-5 text-2xl font-semibold">Kunde inte läsa träningsdata</h1>
|
||||
|
||||
<p className="mt-3 text-sm text-zinc-500">{error}</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !selectedDay) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[#080a0c] text-zinc-500">
|
||||
Hämtar Garmin-data…
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen bg-[#080a0c] text-white">
|
||||
<div className="vyta-page vyta-bottom-safe mx-auto min-h-screen max-w-md px-5">
|
||||
<header>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold tracking-[0.32em]">VYTA</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
|
||||
<span className="text-[10px] font-medium tracking-wide text-zinc-600">GARMIN</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/installningar"
|
||||
aria-label="Inställningar"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/[0.05] text-zinc-400 transition hover:bg-white/[0.08] active:scale-95"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight">Träning</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-500">Belastning från dina Garmin-aktiviteter.</p>
|
||||
</header>
|
||||
|
||||
<DateNavigator
|
||||
date={selectedDate}
|
||||
previousDate={previousDate}
|
||||
nextDate={nextDate}
|
||||
latestDate={latestDate}
|
||||
basePath="/traning"
|
||||
/>
|
||||
|
||||
<section className="mt-5 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
DAGENS BELASTNING
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<span className="text-6xl font-semibold tracking-tight text-sky-400">
|
||||
{formatStrain(selectedDay.strain, strainScale)}
|
||||
</span>
|
||||
|
||||
<span className="ml-2 text-xl text-zinc-600">{getStrainSuffix(strainScale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-zinc-600">Aktiviteter</p>
|
||||
|
||||
<p className="mt-1 text-xl font-semibold">{activitiesForDay.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-500">
|
||||
Beräknad från Garmin Training Load, pulszoner och aktivitetstid.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
BELASTNINGSTREND
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{formatStrain(averageStrain, strainScale)}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-600">Snitt föregående {range} dagar</p>
|
||||
</div>
|
||||
|
||||
<RangeToggle range={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
{strainValues.length > 0 && (
|
||||
<div className="mt-6 text-sky-400">
|
||||
<TrendChart
|
||||
values={displayedStrainValues}
|
||||
labels={labels}
|
||||
min={0}
|
||||
max={getStrainMax(strainScale)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
label="Snitt"
|
||||
value={formatStrain(averageStrain, strainScale)}
|
||||
subtitle={`${range} dagar`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Högsta"
|
||||
value={formatStrain(maxStrain, strainScale)}
|
||||
subtitle={`${range} dagar`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
label="Aktiviteter"
|
||||
value={activitiesForDay.length.toString()}
|
||||
subtitle="Vald dag"
|
||||
/>
|
||||
|
||||
<MetricCard label="Datum" value={formatDate(selectedDate)} subtitle="Garmin" />
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">AKTIVITETER</p>
|
||||
|
||||
{activitiesForDay.length > 0 && (
|
||||
<span className="text-[10px] text-zinc-600">Klicka för detaljer</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{activitiesForDay.length > 0 ? (
|
||||
activitiesForDay.map((activity) => (
|
||||
<Activity key={activity.id} activity={activity} strainScale={strainScale} />
|
||||
))
|
||||
) : (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-zinc-500">Inga registrerade aktiviteter denna dag.</p>
|
||||
|
||||
<p className="mt-2 text-xs text-zinc-700">
|
||||
Belastning och övriga Garmin-värden visas ändå.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Activity({
|
||||
activity,
|
||||
strainScale,
|
||||
}: {
|
||||
activity: VytaApiData["activities"][number];
|
||||
strainScale: StrainScale;
|
||||
}) {
|
||||
const date = getActivityDate(activity.startTime);
|
||||
|
||||
const time = getActivityTime(activity.startTime);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/traning/${activity.id}`}
|
||||
className="block rounded-[22px] border border-white/[0.05] bg-white/[0.02] p-4 transition active:scale-[0.99]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{activity.name ?? formatActivityType(activity.type)}</p>
|
||||
|
||||
<span className="text-xs text-zinc-700">›</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{date}
|
||||
|
||||
{time ? ` · ${time}` : ""}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{activity.durationMinutes !== null ? formatMinutes(activity.durationMinutes) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-semibold text-sky-400">
|
||||
{formatStrain(activity.strain, strainScale)}
|
||||
</p>
|
||||
|
||||
<p className="text-[10px] text-zinc-600">strain {getStrainSuffix(strainScale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
<Mini label="Snittpuls" value={activity.averageHeartRate ?? "—"} />
|
||||
|
||||
<Mini label="Maxpuls" value={activity.maxHeartRate ?? "—"} />
|
||||
|
||||
<Mini
|
||||
label="Garmin load"
|
||||
value={
|
||||
activity.garminTrainingLoad !== null ? formatDecimal(activity.garminTrainingLoad) : "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-1 text-sm font-medium text-zinc-300">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/[0.06] bg-[#111418] p-5">
|
||||
<p className="text-xs text-zinc-500">{label}</p>
|
||||
|
||||
<p className="mt-3 text-2xl font-semibold">{value}</p>
|
||||
|
||||
<p className="mt-2 text-[11px] text-zinc-600">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeToggle({ range, onChange }: { range: 7 | 30; onChange: (value: 7 | 30) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-full bg-white/[0.05] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(7)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs ${
|
||||
range === 7 ? "bg-white text-black" : "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
7 d
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(30)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs ${
|
||||
range === 30 ? "bg-white text-black" : "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
30 d
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 15.25A3.25 3.25 0 1 0 12 8.75a3.25 3.25 0 0 0 0 6.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
|
||||
<path
|
||||
d="M19.4 13a7.8 7.8 0 0 0 .05-1 7.8 7.8 0 0 0-.05-1l2-1.55-2-3.45-2.45.98a8.3 8.3 0 0 0-1.72-1L14.9 3.35h-4l-.33 2.63a8.3 8.3 0 0 0-1.72 1L6.4 6l-2 3.45L6.4 11a7.8 7.8 0 0 0-.05 1c0 .34.02.67.05 1l-2 1.55 2 3.45 2.45-.98c.53.4 1.1.73 1.72 1l.33 2.63h4l.33-2.63a8.3 8.3 0 0 0 1.72-1l2.45.98 2-3.45L19.4 13Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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<LatLngExpression[]>(
|
||||
() =>
|
||||
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 (
|
||||
<section className="mt-5 overflow-hidden rounded-[28px] border border-white/[0.06] bg-[#111418]">
|
||||
<div className="border-b border-white/[0.05] px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-semibold tracking-[0.18em] text-zinc-500">RUTT</p>
|
||||
|
||||
<span className="text-[10px] text-zinc-600">Garmin GPS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-[310px]">
|
||||
<MapContainer
|
||||
center={first}
|
||||
zoom={14}
|
||||
scrollWheelZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap"
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: "#38bdf8",
|
||||
weight: 5,
|
||||
opacity: 0.95,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CircleMarker
|
||||
center={first}
|
||||
radius={6}
|
||||
pathOptions={{
|
||||
color: "#34d399",
|
||||
fillColor: "#34d399",
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CircleMarker
|
||||
center={last}
|
||||
radius={6}
|
||||
pathOptions={{
|
||||
color: "#f87171",
|
||||
fillColor: "#f87171",
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
<FitRoute positions={positions} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t border-white/[0.06] bg-[#080a0c]/95 backdrop-blur-xl">
|
||||
<div className="mx-auto grid max-w-md grid-cols-4 px-3 pb-[max(12px,env(safe-area-inset-bottom))] pt-3">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center gap-1 text-[11px] ${
|
||||
active ? "text-white" : "text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="mb-4 rounded-[28px] border border-white/[0.06] bg-gradient-to-br from-[#15191e] to-[#101317] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
|
||||
<p className="text-[10px] font-semibold tracking-[0.18em] text-zinc-600">
|
||||
VYTA INSIGHT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-lg font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-500">
|
||||
{text}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{previousDate ? (
|
||||
<Link
|
||||
href={getHref(previousDate)}
|
||||
aria-label="Föregående dag"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.06] bg-[#111418] text-2xl text-zinc-300 transition active:scale-95"
|
||||
>
|
||||
‹
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.06] bg-[#111418] text-2xl text-zinc-300 opacity-20"
|
||||
>
|
||||
‹
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 px-4 text-center">
|
||||
{isLatest ? (
|
||||
<>
|
||||
<p className="text-sm font-semibold capitalize text-zinc-200">
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-[10px] font-semibold tracking-[0.18em] text-zinc-600">
|
||||
IDAG
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={basePath}
|
||||
className="block"
|
||||
>
|
||||
<p className="text-sm font-semibold capitalize text-zinc-200">
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-[10px] font-semibold tracking-[0.18em] text-zinc-600">
|
||||
HISTORIK
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nextDate ? (
|
||||
<Link
|
||||
href={getHref(nextDate)}
|
||||
aria-label="Nästa dag"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.06] bg-[#111418] text-2xl text-zinc-300 transition active:scale-95"
|
||||
>
|
||||
›
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.06] bg-[#111418] text-2xl text-zinc-300 opacity-20"
|
||||
>
|
||||
›
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(
|
||||
dateString: string
|
||||
) {
|
||||
return new Intl.DateTimeFormat(
|
||||
"sv-SE",
|
||||
{
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
}
|
||||
)
|
||||
.format(
|
||||
new Date(
|
||||
`${dateString}T12:00:00`
|
||||
)
|
||||
)
|
||||
.replace(".", "");
|
||||
}
|
||||
+236
@@ -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<string | null>(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 (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">GARMIN-KONTO</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
"h-2 w-2 rounded-full",
|
||||
connected ? "bg-emerald-400" : "bg-zinc-600",
|
||||
].join(" ")}
|
||||
/>
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
{connected ? "Kopplat" : "Inte kopplat"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disconnecting}
|
||||
onClick={disconnect}
|
||||
className="rounded-full border border-red-500/20 bg-red-500/10 px-4 py-2 text-xs font-semibold text-red-300 transition active:scale-[0.97] disabled:opacity-40"
|
||||
>
|
||||
Koppla bort
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<Field
|
||||
label="Garmin-mejl"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="namn@example.com"
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={connected ? "Nytt lösenord" : "Garmin-lösenord"}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={connected ? "Lämna tomt tills du vill byta" : "••••••••"}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !email || !password}
|
||||
onClick={saveConnection}
|
||||
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Sparar…" : connected ? "Uppdatera Garmin" : "Koppla Garmin"}
|
||||
</button>
|
||||
|
||||
{message && <p className="text-xs leading-5 text-zinc-400">{message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-white/[0.05] pt-4">
|
||||
<InfoRow label="Konto" value={savedEmail ?? "—"} />
|
||||
<InfoRow label="Koppling sparad" value={formatDateTime(updatedAt)} />
|
||||
<InfoRow label="Senaste Garmin-data" value={formatDate(latestDataDate)} />
|
||||
<InfoRow label="TDEE-underlag" value={`${tdeeDays} dagar`} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
type: "email" | "password";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-zinc-500">{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={type === "password" ? "current-password" : "email"}
|
||||
className="mt-2 w-full rounded-2xl border border-white/[0.06] bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition placeholder:text-zinc-700 focus:border-white/[0.16]"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="mt-3 flex justify-between gap-4 text-xs first:mt-0">
|
||||
<span className="text-zinc-600">{label}</span>
|
||||
<span className="text-right text-zinc-400">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -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<NotificationSettings>(
|
||||
DEFAULTS
|
||||
);
|
||||
|
||||
const [
|
||||
loading,
|
||||
setLoading,
|
||||
] = useState(true);
|
||||
|
||||
const [
|
||||
saving,
|
||||
setSaving,
|
||||
] = useState(false);
|
||||
|
||||
const [
|
||||
error,
|
||||
setError,
|
||||
] = useState<string | null>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SectionHeader
|
||||
title="NOTISER"
|
||||
description="Välj vilka händelser VYTA får skicka pushnotiser om."
|
||||
/>
|
||||
|
||||
<div className="mt-3">
|
||||
<ToggleRow
|
||||
title="Pushnotiser"
|
||||
description="Huvudbrytare för personliga VYTA-notiser."
|
||||
enabled={
|
||||
settings.enabled
|
||||
}
|
||||
disabled={
|
||||
loading
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"enabled",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SectionHeader
|
||||
title="SÖMN"
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Morgonsummering"
|
||||
description="Sömnscore, sömntid och sömnskuld."
|
||||
enabled={
|
||||
settings.sleepSummary
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"sleepSummary",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Låg sömnscore"
|
||||
description="När sömnen avviker tydligt nedåt."
|
||||
enabled={
|
||||
settings.lowSleepScore
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"lowSleepScore",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Sömnskuld"
|
||||
description="När sömnskulden blivit tydligt förhöjd."
|
||||
enabled={
|
||||
settings.sleepDebt
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"sleepDebt",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SectionHeader
|
||||
title="ÅTERHÄMTNING"
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Recovery"
|
||||
description="Viktiga förändringar i dagens återhämtning."
|
||||
enabled={
|
||||
settings.recovery
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"recovery",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="HRV-avvikelse"
|
||||
description="När HRV avviker tydligt från din baseline."
|
||||
enabled={
|
||||
settings.hrvDeviation
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"hrvDeviation",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Förhöjd vilopuls"
|
||||
description="När vilopulsen ligger tydligt över din normala nivå."
|
||||
enabled={
|
||||
settings
|
||||
.elevatedRestingHeartRate
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"elevatedRestingHeartRate",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Låg Body Battery"
|
||||
description="När energinivån blivit låg."
|
||||
enabled={
|
||||
settings.lowBodyBattery
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"lowBodyBattery",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Hög stress"
|
||||
description="När stressnivån ligger ovanligt högt."
|
||||
enabled={
|
||||
settings.highStress
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"highStress",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SectionHeader
|
||||
title="TRÄNING"
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Ny aktivitet"
|
||||
description="När en ny Garmin-aktivitet har synkats."
|
||||
enabled={
|
||||
settings.newActivity
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"newActivity",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Stegmål uppnått"
|
||||
description="När du når dagens personliga stegmål."
|
||||
enabled={
|
||||
settings.stepGoalReached
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"stepGoalReached",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Intensitetsmål"
|
||||
description="När veckans aktivitetsmål är uppnått."
|
||||
enabled={
|
||||
settings
|
||||
.intensityGoalReached
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"intensityGoalReached",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="VO₂max förändrat"
|
||||
description="När Garmin registrerar ett nytt VO₂max-värde."
|
||||
enabled={
|
||||
settings.vo2maxChanged
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"vo2maxChanged",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SectionHeader
|
||||
title="VYTA"
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Ny rekommendation"
|
||||
description="När VYTA hittar en ny viktig förbättringspunkt."
|
||||
enabled={
|
||||
settings.newRecommendation
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"newRecommendation",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Vyta Age förändrat"
|
||||
description="När din långsiktiga hälsoprofil förändras tydligt."
|
||||
enabled={
|
||||
settings.vytaAgeChanged
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"vytaAgeChanged",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Veckosummering"
|
||||
description="Summering av sömn, träning och återhämtning."
|
||||
enabled={
|
||||
settings.weeklySummary
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"weeklySummary",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard>
|
||||
<SectionHeader
|
||||
title="STÖR EJ"
|
||||
description="Tysta vanliga VYTA-notiser under natten."
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
title="Stör ej"
|
||||
description="Notiser hålls tysta mellan tiderna nedan."
|
||||
enabled={
|
||||
settings
|
||||
.quietHoursEnabled
|
||||
}
|
||||
disabled={
|
||||
loading ||
|
||||
globallyDisabled
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"quietHoursEnabled",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{settings
|
||||
.quietHoursEnabled && (
|
||||
<div className="grid grid-cols-2 gap-3 pt-4">
|
||||
<TimeField
|
||||
label="Från"
|
||||
value={
|
||||
settings
|
||||
.quietHoursStart
|
||||
}
|
||||
disabled={
|
||||
globallyDisabled ||
|
||||
saving
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"quietHoursStart",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TimeField
|
||||
label="Till"
|
||||
value={
|
||||
settings
|
||||
.quietHoursEnd
|
||||
}
|
||||
disabled={
|
||||
globallyDisabled ||
|
||||
saving
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) =>
|
||||
saveChange(
|
||||
"quietHoursEnd",
|
||||
value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{saving && (
|
||||
<p className="text-center text-[11px] text-zinc-600">
|
||||
Sparar…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-2xl border border-red-400/10 bg-red-400/[0.05] px-4 py-3 text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCard({
|
||||
children,
|
||||
}: {
|
||||
children:
|
||||
React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-[28px] border border-white/[0.06] bg-[#111418] px-5 py-5">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<p className="text-[11px] font-semibold tracking-[0.2em] text-zinc-500">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{description && (
|
||||
<p className="mt-2 text-[11px] leading-5 text-zinc-600">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
disabled: boolean;
|
||||
|
||||
onChange: (
|
||||
enabled: boolean
|
||||
) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"flex min-h-[64px] items-center justify-between gap-4",
|
||||
"border-b border-white/[0.045] py-3.5 last:border-b-0",
|
||||
disabled
|
||||
? "opacity-40"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
<p className="text-[14px] font-medium tracking-[-0.01em] text-zinc-100">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-[11px] leading-[1.45] text-zinc-600">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AppleSwitch
|
||||
enabled={
|
||||
enabled
|
||||
}
|
||||
disabled={
|
||||
disabled
|
||||
}
|
||||
onChange={
|
||||
onChange
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppleSwitch({
|
||||
enabled,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
disabled: boolean;
|
||||
|
||||
onChange: (
|
||||
enabled: boolean
|
||||
) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={
|
||||
enabled
|
||||
}
|
||||
disabled={
|
||||
disabled
|
||||
}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
!enabled
|
||||
)
|
||||
}
|
||||
className={[
|
||||
"relative h-[28px] w-[48px] shrink-0 rounded-full",
|
||||
"transition-colors duration-200 ease-out",
|
||||
enabled
|
||||
? "bg-[#00c896]"
|
||||
: "bg-[#34373d]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"absolute left-[2px] top-[2px]",
|
||||
"h-[24px] w-[24px] rounded-full bg-white",
|
||||
"shadow-[0_1px_3px_rgba(0,0,0,0.35)]",
|
||||
"transition-transform duration-200 ease-out",
|
||||
enabled
|
||||
? "translate-x-[20px]"
|
||||
: "translate-x-0",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeField({
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
|
||||
onChange: (
|
||||
value: string
|
||||
) => void;
|
||||
}) {
|
||||
const [
|
||||
hours = "00",
|
||||
minutes = "00",
|
||||
] = value.split(":");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[11px] text-zinc-600">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<div className="mt-2 flex h-[48px] items-center rounded-2xl border border-white/[0.07] bg-black/20 px-2">
|
||||
<select
|
||||
value={hours}
|
||||
disabled={disabled}
|
||||
onChange={(
|
||||
event
|
||||
) =>
|
||||
onChange(
|
||||
`${event.target.value}:${minutes}`
|
||||
)
|
||||
}
|
||||
className="min-w-0 flex-1 appearance-none bg-transparent text-center text-sm font-medium tabular-nums text-white outline-none"
|
||||
>
|
||||
{Array.from(
|
||||
{
|
||||
length: 24,
|
||||
},
|
||||
(_, index) =>
|
||||
String(
|
||||
index
|
||||
).padStart(
|
||||
2,
|
||||
"0"
|
||||
)
|
||||
).map(
|
||||
(hour) => (
|
||||
<option
|
||||
key={hour}
|
||||
value={hour}
|
||||
className="bg-[#111418]"
|
||||
>
|
||||
{hour}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
|
||||
<span className="text-zinc-500">
|
||||
:
|
||||
</span>
|
||||
|
||||
<select
|
||||
value={minutes}
|
||||
disabled={disabled}
|
||||
onChange={(
|
||||
event
|
||||
) =>
|
||||
onChange(
|
||||
`${hours}:${event.target.value}`
|
||||
)
|
||||
}
|
||||
className="min-w-0 flex-1 appearance-none bg-transparent text-center text-sm font-medium tabular-nums text-white outline-none"
|
||||
>
|
||||
{Array.from(
|
||||
{
|
||||
length: 60,
|
||||
},
|
||||
(_, index) =>
|
||||
String(
|
||||
index
|
||||
).padStart(
|
||||
2,
|
||||
"0"
|
||||
)
|
||||
).map(
|
||||
(minute) => (
|
||||
<option
|
||||
key={minute}
|
||||
value={minute}
|
||||
className="bg-[#111418]"
|
||||
>
|
||||
{minute}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PushState>(
|
||||
"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 (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
NOTISER
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-sm text-zinc-400">
|
||||
Pushnotiser stöds inte i den här webbläsaren.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
state === "denied"
|
||||
) {
|
||||
return (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
NOTISER
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-sm text-zinc-400">
|
||||
Notiser är blockerade för Vyta. Tillåt dem i systemets inställningar.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
NOTISER
|
||||
</p>
|
||||
|
||||
<p className="mt-2 text-sm text-zinc-300">
|
||||
{state ===
|
||||
"enabled"
|
||||
? "Vyta-notiser är aktiverade."
|
||||
: "Få notiser om sömn, recovery och träning."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${
|
||||
state ===
|
||||
"enabled"
|
||||
? "bg-emerald-400"
|
||||
: "bg-zinc-700"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
{state ===
|
||||
"enabled" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={sendTest}
|
||||
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black disabled:opacity-50"
|
||||
>
|
||||
{busy
|
||||
? "Skickar…"
|
||||
: "Skicka testnotis"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={
|
||||
enableNotifications
|
||||
}
|
||||
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black disabled:opacity-50"
|
||||
>
|
||||
{busy
|
||||
? "Aktiverar…"
|
||||
: "Aktivera notiser"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SettingsButton() {
|
||||
return (
|
||||
<Link
|
||||
href="/installningar"
|
||||
aria-label="Inställningar"
|
||||
title="Inställningar"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.06] bg-white/[0.04] text-zinc-400 transition hover:bg-white/[0.08] hover:text-white active:scale-95"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-[19px] w-[19px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 8.5 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.1 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H2.3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.1 8.5a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 8.5 4.1a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V2.3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 .6 1.1 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.9 8.5c.14.37.36.7.66.96.3.26.68.4 1.08.4H21.7a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.1.4c-.3.2-.52.46-.66.74Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<>
|
||||
<section className="rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">KROPP & ENERGI</p>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<Field
|
||||
label="Längd"
|
||||
suffix="cm"
|
||||
value={heightCm}
|
||||
onChange={setHeightCm}
|
||||
inputMode="decimal"
|
||||
placeholder="187"
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Vikt"
|
||||
suffix="kg"
|
||||
value={weightKg}
|
||||
onChange={setWeightKg}
|
||||
inputMode="decimal"
|
||||
placeholder="87,0"
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Viktmål"
|
||||
suffix="kg"
|
||||
value={weightTargetKg}
|
||||
onChange={setWeightTargetKg}
|
||||
inputMode="decimal"
|
||||
placeholder="82,0"
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Kalorimål"
|
||||
suffix="kcal/dag"
|
||||
value={calorieTarget}
|
||||
onChange={setCalorieTarget}
|
||||
inputMode="numeric"
|
||||
placeholder="1800"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">BELASTNINGSSKALA</p>
|
||||
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-600">Välj hur VYTA visar strain i appen.</p>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 rounded-2xl bg-black/25 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStrainScale("21")}
|
||||
className={[
|
||||
"rounded-xl px-4 py-3 text-sm font-semibold transition",
|
||||
strainScale === "21"
|
||||
? "bg-white text-black shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300",
|
||||
].join(" ")}
|
||||
>
|
||||
0–21
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStrainScale("100")}
|
||||
className={[
|
||||
"rounded-xl px-4 py-3 text-sm font-semibold transition",
|
||||
strainScale === "100"
|
||||
? "bg-white text-black shadow-sm"
|
||||
: "text-zinc-500 hover:text-zinc-300",
|
||||
].join(" ")}
|
||||
>
|
||||
0–100
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] leading-4 text-zinc-600">
|
||||
Valet ändrar hur belastning visas. Den interna beräkningen ligger kvar på 0–21.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">AKTIVITETSMÅL</p>
|
||||
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-600">
|
||||
Dina mål är personliga. VYTA visar samtidigt rekommenderade nivåer baserat på din
|
||||
Garmin-data och Healthspan-modellen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<GoalField
|
||||
label="Stegmål"
|
||||
suffix="steg/dag"
|
||||
value={stepGoal}
|
||||
onChange={setStepGoal}
|
||||
placeholder="9000"
|
||||
current={
|
||||
averageSteps !== null
|
||||
? `${Math.round(averageSteps).toLocaleString("sv-SE")} steg/dag i snitt`
|
||||
: "Ingen Garmin-data ännu"
|
||||
}
|
||||
recommended={`${suggestedStepGoal.toLocaleString("sv-SE")} steg/dag`}
|
||||
onUseRecommended={() => setStepGoal(String(suggestedStepGoal))}
|
||||
/>
|
||||
|
||||
<GoalField
|
||||
label="Aktivitetsmål"
|
||||
suffix="min/vecka"
|
||||
value={intensityMinutesGoal}
|
||||
onChange={setIntensityMinutesGoal}
|
||||
placeholder="150"
|
||||
current={
|
||||
averageIntensityMinutes !== null
|
||||
? `${Math.round(averageIntensityMinutes)} min/vecka just nu`
|
||||
: "Ingen Garmin-data ännu"
|
||||
}
|
||||
recommended={`${suggestedIntensityGoal} min/vecka`}
|
||||
onUseRecommended={() => setIntensityMinutesGoal(String(suggestedIntensityGoal))}
|
||||
/>
|
||||
|
||||
<GoalField
|
||||
label="Högintensivt mål"
|
||||
suffix="min/vecka"
|
||||
value={highIntensityMinutesGoal}
|
||||
onChange={setHighIntensityMinutesGoal}
|
||||
placeholder="45"
|
||||
current={
|
||||
averageHighIntensityMinutes !== null
|
||||
? `${Math.round(averageHighIntensityMinutes)} min/vecka just nu`
|
||||
: "Ingen Garmin-data ännu"
|
||||
}
|
||||
recommended={`${suggestedHighIntensityGoal} min/vecka`}
|
||||
onUseRecommended={() => setHighIntensityMinutesGoal(String(suggestedHighIntensityGoal))}
|
||||
/>
|
||||
|
||||
<GoalField
|
||||
label="Sömnmål"
|
||||
suffix="h/natt"
|
||||
value={sleepGoalMinutes}
|
||||
onChange={setSleepGoalMinutes}
|
||||
placeholder="7,5"
|
||||
inputMode="decimal"
|
||||
current={
|
||||
averageSleepMinutes !== null
|
||||
? `${formatDuration(averageSleepMinutes)} i snitt`
|
||||
: "Ingen Garmin-data ännu"
|
||||
}
|
||||
recommended={formatDuration(suggestedSleepGoalMinutes)}
|
||||
onUseRecommended={() =>
|
||||
setSleepGoalMinutes(String(suggestedSleepGoalMinutes / 60).replace(".", ","))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="mt-7 w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Sparar…" : "Spara alla mål"}
|
||||
</button>
|
||||
|
||||
{message && <p className="mt-3 text-center text-xs text-zinc-500">{message}</p>}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">KROPP</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<Metric
|
||||
label="BMI"
|
||||
value={bmi !== null ? bmi.toFixed(1).replace(".", ",") : "—"}
|
||||
subtitle={getBmiLabel(bmi) || "Ange vikt och längd"}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Viktmål"
|
||||
value={numericWeightTarget !== null ? `${formatOne(numericWeightTarget)} kg` : "—"}
|
||||
subtitle={
|
||||
numericWeight !== null && numericWeightTarget !== null
|
||||
? `${formatOne(numericWeight - numericWeightTarget)} kg från mål`
|
||||
: "Vyta-profil"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">VYTA TDEE</p>
|
||||
|
||||
<p className="mt-4 text-4xl font-semibold tracking-tight text-amber-300">
|
||||
{tdee !== null ? tdee.toLocaleString("sv-SE") : "—"}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-sm text-zinc-500">kcal/dag</p>
|
||||
</div>
|
||||
|
||||
<span className="rounded-full bg-white/[0.05] px-3 py-1.5 text-[11px] text-zinc-400">
|
||||
{tdeeConfidence}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-xs leading-5 text-zinc-600">
|
||||
Beräknas från Garmins faktiska totala kaloriförbrukning under upp till 28 avslutade dagar.
|
||||
{tdeeDays > 0 ? ` ${tdeeDays} dagar används just nu.` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 border-t border-white/[0.05] pt-5">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Metric
|
||||
label="Kalorimål"
|
||||
value={
|
||||
numericCalorieTarget !== null ? numericCalorieTarget.toLocaleString("sv-SE") : "—"
|
||||
}
|
||||
subtitle="kcal/dag"
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label={deficit !== null && deficit < 0 ? "Planerat överskott" : "Planerat underskott"}
|
||||
value={deficit !== null ? Math.abs(Math.round(deficit)).toLocaleString("sv-SE") : "—"}
|
||||
subtitle="kcal/dag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Field
|
||||
label={label}
|
||||
suffix={suffix}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
inputMode={inputMode}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-start justify-between gap-3 px-1">
|
||||
<p className="text-[11px] leading-4 text-zinc-600">Garmin: {current}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseRecommended}
|
||||
className="shrink-0 text-right text-emerald-400"
|
||||
>
|
||||
<span className="font-serif text-[11px] italic tracking-normal text-emerald-400/80">
|
||||
VYTA rekommenderar
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-[11px] font-medium text-emerald-400">{recommended}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
suffix,
|
||||
value,
|
||||
onChange,
|
||||
inputMode,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
suffix: string;
|
||||
value: string;
|
||||
|
||||
onChange: (value: string) => void;
|
||||
|
||||
inputMode: "decimal" | "numeric";
|
||||
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
|
||||
<div className="mt-2 flex items-center rounded-2xl border border-white/[0.07] bg-black/20 px-4">
|
||||
<input
|
||||
value={value}
|
||||
inputMode={inputMode}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent py-4 text-lg font-medium text-white outline-none placeholder:text-zinc-700"
|
||||
/>
|
||||
|
||||
<span className="ml-3 text-xs text-zinc-600">{suffix}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-2 text-xl font-semibold tracking-tight">{value}</p>
|
||||
|
||||
<p className="mt-1 text-[11px] text-zinc-600">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="mt-4 overflow-hidden rounded-[28px] border border-violet-400/10 bg-gradient-to-br from-[#15131d] via-[#111319] to-[#0e1114] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-violet-400">◐</span>
|
||||
|
||||
<p className="text-[10px] font-semibold tracking-[0.18em] text-violet-400">
|
||||
PLANERA NATTEN
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<SleepMetric label="Sömnbehov" value={formatDuration(targetSleepMinutes)} />
|
||||
|
||||
<SleepMetric
|
||||
label="Sömnskuld"
|
||||
value={formatDuration(sleepDebtMinutes)}
|
||||
warning={sleepDebtMinutes >= 60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[22px] border border-white/[0.05] bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div>
|
||||
<p className="text-[11px] text-zinc-600">Jag vill vakna</p>
|
||||
|
||||
<p className="mt-1 text-sm text-zinc-400">Ändra tiden för att planera natten.</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="time"
|
||||
value={wakeTime}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-white/[0.05] pt-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-zinc-600">
|
||||
Rekommenderad läggtid
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-end justify-between gap-4">
|
||||
<p className="text-4xl font-semibold tracking-[-0.04em] text-violet-300">{bedTime}</p>
|
||||
|
||||
<p className="pb-1 text-right text-[11px] leading-4 text-zinc-600">
|
||||
för cirka
|
||||
<br />
|
||||
{formatDuration(targetSleepMinutes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sleepGap > 0 && (
|
||||
<p className="mt-4 text-[11px] leading-5 text-zinc-500">
|
||||
Förra natten sov du{" "}
|
||||
<span className="font-medium text-violet-300">{formatDuration(sleepGap)} mindre</span> än
|
||||
dagens beräknade sömnbehov.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SleepMetric({
|
||||
label,
|
||||
value,
|
||||
warning = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
warning?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[20px] bg-white/[0.035] p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-zinc-600">{label}</p>
|
||||
|
||||
<p
|
||||
className={[
|
||||
"mt-2 text-xl font-semibold tracking-tight",
|
||||
warning ? "text-amber-300" : "text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
|
||||
GARMIN
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
className={[
|
||||
"h-2 w-2 rounded-full",
|
||||
syncing
|
||||
? "animate-pulse bg-amber-400"
|
||||
: status?.status ===
|
||||
"error"
|
||||
? "bg-red-400"
|
||||
: "bg-emerald-400",
|
||||
].join(
|
||||
" "
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
{loading
|
||||
? "Kontrollerar…"
|
||||
: syncing
|
||||
? "Synkar…"
|
||||
: status?.status ===
|
||||
"error"
|
||||
? "Syncfel"
|
||||
: "Ansluten"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
syncing ||
|
||||
requesting
|
||||
}
|
||||
onClick={
|
||||
triggerSync
|
||||
}
|
||||
className="rounded-full border border-white/[0.08] bg-white/[0.05] px-4 py-2 text-xs font-medium text-zinc-200 transition active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
syncing
|
||||
? "inline-block animate-spin"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
↻
|
||||
</span>
|
||||
|
||||
<span className="ml-2">
|
||||
{syncing
|
||||
? "Synkar…"
|
||||
: requesting
|
||||
? "Startar…"
|
||||
: "Synka nu"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 divide-y divide-white/[0.05] border-t border-white/[0.05]">
|
||||
<InfoRow
|
||||
label="Senast synkad"
|
||||
value={
|
||||
formatSyncTime(
|
||||
status
|
||||
?.lastSuccessfulSync
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoRow
|
||||
label="Automatisk synk"
|
||||
value={
|
||||
formatInterval(
|
||||
status
|
||||
?.syncIntervalSeconds
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoRow
|
||||
label="Nästa synk"
|
||||
value={
|
||||
syncing
|
||||
? "Pågår nu"
|
||||
: formatCountdown(
|
||||
status
|
||||
?.nextAutomaticSyncInSeconds
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{status?.trigger && (
|
||||
<InfoRow
|
||||
label="Senaste start"
|
||||
value={
|
||||
formatTrigger(
|
||||
status.trigger
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncing && (
|
||||
<div className="mt-5 overflow-hidden rounded-full bg-white/[0.05]">
|
||||
<div className="h-1 w-1/3 animate-pulse rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.status ===
|
||||
"error" &&
|
||||
status.error && (
|
||||
<div className="mt-5 rounded-2xl border border-red-400/10 bg-red-400/[0.05] px-4 py-3">
|
||||
<p className="text-xs font-medium text-red-400">
|
||||
Senaste synkningen misslyckades
|
||||
</p>
|
||||
|
||||
<p className="mt-1 break-words text-[11px] leading-4 text-red-400/70">
|
||||
{status.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 py-4">
|
||||
<span className="text-xs text-zinc-600">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span className="text-right text-sm font-medium text-zinc-300">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
setNow(new Date());
|
||||
};
|
||||
|
||||
updateTime();
|
||||
|
||||
const interval = window.setInterval(updateTime, 60_000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!now) {
|
||||
return (
|
||||
<header className="mb-7 flex min-h-[88px] items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold tracking-[0.32em]">
|
||||
VYTA
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-7 w-32 animate-pulse rounded bg-white/[0.04]" />
|
||||
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-white/[0.03]" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<header className="mb-7 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold tracking-[0.32em]">
|
||||
VYTA
|
||||
</div>
|
||||
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-tight">
|
||||
{greeting}
|
||||
</h1>
|
||||
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Så här mår din kropp idag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-semibold tracking-[0.18em] text-zinc-600">
|
||||
{weekday}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm font-medium text-zinc-300">
|
||||
{date}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-32 w-full overflow-visible"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{labels && labels.length > 0 && (
|
||||
<div className="mt-2 flex justify-between text-[10px] text-zinc-600">
|
||||
<span>{labels[0]}</span>
|
||||
<span>{labels[Math.floor(labels.length / 2)]}</span>
|
||||
<span>{labels[labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
className={[
|
||||
"mt-4 overflow-hidden rounded-[28px] border p-5",
|
||||
"bg-gradient-to-br from-[#101b17] via-[#101614] to-[#0e1213]",
|
||||
accent.border,
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={["text-base", accent.text].join(" ")}>✦</span>
|
||||
|
||||
<p className={["text-[10px] font-semibold tracking-[0.18em]", accent.text].join(" ")}>
|
||||
VYTA INSIGHT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"rounded-full border px-2.5 py-1 text-[9px] font-semibold tracking-[0.08em]",
|
||||
accent.badge,
|
||||
].join(" ")}
|
||||
>
|
||||
PERSONLIG
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-[22px] font-semibold tracking-[-0.025em] text-white">{title}</h2>
|
||||
|
||||
<p className="mt-2 text-[13px] leading-5 text-zinc-400">{text}</p>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 divide-x divide-white/[0.07] border-t border-white/[0.06] pt-5">
|
||||
<InsightMetric
|
||||
label="Recovery"
|
||||
value={recovery !== null ? `${Math.round(recovery)}%` : "—"}
|
||||
/>
|
||||
|
||||
<InsightMetric
|
||||
label="Rek. strain"
|
||||
value={`${formatStrain(recommendedMin, strainScale)}–${formatStrain(
|
||||
recommendedMax,
|
||||
strainScale
|
||||
)}`}
|
||||
/>
|
||||
|
||||
<InsightMetric label="Nu" value={formatStrain(currentStrain, strainScale)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||
{contributors.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-center justify-between rounded-2xl bg-white/[0.035] px-3 py-2.5"
|
||||
>
|
||||
<span className="text-[11px] text-zinc-500">{item.label}</span>
|
||||
|
||||
<span className={["text-[11px] font-semibold", contributorClass(item.score)].join(" ")}>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="px-3 first:pl-0 last:pr-0">
|
||||
<p className="text-[9px] uppercase tracking-[0.12em] text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-1 text-[17px] font-semibold tracking-tight text-white">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="mt-4 rounded-[28px] border border-white/[0.06] bg-[#111418] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold tracking-[0.18em] text-zinc-500">VECKAN</p>
|
||||
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">Din senaste 7-dagarsbild</h2>
|
||||
</div>
|
||||
|
||||
<span className="rounded-full bg-white/[0.04] px-3 py-1.5 text-[10px] font-medium text-zinc-500">
|
||||
7 DAGAR
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<Metric label="Recovery" value={formatValue(recoveryAverage, "%")} />
|
||||
<Metric label="Sömnscore" value={formatValue(sleepAverage)} />
|
||||
<Metric label="HRV" value={formatValue(hrvAverage, " ms")} />
|
||||
<Metric label="Belastning" value={formatOne(strainAverage)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl bg-white/[0.025] px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-zinc-600">Aktiviteter</span>
|
||||
<span className="text-sm font-semibold">{activities}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-white/[0.05] pt-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-zinc-600">VYTA summerar</p>
|
||||
|
||||
<p className="mt-2 text-[13px] leading-5 text-zinc-400">{insight}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[20px] bg-white/[0.03] p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.1em] text-zinc-600">{label}</p>
|
||||
|
||||
<p className="mt-2 text-xl font-semibold tracking-tight text-white">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(".", ",");
|
||||
}
|
||||
@@ -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<number | null>,
|
||||
days = 30
|
||||
): number | null {
|
||||
const validValues = values
|
||||
.filter(
|
||||
(value): value is number =>
|
||||
value !== null
|
||||
)
|
||||
.slice(-days);
|
||||
|
||||
return average(validValues);
|
||||
}
|
||||
@@ -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<HealthspanInput>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Executable
+101
@@ -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<GarminConnectionStatus> {
|
||||
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();
|
||||
}
|
||||
@@ -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<VytaApiData> {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationState> {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<VytaSettings> {
|
||||
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<NotificationSettings>;
|
||||
}): Promise<VytaSettings> {
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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}`;
|
||||
}
|
||||
@@ -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)$).*)",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}>;
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.garmin_tokens/
|
||||
garmin-credentials.json
|
||||
*.tmp
|
||||
logs/
|
||||
@@ -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"]
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
garminconnect
|
||||
python-dotenv
|
||||
bleak
|
||||
Executable
+8
@@ -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%
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user