diff --git a/README.md b/README.md new file mode 100644 index 0000000..2be54e2 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# VYTA + +**VYTA** is a self-hosted health dashboard for Garmin data. It turns activity, sleep, recovery and training signals into a private daily overview with VYTA Age, pace of aging, training load, sleep insights, push notifications and local Garmin sync. + +The project is built for people who want the feel of a polished health app without sending their personal health archive to a third-party dashboard. + +## What It Does + +- **VYTA Age**: a dynamic healthspan-style score based on VO2max, resting heart rate, HRV, sleep, steps and training signals. +- **Healthspan view**: weekly period display, animated orb, pace of aging and contributor breakdowns. +- **Today view**: Garmin-based recovery, strain, activity, sleep and energy summaries. +- **Sleep view**: sleep score, duration, sleep debt and sleep phase details. +- **Training view**: daily load, activity list, activity details and trend charts. +- **Notifications**: optional push alerts for recovery, HRV deviation, sleep, new activities, VYTA Age changes and more. +- **Garmin account setup in app**: each installation can connect its own Garmin account from settings. +- **Private local data**: runtime data, Garmin credentials and token stores are kept outside Git. + +## Repository Layout + +```text +app/vyta/ Next.js web app +sync/vyta-sync/ Python Garmin collector, normalizer and scheduler +``` + +Runtime data belongs outside the repository, normally in `/data` inside the containers and mounted from your server, for example: + +```text +/mnt/tank/appdata/vyta/data +``` + +## Privacy Model + +This repo intentionally does **not** include personal data or secrets. + +Never commit: + +```text +.env +.env.local +data/ +private-data/ +public/data/ +garmin-credentials.json +.garmin_tokens/ +vyta-garmin.json +vyta-data.json +settings.json +push-subscriptions.json +notification-state.json +``` + +Garmin credentials are saved by the app to: + +```text +/data/garmin-credentials.json +``` + +Garmin session tokens are saved by the sync service to: + +```text +/data/.garmin_tokens/ +``` + +Both paths should be Docker volumes, not repository files. + +## Configuration + +The web container expects environment variables for authentication, public URL, push notifications and internal sync communication. + +Common web variables: + +```text +VYTA_PUBLIC_URL +VYTA_USERNAME +VYTA_PASSWORD_HASH +VYTA_SESSION_SECRET +VYTA_DATA_PATH=/data/vyta-data.json +VYTA_SETTINGS_PATH=/data/settings.json +VYTA_PUSH_SUBSCRIPTIONS_PATH=/data/push-subscriptions.json +VYTA_SYNC_STATUS_PATH=/data/sync-status.json +VYTA_SYNC_REQUEST_PATH=/data/sync-request.json +VYTA_INTERNAL_SYNC_SECRET +NEXT_PUBLIC_VAPID_PUBLIC_KEY +VAPID_PRIVATE_KEY +VAPID_SUBJECT +``` + +Common sync variables: + +```text +VYTA_WEB_URL +VYTA_INTERNAL_SYNC_SECRET +VYTA_OUTPUT_PATH=/data/vyta-data.json +VYTA_SYNC_INTERVAL_SECONDS=300 +VYTA_SYNC_POLL_SECONDS=2 +VYTA_SYNC_STATUS_PATH=/data/sync-status.json +VYTA_SYNC_REQUEST_PATH=/data/sync-request.json +VYTA_GARMIN_CREDENTIALS_PATH=/data/garmin-credentials.json +VYTA_GARMIN_TOKENSTORE=/data/.garmin_tokens +TZ=Europe/Stockholm +``` + +## Build + +Web app: + +```bash +docker build -t vyta-web:latest app/vyta +``` + +Sync service: + +```bash +docker build -t vyta-sync:latest sync/vyta-sync +``` + +## Run Example + +Web: + +```bash +docker run -d \ + --name vyta \ + --restart unless-stopped \ + -p 3777:3000 \ + -v /mnt/tank/appdata/vyta/data:/data \ + --env-file /path/to/vyta.env \ + vyta-web:latest +``` + +Sync: + +```bash +docker run -d \ + --name vyta-sync \ + --restart unless-stopped \ + -v /mnt/tank/appdata/vyta/data:/data \ + --env-file /path/to/vyta-sync.env \ + vyta-sync:latest +``` + +## Garmin Setup + +1. Start the web app. +2. Sign in to VYTA. +3. Open **Inställningar**. +4. Connect Garmin under **Garmin-konto**. +5. The app stores credentials in `/data/garmin-credentials.json` and triggers sync. +6. The sync service uses `/data/.garmin_tokens/` after the first successful login. + +Each clone or deployment can connect its own Garmin account without changing the code. + +## Development + +Web app: + +```bash +cd app/vyta +npm install +npm run dev +``` + +Sync service: + +```bash +cd sync/vyta-sync +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +python sync.py +``` + +## Roadmap + +- Language selection in settings: Swedish and English UI. +- More install-friendly Docker Compose template. +- Better onboarding for first-time Garmin setup. +- Optional multi-user support. +- More Healthspan contributor tuning as more Garmin signals become available. + +## Status + +VYTA is a personal, self-hosted health app. It is not a medical device and does not provide clinical advice. diff --git a/app/vyta/README.md b/app/vyta/README.md index e215bc4..affe455 100644 --- a/app/vyta/README.md +++ b/app/vyta/README.md @@ -1,36 +1,3 @@ -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). +# VYTA Web 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. +Next.js frontend for VYTA. See the repository root README for setup, privacy notes and deployment instructions. diff --git a/app/vyta/package-lock.json b/app/vyta/package-lock.json index 2349344..54bdb3d 100644 --- a/app/vyta/package-lock.json +++ b/app/vyta/package-lock.json @@ -8,7 +8,6 @@ "name": "vyta", "version": "0.1.0", "dependencies": { - "@vercel/blob": "^2.8.0", "bcryptjs": "^3.0.3", "jose": "^6.2.8", "leaflet": "^1.9.4", @@ -2333,77 +2332,6 @@ "win32" ] }, - "node_modules/@vercel/blob": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.8.0.tgz", - "integrity": "sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==", - "license": "Apache-2.0", - "dependencies": { - "@vercel/oidc": "^3.6.1", - "async-retry": "^1.3.3", - "is-buffer": "^2.0.5", - "is-node-process": "^1.2.0", - "throttleit": "^2.1.0", - "undici": "^6.23.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@vercel/cli-config": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.3.tgz", - "integrity": "sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==", - "license": "Apache-2.0", - "dependencies": { - "xdg-app-paths": "5", - "zod": "4.1.11" - } - }, - "node_modules/@vercel/cli-config/node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@vercel/cli-exec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-1.0.1.tgz", - "integrity": "sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==", - "license": "Apache-2.0", - "dependencies": { - "execa": "5.1.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@vercel/oidc": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.8.4.tgz", - "integrity": "sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==", - "license": "Apache-2.0", - "dependencies": { - "@vercel/cli-config": "0.2.3", - "@vercel/cli-exec": "1.0.1", - "jose": "^5.9.6" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@vercel/oidc/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", diff --git a/app/vyta/package.json b/app/vyta/package.json index b0cff05..c18bfd8 100644 --- a/app/vyta/package.json +++ b/app/vyta/package.json @@ -9,7 +9,6 @@ "lint": "eslint" }, "dependencies": { - "@vercel/blob": "^2.8.0", "bcryptjs": "^3.0.3", "jose": "^6.2.8", "leaflet": "^1.9.4", diff --git a/app/vyta/src/app/api/auth/callback/pocketid/route.ts b/app/vyta/src/app/api/auth/callback/pocketid/route.ts index 06418a4..5117241 100644 --- a/app/vyta/src/app/api/auth/callback/pocketid/route.ts +++ b/app/vyta/src/app/api/auth/callback/pocketid/route.ts @@ -171,7 +171,7 @@ export async function GET( const publicUrl = process.env .VYTA_PUBLIC_URL || - "https://vyta.bitli.se"; + "http://localhost:3000"; return NextResponse.redirect( new URL( @@ -181,3 +181,4 @@ export async function GET( ); } } + diff --git a/app/vyta/src/app/api/garmin/connection/route.ts b/app/vyta/src/app/api/garmin/connection/route.ts old mode 100755 new mode 100644 diff --git a/app/vyta/src/app/api/logout/route.ts b/app/vyta/src/app/api/logout/route.ts index 804a3ac..d8df3fc 100644 --- a/app/vyta/src/app/api/logout/route.ts +++ b/app/vyta/src/app/api/logout/route.ts @@ -15,7 +15,7 @@ export async function POST() { new URL( "/login", publicUrl || - "https://vyta.bitli.se" + "http://localhost:3000" ), 303 ); @@ -45,3 +45,5 @@ export async function POST() { return response; } + + diff --git a/app/vyta/src/components/GarminConnectionSettings.tsx b/app/vyta/src/components/GarminConnectionSettings.tsx old mode 100755 new mode 100644 diff --git a/app/vyta/src/lib/garminConnection.ts b/app/vyta/src/lib/garminConnection.ts old mode 100755 new mode 100644 diff --git a/sync/vyta-sync/run_sync.cmd b/sync/vyta-sync/run_sync.cmd deleted file mode 100755 index 1d88b26..0000000 --- a/sync/vyta-sync/run_sync.cmd +++ /dev/null @@ -1,8 +0,0 @@ -@echo off - -cd /d "C:\Users\Silicon Solder\Documents\Codex\vyta-sync" - -"C:\Users\Silicon Solder\Documents\Codex\vyta-sync\.venv\Scripts\python.exe" ^ -"C:\Users\Silicon Solder\Documents\Codex\vyta-sync\sync.py" - -exit /b %errorlevel% \ No newline at end of file diff --git a/sync/vyta-sync/sync.py b/sync/vyta-sync/sync.py index 73b46df..ccb50a1 100644 --- a/sync/vyta-sync/sync.py +++ b/sync/vyta-sync/sync.py @@ -21,10 +21,6 @@ NORMALIZER = os.path.join( "normalizer.py", ) -UPLOADER = os.path.join( - BASE_DIR, - "upload_to_blob.py", -) LOG_DIR = os.path.join( BASE_DIR, @@ -179,24 +175,6 @@ def run_script( ) -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(): @@ -312,15 +290,6 @@ def main(): NORMALIZER ) - if should_upload_to_blob(): - run_script( - UPLOADER - ) - else: - log( - "Hoppar över Vercel Blob-upload" - ) - notify_vyta() completed_at = utc_now() @@ -377,3 +346,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/sync/vyta-sync/test_garmin.py b/sync/vyta-sync/test_garmin.py deleted file mode 100644 index 23ed146..0000000 --- a/sync/vyta-sync/test_garmin.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -import os -from datetime import date -from getpass import getpass - -from garminconnect import Garmin - - -TOKENSTORE = os.path.join(os.path.dirname(__file__), ".garmin_tokens") - - -def dump(title, data): - print() - print("=" * 70) - print(title) - print("=" * 70) - print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) - - -def main(): - email = input("Garmin email: ").strip() - password = getpass("Garmin password: ") - - garmin = Garmin( - email=email, - password=password, - ) - - os.makedirs(TOKENSTORE, exist_ok=True) - - print("\nLoggar in mot Garmin Connect...") - - result = garmin.login(TOKENSTORE) - - print("Inloggning OK.") - print("Display name:", garmin.display_name) - - today = date.today().isoformat() - - print(f"\nHämtar Garmin-data för {today}...") - - calls = [ - ("Daily stats", lambda: garmin.get_stats(today)), - ("Heart rate", lambda: garmin.get_heart_rates(today)), - ("Sleep", lambda: garmin.get_sleep_data(today)), - ("HRV", lambda: garmin.get_hrv_data(today)), - ("Stress", lambda: garmin.get_stress_data(today)), - ("Body Battery", lambda: garmin.get_body_battery(today)), - ("Respiration", lambda: garmin.get_respiration_data(today)), - ("SpO2", lambda: garmin.get_spo2_data(today)), - ("Training readiness", lambda: garmin.get_training_readiness(today)), - ("Training status", lambda: garmin.get_training_status(today)), - ("Activities", lambda: garmin.get_activities(0, 10)), - ("Devices", lambda: garmin.get_devices()), - ] - - for title, fn in calls: - try: - data = fn() - dump(title, data) - except Exception as e: - print() - print("=" * 70) - print(title) - print("=" * 70) - print(f"ERROR: {type(e).__name__}: {e}") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/sync/vyta-sync/upload_to_blob.py b/sync/vyta-sync/upload_to_blob.py deleted file mode 100644 index 07213a9..0000000 --- a/sync/vyta-sync/upload_to_blob.py +++ /dev/null @@ -1,166 +0,0 @@ -import os -import shutil -import subprocess -import sys -from pathlib import Path - -from dotenv import load_dotenv - - -# ------------------------------------------------------------ -# CONFIG -# ------------------------------------------------------------ - -SCRIPT_DIR = Path(__file__).resolve().parent - -VYTA_PROJECT = Path( - r"C:\Users\Silicon Solder\Documents\Codex\vyta" -) - -DATA_FILE = ( - VYTA_PROJECT - / "private-data" - / "vyta-data.json" -) - -BLOB_PATH = "vyta/vyta-data.json" - - -# Läs vyta-sync\.env -load_dotenv(SCRIPT_DIR / ".env") - - -# ------------------------------------------------------------ -# HELPERS -# ------------------------------------------------------------ - -def find_vercel_cli() -> str: - """ - Hitta Vercel CLI. - - På Windows ligger global npm CLI normalt som vercel.cmd. - """ - - candidates = [ - "vercel.cmd", - "vercel", - ] - - for candidate in candidates: - executable = shutil.which(candidate) - - if executable: - return executable - - # Vanlig npm global-bin på Windows - appdata = os.environ.get("APPDATA") - - if appdata: - fallback = Path(appdata) / "npm" / "vercel.cmd" - - if fallback.exists(): - return str(fallback) - - raise FileNotFoundError( - "Kunde inte hitta Vercel CLI. " - "Kontrollera att 'vercel --version' fungerar i PowerShell." - ) - - -def main(): - print("=" * 60) - print("VYTA → VERCEL PRIVATE BLOB") - print("=" * 60) - - # -------------------------------------------------------- - # Kontrollera datafil - # -------------------------------------------------------- - - if not DATA_FILE.exists(): - print() - print("ERROR: Vyta-datafilen saknas:") - print(DATA_FILE) - sys.exit(1) - - print() - print(f"Datafil: {DATA_FILE}") - print(f"Storlek: {DATA_FILE.stat().st_size:,} bytes") - - # -------------------------------------------------------- - # Blob-token - # -------------------------------------------------------- - - token = os.environ.get( - "BLOB_READ_WRITE_TOKEN" - ) - - if not token: - print() - print( - "ERROR: BLOB_READ_WRITE_TOKEN saknas i vyta-sync\\.env" - ) - sys.exit(1) - - print("Blob-token: OK") - - # -------------------------------------------------------- - # Vercel CLI - # -------------------------------------------------------- - - try: - vercel_cli = find_vercel_cli() - except FileNotFoundError as exc: - print() - print(f"ERROR: {exc}") - sys.exit(1) - - print(f"Vercel CLI: {vercel_cli}") - - # -------------------------------------------------------- - # Upload - # -------------------------------------------------------- - - command = [ - vercel_cli, - "blob", - "put", - str(DATA_FILE), - "--pathname", - BLOB_PATH, - "--access", - "private", - "--allow-overwrite", - "--content-type", - "application/json", - "--rw-token", - token, - ] - - print() - print( - f"Laddar upp till Private Blob: {BLOB_PATH}" - ) - print() - - result = subprocess.run( - command, - cwd=str(VYTA_PROJECT), - text=True, - ) - - if result.returncode != 0: - print() - print( - f"ERROR: Blob-upload misslyckades " - f"(exit code {result.returncode})" - ) - sys.exit(result.returncode) - - print() - print("=" * 60) - print("VYTA BLOB-UPLOAD KLAR") - print("=" * 60) - - -if __name__ == "__main__": - main() \ No newline at end of file