Initial clean VYTA import

This commit is contained in:
siliconsolder committed 2026-08-21 06:26:39 +00:00
commit 3ea1c3f889
92 files changed
+25789

No files matched your search

+33
View File
@@ -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
+50
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"useTabs": false,
"printWidth": 100,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always"
}
+9
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+42
View File
@@ -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"]
+36
View File
@@ -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.
+18
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
output: "standalone",
};
export default nextConfig;
+7318
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+5
View File
@@ -0,0 +1,5 @@
.next
node_modules
.vercel
private-data
public/data
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+84
View File
@@ -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
);
}
})
);
}
);
+1
View File
@@ -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

+1
View File
@@ -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
)
);
}
}
+87
View File
@@ -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
View File
@@ -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,
}
);
}
+169
View File
@@ -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;
}
+47
View File
@@ -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,
}
);
}
}
+122
View File
@@ -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,
};
}
+259
View File
@@ -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,
}
);
}
+91
View File
@@ -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,
}
);
}
}
+60
View File
@@ -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,
}
);
}
}
+52
View File
@@ -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

+180
View File
@@ -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
+54
View File
@@ -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,
}
);
}
+278
View File
@@ -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",
});
}
+39
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+29
View File
@@ -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",
},
],
};
}
+870
View File
@@ -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.";
}
+339
View File
@@ -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>
);
}
+303
View File
@@ -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("_", " ");
}
+528
View File
@@ -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;
}
+54
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+118
View File
@@ -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
View File
@@ -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>
);
}
+540
View File
@@ -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`;
}
+155
View File
@@ -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`;
}
+558
View File
@@ -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;
}
}
+87
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+156
View File
@@ -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(".", ",");
}
+28
View File
@@ -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);
}
+858
View File
@@ -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;
}
+41
View File
@@ -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)
);
}
+90
View File
@@ -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",
},
];
}
+53
View File
@@ -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
+78
View File
@@ -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
);
}
+346
View File
@@ -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
);
}
+14
View File
@@ -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`;
}
+101
View File
@@ -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();
}
+43
View File
@@ -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}`
);
}
}
+480
View File
@@ -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;
}
+56
View File
@@ -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();
}
+163
View File
@@ -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
);
}
+169
View File
@@ -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
);
}
+49
View File
@@ -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";
}
+318
View File
@@ -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;
}
+421
View File
@@ -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}`;
}
+108
View File
@@ -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)$).*)",
],
};
+98
View File
@@ -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;
};
+98
View File
@@ -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;
};
+288
View File
@@ -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;
}>;
};
+34
View File
@@ -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"]
}
+4
View File
@@ -0,0 +1,4 @@
.garmin_tokens/
garmin-credentials.json
*.tmp
logs/
+13
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
garminconnect
python-dotenv
bleak
+8
View File
@@ -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%
+288
View File
@@ -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()
+379
View File
@@ -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()
+70
View File
@@ -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()
+166
View File
@@ -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()