Initial project setup for PhaseFlow
Set up Next.js 16 project with TypeScript for a training decision app that integrates menstrual cycle phases with Garmin biometrics for Hashimoto's thyroiditis management. Stack: Next.js 16, React 19, Tailwind/shadcn, PocketBase, Drizzle, Zod, Resend, Vitest, Biome, Lefthook, Nix dev environment. Includes: - 7 page routes (dashboard, login, settings, calendar, history, plan) - 12 API endpoints (garmin, user, cycle, calendar, overrides, cron) - Core lib utilities (decision engine, cycle phases, nutrition, ICS) - Type definitions and component scaffolding - Python script for Garmin token bootstrapping - Initial unit tests for cycle utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
21
.env.example
Normal file
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# ABOUTME: Example environment variables for PhaseFlow.
|
||||
# ABOUTME: Copy to .env and fill in your values.
|
||||
|
||||
# App
|
||||
APP_URL=https://phaseflow.yourdomain.com
|
||||
NODE_ENV=development
|
||||
|
||||
# PocketBase
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
EMAIL_FROM=phaseflow@yourdomain.com
|
||||
|
||||
# Encryption (for Garmin tokens)
|
||||
# Generate with: openssl rand -hex 16
|
||||
ENCRYPTION_KEY=your-32-character-encryption-key
|
||||
|
||||
# Cron secret (for protected endpoints)
|
||||
# Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=your-cron-secret-here
|
||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# nix
|
||||
result
|
||||
.direnv/
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
36
README.md
Normal file
36
README.md
Normal 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.
|
||||
46
biome.json
Normal file
46
biome.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767892417,
|
||||
"narHash": "sha256-dhhvQY67aboBk8b0/u0XB6vwHdgbROZT3fJAjyNh5Ww=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
23
flake.nix
Normal file
23
flake.nix
Normal file
@@ -0,0 +1,23 @@
|
||||
# ABOUTME: Nix flake for PhaseFlow development environment.
|
||||
# ABOUTME: Provides Node.js 24, pnpm, turbo, and lefthook.
|
||||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { nixpkgs, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_24
|
||||
pnpm
|
||||
turbo
|
||||
lefthook
|
||||
];
|
||||
|
||||
# For native modules (sharp, better-sqlite3, etc.)
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
|
||||
};
|
||||
};
|
||||
}
|
||||
9
lefthook.yml
Normal file
9
lefthook.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
# ABOUTME: Pre-commit hooks configuration for code quality enforcement.
|
||||
# ABOUTME: Runs biome linting and vitest tests before allowing commits.
|
||||
pre-commit:
|
||||
parallel: true
|
||||
commands:
|
||||
lint:
|
||||
run: pnpm biome check --staged --no-errors-on-unmatched
|
||||
test:
|
||||
run: pnpm test:run
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "phaseflow",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"ics": "^3.8.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"pocketbase": "^0.26.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"resend": "^6.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^20",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"jsdom": "^27.4.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
3570
pnpm-lock.yaml
generated
Normal file
3570
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- .
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
public/globe.svg
Normal file
1
public/globe.svg
Normal 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
public/next.svg
Normal file
1
public/next.svg
Normal 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 |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal 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
public/window.svg
Normal file
1
public/window.svg
Normal 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 |
37
scripts/garmin_auth.py
Normal file
37
scripts/garmin_auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
# ABOUTME: PhaseFlow Garmin Token Generator using garth library.
|
||||
# ABOUTME: Run this locally to authenticate with Garmin (supports MFA).
|
||||
"""
|
||||
PhaseFlow Garmin Token Generator
|
||||
Run this locally to authenticate with Garmin (supports MFA)
|
||||
|
||||
Usage:
|
||||
pip install garth
|
||||
python3 garmin_auth.py
|
||||
"""
|
||||
import json
|
||||
from getpass import getpass
|
||||
|
||||
try:
|
||||
import garth
|
||||
except ImportError:
|
||||
print("Error: garth library not installed.")
|
||||
print("Please install it with: pip install garth")
|
||||
exit(1)
|
||||
|
||||
email = input("Garmin email: ")
|
||||
password = getpass("Garmin password: ")
|
||||
|
||||
# MFA handled automatically - prompts if needed
|
||||
garth.login(email, password)
|
||||
|
||||
tokens = {
|
||||
"oauth1": garth.client.oauth1_token.serialize(),
|
||||
"oauth2": garth.client.oauth2_token.serialize(),
|
||||
"expires_at": garth.client.oauth2_token.expires_at.isoformat()
|
||||
}
|
||||
|
||||
print("\n--- Copy everything below this line ---")
|
||||
print(json.dumps(tokens, indent=2))
|
||||
print("--- Copy everything above this line ---")
|
||||
print(f"\nTokens expire: {tokens['expires_at']}")
|
||||
855
spec.md
Normal file
855
spec.md
Normal file
@@ -0,0 +1,855 @@
|
||||
# PhaseFlow - MVP Specification
|
||||
|
||||
## Overview
|
||||
|
||||
A self-hosted webapp that automates training decisions for individuals with Hashimoto's thyroiditis by integrating menstrual cycle phases with biometric data from Garmin wearables. Provides daily actionable guidance via email notifications and calendar integration.
|
||||
|
||||
**Core Value Proposition**: Zero daily effort. Data-driven, body-respectful training decisions delivered automatically each morning.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice |
|
||||
|-------|--------|
|
||||
| Language | TypeScript (strict) |
|
||||
| Runtime | Node.js 24 LTS |
|
||||
| Framework | Next.js 16 (App Router) |
|
||||
| Database | PocketBase (SQLite, auth, real-time) |
|
||||
| ORM | Drizzle (for any direct queries) |
|
||||
| Validation | Zod |
|
||||
| UI | Tailwind + shadcn/ui |
|
||||
| Scheduling | Node-cron or PocketBase hooks |
|
||||
| Email | Resend or Nodemailer |
|
||||
| Calendar | ICS feed (user subscribes via URL) |
|
||||
| Garmin | Python `garth` library for token bootstrap |
|
||||
| Deployment | Nomad + Traefik (homelab) |
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Garmin Integration
|
||||
|
||||
**Data Points Fetched Daily**:
|
||||
|
||||
| Metric | Source | Purpose |
|
||||
|--------|--------|---------|
|
||||
| Body Battery (current) | Garmin Connect | Assess readiness to train |
|
||||
| Body Battery (yesterday's low) | Garmin Connect | Detect recovery adequacy |
|
||||
| HRV Status | Garmin Connect | Detect autonomic stress |
|
||||
| Weekly Intensity Minutes | Garmin Connect | Track cumulative load |
|
||||
|
||||
#### Authentication Strategy
|
||||
|
||||
The npm `garmin-connect` library does **not** support MFA. The Python `garth` library does. Since MFA is required, we use a hybrid approach:
|
||||
|
||||
**Token Bootstrap Flow**:
|
||||
1. User runs a provided Python script locally (or via container exec):
|
||||
```bash
|
||||
# One-time setup script included with PhaseFlow
|
||||
python3 garmin_auth.py
|
||||
```
|
||||
2. Script uses `garth` to authenticate (prompts for MFA code)
|
||||
3. Script outputs OAuth1 + OAuth2 tokens as JSON
|
||||
4. User pastes tokens into PhaseFlow settings page
|
||||
5. App stores tokens encrypted in PocketBase
|
||||
6. App uses tokens directly for Garmin API calls (no library needed for data fetch, just HTTP with Bearer auth)
|
||||
|
||||
**Token Lifecycle**:
|
||||
- Tokens valid for ~90 days
|
||||
- App tracks expiry, warns user 7 days before
|
||||
- User re-runs bootstrap script when needed
|
||||
- Future: auto-refresh if garth adds programmatic refresh support
|
||||
|
||||
**Garmin API Calls** (with stored tokens):
|
||||
```typescript
|
||||
// Direct HTTP calls using stored OAuth2 token
|
||||
const response = await fetch('https://connect.garmin.com/modern/proxy/usersummary-service/stats/heartRate/daily/2025-01-09', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${oauth2Token}`,
|
||||
'NK': 'NT'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Provided Bootstrap Script** (`scripts/garmin_auth.py`):
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PhaseFlow Garmin Token Generator
|
||||
Run this locally to authenticate with Garmin (supports MFA)
|
||||
"""
|
||||
import garth
|
||||
import json
|
||||
from getpass import getpass
|
||||
|
||||
email = input("Garmin email: ")
|
||||
password = getpass("Garmin password: ")
|
||||
|
||||
# MFA handled automatically - prompts if needed
|
||||
garth.login(email, password)
|
||||
|
||||
tokens = {
|
||||
"oauth1": garth.client.oauth1_token.serialize(),
|
||||
"oauth2": garth.client.oauth2_token.serialize(),
|
||||
"expires_at": garth.client.oauth2_token.expires_at.isoformat()
|
||||
}
|
||||
|
||||
print("\n--- Copy everything below this line ---")
|
||||
print(json.dumps(tokens, indent=2))
|
||||
print("--- Copy everything above this line ---")
|
||||
print(f"\nTokens expire: {tokens['expires_at']}")
|
||||
```
|
||||
|
||||
### 2. Cycle Phase Engine
|
||||
|
||||
**Phase Definitions** (based on cycle day from last period):
|
||||
|
||||
| Phase | Days | Weekly Limit | Daily Avg | Training Type |
|
||||
|-------|------|--------------|-----------|---------------|
|
||||
| MENSTRUAL | 1-3 | 30 min | 10 min | Gentle rebounding only |
|
||||
| FOLLICULAR | 4-14 | 120 min | 17 min | Strength + rebounding |
|
||||
| OVULATION | 15-16 | 80 min | 40 min | Peak performance |
|
||||
| EARLY LUTEAL | 17-24 | 100 min | 14 min | Moderate training |
|
||||
| **LATE LUTEAL** ⚠️ | 25-31 | 50 min | 8 min | **Gentle rebounding ONLY** |
|
||||
|
||||
**Configuration**:
|
||||
- Last period date (user updates when period arrives)
|
||||
- Average cycle length (default: 31 days, adjustable)
|
||||
- Cycle day formula: `((daysSinceLastPeriod) % cycleLength) + 1`
|
||||
|
||||
### 3. Training Decision Engine
|
||||
|
||||
**Decision Priority (evaluated in order)**:
|
||||
|
||||
```typescript
|
||||
type DecisionStatus = 'REST' | 'GENTLE' | 'LIGHT' | 'REDUCED' | 'TRAIN';
|
||||
|
||||
interface Decision {
|
||||
status: DecisionStatus;
|
||||
reason: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function getTrainingDecision(data: DailyData): Decision {
|
||||
const { hrvStatus, bbYesterdayLow, phase, weekIntensity, phaseLimit, bbCurrent } = data;
|
||||
|
||||
if (hrvStatus === 'Unbalanced')
|
||||
return { status: 'REST', reason: 'HRV Unbalanced', icon: '🛑' };
|
||||
|
||||
if (bbYesterdayLow < 30)
|
||||
return { status: 'REST', reason: 'BB too depleted', icon: '🛑' };
|
||||
|
||||
if (phase === 'LATE_LUTEAL')
|
||||
return { status: 'GENTLE', reason: 'Gentle rebounding only (10-15min)', icon: '🟡' };
|
||||
|
||||
if (phase === 'MENSTRUAL')
|
||||
return { status: 'GENTLE', reason: 'Gentle rebounding only (10min)', icon: '🟡' };
|
||||
|
||||
if (weekIntensity >= phaseLimit)
|
||||
return { status: 'REST', reason: 'WEEKLY LIMIT REACHED - Rest', icon: '🛑' };
|
||||
|
||||
if (bbCurrent < 75)
|
||||
return { status: 'LIGHT', reason: 'Light activity only - BB not recovered', icon: '🟡' };
|
||||
|
||||
if (bbCurrent < 85)
|
||||
return { status: 'REDUCED', reason: 'Reduce intensity 25%', icon: '🟡' };
|
||||
|
||||
return { status: 'TRAIN', reason: 'OK to train - follow phase plan', icon: '✅' };
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Daily Email Notification
|
||||
|
||||
**Delivery**: Email at user-configured time (default 7:00 AM local)
|
||||
|
||||
**Content Structure**:
|
||||
```
|
||||
Subject: Today's Training: ✅ TRAIN (or 🛑 REST or 🟡 GENTLE)
|
||||
|
||||
Good morning!
|
||||
|
||||
📅 CYCLE DAY: 12 (FOLLICULAR)
|
||||
|
||||
💪 TODAY'S PLAN:
|
||||
✅ OK to train - follow phase plan
|
||||
|
||||
📊 YOUR DATA:
|
||||
• Body Battery Now: 95
|
||||
• Yesterday's Low: 42
|
||||
• HRV Status: Balanced
|
||||
• Week Intensity: 85 / 120 minutes
|
||||
• Remaining: 35 minutes
|
||||
|
||||
🏋️ EXERCISE:
|
||||
Great day for strength training! Do your full workout.
|
||||
|
||||
🌱 SEEDS: Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)
|
||||
|
||||
🍽️ MACROS: Variable (can go low 20-100g).
|
||||
🥑 KETO: OPTIONAL - this is your optimal keto window if you want to try!
|
||||
Days 7-10: Transition in | Days 11-14: Full keto | Day 14 evening: Transition out
|
||||
|
||||
---
|
||||
Auto-generated by PhaseFlow
|
||||
```
|
||||
|
||||
### 5. Nutrition Guidance
|
||||
|
||||
**Seed Cycling** (included in daily email):
|
||||
|
||||
| Days | Seeds |
|
||||
|------|-------|
|
||||
| 1-14 | Flax (1-2 tbsp) + Pumpkin (1-2 tbsp) |
|
||||
| 15+ | Sesame (1-2 tbsp) + Sunflower (1-2 tbsp) |
|
||||
|
||||
Day 15 alert: "🌱 SWITCH TODAY! Start Sesame + Sunflower"
|
||||
|
||||
**Macro Guidance by Phase**:
|
||||
|
||||
| Days | Carb Range | Keto Guidance |
|
||||
|------|------------|---------------|
|
||||
| 1-3 | 100-150g | No - body needs carbs during menstruation |
|
||||
| 4-6 | 75-100g | No - transition phase |
|
||||
| 7-14 | 20-100g | OPTIONAL - optimal keto window |
|
||||
| 15-16 | 100-150g | No - exit keto, need carbs for ovulation |
|
||||
| 17-24 | 75-125g | No - progesterone needs carbs |
|
||||
| 25-31 | 100-150g+ | NEVER - mood/hormones need carbs for PMS |
|
||||
|
||||
### 6. Calendar Integration (ICS Feed)
|
||||
|
||||
**Approach**: Serve a dynamic ICS/iCal file that users subscribe to in any calendar app.
|
||||
|
||||
**Why ICS Feed vs Google Calendar API**:
|
||||
- No Google OAuth required
|
||||
- Works with Google Calendar, Apple Calendar, Outlook, etc.
|
||||
- No Google Cloud project setup
|
||||
- User just adds URL: "Add calendar from URL"
|
||||
- Calendar apps poll automatically (Google: ~12-24 hours)
|
||||
- When period date changes, feed regenerates - picked up on next poll
|
||||
|
||||
**Feed URL**: `https://phaseflow.yourdomain.com/api/calendar/{userId}/{token}.ics`
|
||||
|
||||
- `userId`: identifies the user
|
||||
- `token`: random secret token for security (regeneratable)
|
||||
|
||||
**ICS Content**:
|
||||
```ics
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//PhaseFlow//Cycle Calendar//EN
|
||||
NAME:PhaseFlow Cycle
|
||||
X-WR-CALNAME:PhaseFlow Cycle
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20250105
|
||||
DTEND;VALUE=DATE:20250108
|
||||
SUMMARY:🔵 MENSTRUAL
|
||||
DESCRIPTION:Gentle rebounding only (10 min max)
|
||||
COLOR:blue
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20250108
|
||||
DTEND;VALUE=DATE:20250119
|
||||
SUMMARY:🟢 FOLLICULAR
|
||||
DESCRIPTION:Strength training + rebounding (15-20 min/day avg)
|
||||
COLOR:green
|
||||
END:VEVENT
|
||||
...
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20250126
|
||||
DTEND;VALUE=DATE:20250126
|
||||
SUMMARY:⚠️ Late Luteal Starts in 3 Days
|
||||
DESCRIPTION:Begin reducing training intensity
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20250129
|
||||
DTEND;VALUE=DATE:20250129
|
||||
SUMMARY:🔴 CRITICAL PHASE - Gentle Rebounding Only!
|
||||
DESCRIPTION:Late luteal phase - protect your cycle
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
```
|
||||
|
||||
**Phase Colors** (as emoji prefix since ICS color support varies):
|
||||
|
||||
| Phase | Emoji | Description |
|
||||
|-------|-------|-------------|
|
||||
| MENSTRUAL | 🔵 | Blue |
|
||||
| FOLLICULAR | 🟢 | Green |
|
||||
| OVULATION | 🟣 | Purple |
|
||||
| EARLY LUTEAL | 🟡 | Yellow |
|
||||
| LATE LUTEAL | 🔴 | Red |
|
||||
|
||||
**Warning Events**:
|
||||
- Day 22: "⚠️ Late Luteal Phase Starts in 3 Days - Reduce Training 50%"
|
||||
- Day 25: "🔴 CRITICAL PHASE - Gentle Rebounding Only!"
|
||||
|
||||
### 7. In-App Calendar View
|
||||
|
||||
**Display**:
|
||||
- Monthly calendar showing phase colors
|
||||
- Current day highlighted
|
||||
- Click day to see that day's data/decision
|
||||
- Visual indicator when approaching late luteal
|
||||
|
||||
### 8. Period Logging
|
||||
|
||||
**User Flow**:
|
||||
1. Click "Period Started" button
|
||||
2. Confirm date (defaults to today, can select past date)
|
||||
3. System recalculates all phase dates
|
||||
4. ICS feed automatically reflects new dates
|
||||
5. Confirmation email sent
|
||||
|
||||
**Confirmation Email**:
|
||||
```
|
||||
Subject: 🔵 Period Tracking Updated
|
||||
|
||||
Your cycle has been reset. Last period: [date]
|
||||
Phase calendar updated for next [cycle_length] days.
|
||||
|
||||
Your calendar will update automatically within 24 hours.
|
||||
```
|
||||
|
||||
### 9. Emergency Modifications
|
||||
|
||||
User-triggered overrides accessible from dashboard:
|
||||
|
||||
| Override | Effect | Duration |
|
||||
|----------|--------|----------|
|
||||
| Hashimoto's flare | All training → gentle rebounding only | Until deactivated |
|
||||
| High stress week | Reduce all intensity limits by 50% | Until deactivated |
|
||||
| Poor sleep | Replace strength with gentle rebounding | Until deactivated |
|
||||
| PMS symptoms | Extra rebounding, no strength training | Until deactivated |
|
||||
|
||||
These overrides apply on top of the normal decision engine.
|
||||
|
||||
### 10. Exercise Plan Reference
|
||||
|
||||
**Accessible in-app** - the full monthly exercise plan:
|
||||
|
||||
#### Week 1: Menstrual Phase (Days 1-7)
|
||||
- Morning: 10-15 min gentle rebounding
|
||||
- Evening: 15-20 min restorative movement
|
||||
- No strength training
|
||||
|
||||
#### Week 2: Follicular Phase (Days 8-14)
|
||||
- Mon/Wed/Fri: Strength training (20-25 min)
|
||||
- Squats: 3x8-12
|
||||
- Push-ups: 3x5-10
|
||||
- Single-leg deadlifts: 3x6-8 each
|
||||
- Plank: 3x20-45s
|
||||
- Kettlebell swings: 2x10-15
|
||||
- Tue/Thu: Active recovery rebounding (20 min)
|
||||
- Weekend: Choose your adventure
|
||||
|
||||
#### Week 3: Ovulation + Early Luteal (Days 15-24)
|
||||
- Days 15-16: Peak performance (25-30 min strength)
|
||||
- Days 17-21: Modified strength (reduce intensity 10-20%)
|
||||
|
||||
#### Week 4: Late Luteal (Days 22-28)
|
||||
- Daily: Gentle rebounding only (15-20 min)
|
||||
- Optional light bodyweight Mon/Wed if feeling good
|
||||
- Rest days: Tue/Thu/Sat/Sun
|
||||
|
||||
**Rebounding Techniques by Phase**:
|
||||
- Menstrual: Health bounce, lymphatic drainage
|
||||
- Follicular: Strength bounce, intervals
|
||||
- Ovulation: Maximum intensity, plyometric
|
||||
- Luteal: Therapeutic, stress relief
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Users (PocketBase collection)
|
||||
```typescript
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
// Garmin
|
||||
garminConnected: boolean;
|
||||
garminOauth1Token: string; // encrypted JSON
|
||||
garminOauth2Token: string; // encrypted JSON
|
||||
garminTokenExpiresAt: Date;
|
||||
|
||||
// Calendar
|
||||
calendarToken: string; // random secret for ICS URL
|
||||
|
||||
// Cycle
|
||||
lastPeriodDate: Date;
|
||||
cycleLength: number; // default: 31
|
||||
|
||||
// Preferences
|
||||
notificationTime: string; // "07:00"
|
||||
timezone: string;
|
||||
|
||||
// Overrides
|
||||
activeOverrides: string[]; // ['flare', 'stress', 'sleep', 'pms']
|
||||
|
||||
created: Date;
|
||||
updated: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Daily Logs (PocketBase collection)
|
||||
```typescript
|
||||
interface DailyLog {
|
||||
id: string;
|
||||
user: string; // relation
|
||||
date: Date;
|
||||
cycleDay: number;
|
||||
phase: 'MENSTRUAL' | 'FOLLICULAR' | 'OVULATION' | 'EARLY_LUTEAL' | 'LATE_LUTEAL';
|
||||
bodyBatteryCurrent: number | null;
|
||||
bodyBatteryYesterdayLow: number | null;
|
||||
hrvStatus: 'Balanced' | 'Unbalanced' | 'Unknown';
|
||||
weekIntensityMinutes: number;
|
||||
phaseLimit: number;
|
||||
remainingMinutes: number;
|
||||
trainingDecision: string;
|
||||
decisionReason: string;
|
||||
notificationSentAt: Date | null;
|
||||
created: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Period Logs (PocketBase collection)
|
||||
```typescript
|
||||
interface PeriodLog {
|
||||
id: string;
|
||||
user: string; // relation
|
||||
startDate: Date;
|
||||
created: Date;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
```
|
||||
# Auth (PocketBase handles user auth)
|
||||
POST /api/auth/login
|
||||
POST /api/auth/register
|
||||
POST /api/auth/logout
|
||||
|
||||
# Garmin Token Management
|
||||
POST /api/garmin/tokens - Store tokens from bootstrap script
|
||||
DELETE /api/garmin/tokens - Clear stored tokens
|
||||
GET /api/garmin/status - Check connection & token expiry
|
||||
|
||||
# User Settings
|
||||
GET /api/user - Get profile + settings
|
||||
PATCH /api/user - Update settings
|
||||
|
||||
# Cycle
|
||||
POST /api/cycle/period - Log period start
|
||||
GET /api/cycle/current - Current phase info
|
||||
|
||||
# Daily
|
||||
GET /api/today - Today's decision + all data
|
||||
GET /api/history - Historical logs
|
||||
|
||||
# Calendar
|
||||
GET /api/calendar/:userId/:token.ics - ICS feed (public, token-protected)
|
||||
POST /api/calendar/regenerate-token - Generate new calendar token
|
||||
|
||||
# Overrides
|
||||
POST /api/overrides - Set active overrides
|
||||
DELETE /api/overrides/:type - Remove override
|
||||
|
||||
# Cron (internal, protected)
|
||||
POST /api/cron/garmin-sync - Fetch Garmin data (6 AM)
|
||||
POST /api/cron/notifications - Send emails (7 AM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Jobs
|
||||
|
||||
| Job | Schedule | Function |
|
||||
|-----|----------|----------|
|
||||
| Garmin Sync | Daily at user's configured time (default 6:00) | Fetch BB, HRV, intensity |
|
||||
| Morning Email | Daily at user's configured time + 1hr (default 7:00) | Send training decision |
|
||||
| Token Expiry Check | Daily | Warn user if tokens expire within 7 days |
|
||||
|
||||
**Implementation**:
|
||||
- Nomad periodic jobs hitting internal API endpoints
|
||||
- Or: node-cron within the Next.js app
|
||||
- Or: PocketBase hooks + external cron
|
||||
|
||||
---
|
||||
|
||||
## Page Structure
|
||||
|
||||
```
|
||||
src/app/
|
||||
page.tsx # Dashboard (today's decision, quick actions)
|
||||
login/page.tsx # Auth
|
||||
settings/page.tsx # Profile, Garmin tokens, notification time
|
||||
settings/garmin/page.tsx # Garmin token paste UI + instructions
|
||||
calendar/page.tsx # In-app calendar view + ICS subscription instructions
|
||||
history/page.tsx # Historical data table
|
||||
plan/page.tsx # Exercise plan reference
|
||||
api/
|
||||
auth/...
|
||||
garmin/tokens/route.ts
|
||||
garmin/status/route.ts
|
||||
user/route.ts
|
||||
cycle/period/route.ts
|
||||
cycle/current/route.ts
|
||||
today/route.ts
|
||||
history/route.ts
|
||||
calendar/[userId]/[token].ics/route.ts
|
||||
calendar/regenerate-token/route.ts
|
||||
overrides/route.ts
|
||||
cron/garmin-sync/route.ts
|
||||
cron/notifications/route.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PhaseFlow [Settings]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📅 Day 12 of 31 • FOLLICULAR │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (progress bar) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ✅ OK TO TRAIN │ │
|
||||
│ │ Follow your phase plan - strength training day! │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ YOUR DATA NUTRITION TODAY │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐│
|
||||
│ │ Body Battery: 95 │ │ 🌱 Flax + Pumpkin ││
|
||||
│ │ Yesterday Low: 42 │ │ 🍽️ Carbs: 20-100g ││
|
||||
│ │ HRV: Balanced │ │ 🥑 Keto: Optional ││
|
||||
│ │ Week: 85/120 min │ └──────────────────────┘│
|
||||
│ │ Remaining: 35 min │ │
|
||||
│ └──────────────────────┘ QUICK ACTIONS │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ OVERRIDES │ [Period Started] │ │
|
||||
│ ┌──────────────────────┐ │ [View Plan] │ │
|
||||
│ │ ○ Flare Mode │ │ [Calendar] │ │
|
||||
│ │ ○ High Stress │ └──────────────────────┘ │
|
||||
│ │ ○ Poor Sleep │ │
|
||||
│ │ ○ PMS Symptoms │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ [Mini Calendar - current month with phase colors] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Garmin Settings Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Settings > Garmin Connection │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ STATUS: 🟢 Connected │
|
||||
│ Tokens expire: March 15, 2025 (65 days) │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ To connect or refresh your Garmin tokens: │
|
||||
│ │
|
||||
│ 1. Run this command on your computer: │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ curl -O https://phaseflow.../garmin_auth.py │ │
|
||||
│ │ python3 garmin_auth.py │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Enter your Garmin credentials when prompted │
|
||||
│ (MFA code will be requested if enabled) │
|
||||
│ │
|
||||
│ 3. Paste the JSON output here: │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Save Tokens] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [Disconnect Garmin] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Calendar Settings Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Settings > Calendar │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Subscribe to your cycle calendar in any calendar app: │
|
||||
│ │
|
||||
│ Calendar URL: │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ https://phaseflow.example.com/api/calendar/abc123/ │ │
|
||||
│ │ x7f9k2m4n8.ics [📋] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Instructions: │
|
||||
│ │
|
||||
│ Google Calendar: │
|
||||
│ 1. Open Google Calendar settings │
|
||||
│ 2. Click "Add calendar" → "From URL" │
|
||||
│ 3. Paste the URL above │
|
||||
│ │
|
||||
│ Apple Calendar: │
|
||||
│ 1. File → New Calendar Subscription │
|
||||
│ 2. Paste the URL above │
|
||||
│ │
|
||||
│ Note: Calendar updates within 12-24 hours of changes. │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [Regenerate URL] (invalidates old URL) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# App
|
||||
APP_URL=https://phaseflow.yourdomain.com
|
||||
NODE_ENV=production
|
||||
|
||||
# PocketBase
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=xxx
|
||||
EMAIL_FROM=phaseflow@yourdomain.com
|
||||
|
||||
# Encryption (for Garmin tokens)
|
||||
ENCRYPTION_KEY=xxx # 32-byte key for AES-256
|
||||
|
||||
# Cron secret (for protected endpoints)
|
||||
CRON_SECRET=xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Nomad)
|
||||
|
||||
```hcl
|
||||
job "phaseflow" {
|
||||
datacenters = ["dc1"]
|
||||
type = "service"
|
||||
|
||||
group "web" {
|
||||
count = 1
|
||||
|
||||
network {
|
||||
port "http" { to = 3000 }
|
||||
}
|
||||
|
||||
service {
|
||||
name = "phaseflow"
|
||||
port = "http"
|
||||
tags = [
|
||||
"traefik.enable=true",
|
||||
"traefik.http.routers.phaseflow.rule=Host(`phaseflow.yourdomain.com`)",
|
||||
"traefik.http.routers.phaseflow.tls.certresolver=letsencrypt"
|
||||
]
|
||||
}
|
||||
|
||||
task "nextjs" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "phaseflow:latest"
|
||||
ports = ["http"]
|
||||
}
|
||||
|
||||
env {
|
||||
NODE_ENV = "production"
|
||||
}
|
||||
|
||||
template {
|
||||
data = <<EOF
|
||||
{{ with nomadVar "nomad/jobs/phaseflow" }}
|
||||
APP_URL={{ .app_url }}
|
||||
POCKETBASE_URL={{ .pocketbase_url }}
|
||||
RESEND_API_KEY={{ .resend_key }}
|
||||
ENCRYPTION_KEY={{ .encryption_key }}
|
||||
CRON_SECRET={{ .cron_secret }}
|
||||
{{ end }}
|
||||
EOF
|
||||
destination = "secrets/.env"
|
||||
env = true
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 256
|
||||
memory = 512
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group "pocketbase" {
|
||||
count = 1
|
||||
|
||||
network {
|
||||
port "pb" { to = 8090 }
|
||||
}
|
||||
|
||||
volume "pb_data" {
|
||||
type = "host"
|
||||
source = "pocketbase-data"
|
||||
}
|
||||
|
||||
task "pocketbase" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "ghcr.io/muchobien/pocketbase:latest"
|
||||
ports = ["pb"]
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
volume = "pb_data"
|
||||
destination = "/pb_data"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 128
|
||||
memory = 256
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Periodic job for daily sync
|
||||
group "cron" {
|
||||
type = "batch"
|
||||
|
||||
periodic {
|
||||
cron = "0 6 * * *" # 6 AM daily
|
||||
prohibit_overlap = true
|
||||
}
|
||||
|
||||
task "garmin-sync" {
|
||||
driver = "exec"
|
||||
|
||||
config {
|
||||
command = "curl"
|
||||
args = [
|
||||
"-X", "POST",
|
||||
"-H", "Authorization: Bearer ${CRON_SECRET}",
|
||||
"${APP_URL}/api/cron/garmin-sync"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| Garmin API unavailable | Use last known values, note in email |
|
||||
| Garmin tokens expired | Email user with re-auth instructions |
|
||||
| No Garmin data yet | Use phase-only decision |
|
||||
| User hasn't set period date | Prompt in dashboard, block email until set |
|
||||
| Email delivery failure | Log, retry once |
|
||||
| Invalid ICS request | Return 404 |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Garmin tokens encrypted at rest (AES-256)
|
||||
- ICS feed URL contains random token (not guessable)
|
||||
- Cron endpoints protected by secret header
|
||||
- PocketBase handles user auth
|
||||
- HTTPS enforced via Traefik
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
phaseflow/
|
||||
├── scripts/
|
||||
│ └── garmin_auth.py # Token bootstrap script
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ ├── settings/
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ └── garmin/page.tsx
|
||||
│ │ ├── calendar/page.tsx
|
||||
│ │ ├── history/page.tsx
|
||||
│ │ ├── plan/page.tsx
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── api/
|
||||
│ │ ├── garmin/
|
||||
│ │ │ ├── tokens/route.ts
|
||||
│ │ │ └── status/route.ts
|
||||
│ │ ├── user/route.ts
|
||||
│ │ ├── cycle/
|
||||
│ │ │ ├── period/route.ts
|
||||
│ │ │ └── current/route.ts
|
||||
│ │ ├── today/route.ts
|
||||
│ │ ├── history/route.ts
|
||||
│ │ ├── calendar/
|
||||
│ │ │ ├── [userId]/[token].ics/route.ts
|
||||
│ │ │ └── regenerate-token/route.ts
|
||||
│ │ ├── overrides/route.ts
|
||||
│ │ └── cron/
|
||||
│ │ ├── garmin-sync/route.ts
|
||||
│ │ └── notifications/route.ts
|
||||
│ ├── components/
|
||||
│ │ ├── dashboard/
|
||||
│ │ │ ├── decision-card.tsx
|
||||
│ │ │ ├── data-panel.tsx
|
||||
│ │ │ ├── nutrition-panel.tsx
|
||||
│ │ │ ├── override-toggles.tsx
|
||||
│ │ │ └── mini-calendar.tsx
|
||||
│ │ ├── calendar/
|
||||
│ │ │ ├── month-view.tsx
|
||||
│ │ │ └── day-cell.tsx
|
||||
│ │ └── ui/ # shadcn components
|
||||
│ ├── lib/
|
||||
│ │ ├── pocketbase.ts
|
||||
│ │ ├── garmin.ts # API calls with stored tokens
|
||||
│ │ ├── decision-engine.ts
|
||||
│ │ ├── cycle.ts
|
||||
│ │ ├── nutrition.ts
|
||||
│ │ ├── email.ts
|
||||
│ │ ├── ics.ts # ICS feed generation
|
||||
│ │ └── encryption.ts
|
||||
│ └── types/
|
||||
│ └── index.ts
|
||||
├── flake.nix
|
||||
├── .envrc
|
||||
├── biome.json
|
||||
├── vitest.config.ts
|
||||
├── lefthook.yml
|
||||
└── package.json
|
||||
```
|
||||
23
src/app/api/calendar/[userId]/[token].ics/route.ts
Normal file
23
src/app/api/calendar/[userId]/[token].ics/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// ABOUTME: API route for ICS calendar feed generation.
|
||||
// ABOUTME: Returns subscribable iCal feed with cycle phases and warnings.
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
userId: string;
|
||||
token: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { userId, token } = await params;
|
||||
void token; // Token will be used for validation
|
||||
// TODO: Implement ICS feed generation
|
||||
// Validate token, generate ICS content, return with correct headers
|
||||
return new NextResponse(`ICS feed for user ${userId} not implemented`, {
|
||||
status: 501,
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
8
src/app/api/calendar/regenerate-token/route.ts
Normal file
8
src/app/api/calendar/regenerate-token/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: API route for regenerating calendar subscription token.
|
||||
// ABOUTME: Creates new random token, invalidating old calendar URLs.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// TODO: Implement token regeneration
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
16
src/app/api/cron/garmin-sync/route.ts
Normal file
16
src/app/api/cron/garmin-sync/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// ABOUTME: Cron endpoint for syncing Garmin data.
|
||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const expectedSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: Implement Garmin data sync
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
16
src/app/api/cron/notifications/route.ts
Normal file
16
src/app/api/cron/notifications/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// ABOUTME: Cron endpoint for sending daily email notifications.
|
||||
// ABOUTME: Sends morning training decision emails to all users.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const expectedSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: Implement notification sending
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
8
src/app/api/cycle/current/route.ts
Normal file
8
src/app/api/cycle/current/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: API route for current cycle phase information.
|
||||
// ABOUTME: Returns current cycle day, phase, and phase limits.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
// TODO: Implement current cycle info
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
8
src/app/api/cycle/period/route.ts
Normal file
8
src/app/api/cycle/period/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: API route for logging period start dates.
|
||||
// ABOUTME: Recalculates all phase dates when period is logged.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// TODO: Implement period logging
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
8
src/app/api/garmin/status/route.ts
Normal file
8
src/app/api/garmin/status/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: API route for checking Garmin connection status.
|
||||
// ABOUTME: Returns connection state and token expiry information.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
// TODO: Implement status check
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
13
src/app/api/garmin/tokens/route.ts
Normal file
13
src/app/api/garmin/tokens/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// ABOUTME: API route for storing Garmin OAuth tokens.
|
||||
// ABOUTME: Accepts tokens from the bootstrap script and encrypts them for storage.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// TODO: Implement token storage
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
// TODO: Implement token deletion
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
8
src/app/api/history/route.ts
Normal file
8
src/app/api/history/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: API route for historical daily logs.
|
||||
// ABOUTME: Returns paginated list of past training decisions and data.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
// TODO: Implement history retrieval
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
13
src/app/api/overrides/route.ts
Normal file
13
src/app/api/overrides/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// ABOUTME: API route for managing training overrides.
|
||||
// ABOUTME: Handles flare, stress, sleep, and PMS override toggles.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// TODO: Implement override setting
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
// TODO: Implement override removal
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
8
src/app/api/today/route.ts
Normal file
8
src/app/api/today/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: API route for today's training decision and data.
|
||||
// ABOUTME: Returns complete daily snapshot with decision, biometrics, and nutrition.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
// TODO: Implement today's data retrieval
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
13
src/app/api/user/route.ts
Normal file
13
src/app/api/user/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// ABOUTME: API route for user profile management.
|
||||
// ABOUTME: Handles GET for profile retrieval and PATCH for updates.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
// TODO: Implement user profile retrieval
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function PATCH() {
|
||||
// TODO: Implement user profile update
|
||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
||||
}
|
||||
11
src/app/calendar/page.tsx
Normal file
11
src/app/calendar/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
// ABOUTME: In-app calendar view showing cycle phases.
|
||||
// ABOUTME: Displays monthly calendar with color-coded phases and ICS subscription info.
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Calendar</h1>
|
||||
{/* Calendar view will be implemented here */}
|
||||
<p className="text-gray-500">Calendar view placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
125
src/app/globals.css
Normal file
125
src/app/globals.css
Normal file
@@ -0,0 +1,125 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
11
src/app/history/page.tsx
Normal file
11
src/app/history/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
// ABOUTME: Historical data view for past training decisions.
|
||||
// ABOUTME: Shows table of daily logs with biometrics and decisions.
|
||||
export default function HistoryPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">History</h1>
|
||||
{/* History table will be implemented here */}
|
||||
<p className="text-gray-500">History table placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/layout.tsx
Normal file
37
src/app/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// ABOUTME: Root layout for PhaseFlow application.
|
||||
// ABOUTME: Configures fonts, metadata, and global styles.
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PhaseFlow",
|
||||
description:
|
||||
"Automated training decisions for Hashimoto's thyroiditis based on menstrual cycle phases and Garmin biometrics",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
13
src/app/login/page.tsx
Normal file
13
src/app/login/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// ABOUTME: Login page for user authentication.
|
||||
// ABOUTME: Provides email/password login form using PocketBase auth.
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-8 p-8">
|
||||
<h1 className="text-2xl font-bold text-center">PhaseFlow Login</h1>
|
||||
{/* Login form will be implemented here */}
|
||||
<p className="text-center text-gray-500">Login form placeholder</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/page.tsx
Normal file
28
src/app/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// ABOUTME: Main dashboard page for PhaseFlow.
|
||||
// ABOUTME: Displays today's training decision, biometrics, and quick actions.
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
||||
<header className="border-b bg-white dark:bg-zinc-900 px-6 py-4">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">PhaseFlow</h1>
|
||||
<a
|
||||
href="/settings"
|
||||
className="text-sm text-zinc-600 hover:text-zinc-900"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500">Dashboard placeholder</p>
|
||||
<p className="text-sm text-zinc-400 mt-2">
|
||||
Connect your Garmin and set your period date to get started.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/plan/page.tsx
Normal file
11
src/app/plan/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
// ABOUTME: Exercise plan reference page.
|
||||
// ABOUTME: Displays the full monthly exercise plan by phase.
|
||||
export default function PlanPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Exercise Plan</h1>
|
||||
{/* Exercise plan content will be implemented here */}
|
||||
<p className="text-gray-500">Exercise plan placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/app/settings/garmin/page.tsx
Normal file
13
src/app/settings/garmin/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// ABOUTME: Garmin connection settings page.
|
||||
// ABOUTME: Allows users to paste OAuth tokens from the bootstrap script.
|
||||
export default function GarminSettingsPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">
|
||||
Settings > Garmin Connection
|
||||
</h1>
|
||||
{/* Garmin token input will be implemented here */}
|
||||
<p className="text-gray-500">Garmin settings placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/settings/page.tsx
Normal file
11
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
// ABOUTME: User settings page for profile and preferences.
|
||||
// ABOUTME: Allows configuration of notification time, timezone, and cycle length.
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Settings</h1>
|
||||
{/* Settings form will be implemented here */}
|
||||
<p className="text-gray-500">Settings form placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/calendar/day-cell.tsx
Normal file
38
src/components/calendar/day-cell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// ABOUTME: Individual day cell for calendar views.
|
||||
// ABOUTME: Shows day number with phase-appropriate background color.
|
||||
import type { CyclePhase } from "@/types";
|
||||
|
||||
interface DayCellProps {
|
||||
date: Date;
|
||||
cycleDay: number;
|
||||
phase: CyclePhase;
|
||||
isToday: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const PHASE_COLORS: Record<CyclePhase, string> = {
|
||||
MENSTRUAL: "bg-blue-100",
|
||||
FOLLICULAR: "bg-green-100",
|
||||
OVULATION: "bg-purple-100",
|
||||
EARLY_LUTEAL: "bg-yellow-100",
|
||||
LATE_LUTEAL: "bg-red-100",
|
||||
};
|
||||
|
||||
export function DayCell({
|
||||
date,
|
||||
cycleDay,
|
||||
phase,
|
||||
isToday,
|
||||
onClick,
|
||||
}: DayCellProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{date.getDate()}</span>
|
||||
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
32
src/components/calendar/month-view.tsx
Normal file
32
src/components/calendar/month-view.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// ABOUTME: Full month calendar view component.
|
||||
// ABOUTME: Displays calendar grid with phase colors and day details.
|
||||
interface MonthViewProps {
|
||||
year: number;
|
||||
month: number;
|
||||
lastPeriodDate: Date;
|
||||
cycleLength: number;
|
||||
}
|
||||
|
||||
export function MonthView({
|
||||
year,
|
||||
month,
|
||||
lastPeriodDate,
|
||||
cycleLength,
|
||||
}: MonthViewProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{new Date(year, month).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
{/* Calendar grid will be implemented here */}
|
||||
<p className="text-gray-500">Month view placeholder</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Cycle length: {cycleLength} days, Last period:{" "}
|
||||
{lastPeriodDate.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/dashboard/data-panel.tsx
Normal file
34
src/components/dashboard/data-panel.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// ABOUTME: Dashboard panel showing biometric data.
|
||||
// ABOUTME: Displays body battery, HRV, and intensity minutes.
|
||||
interface DataPanelProps {
|
||||
bodyBatteryCurrent: number | null;
|
||||
bodyBatteryYesterdayLow: number | null;
|
||||
hrvStatus: string;
|
||||
weekIntensity: number;
|
||||
phaseLimit: number;
|
||||
remainingMinutes: number;
|
||||
}
|
||||
|
||||
export function DataPanel({
|
||||
bodyBatteryCurrent,
|
||||
bodyBatteryYesterdayLow,
|
||||
hrvStatus,
|
||||
weekIntensity,
|
||||
phaseLimit,
|
||||
remainingMinutes,
|
||||
}: DataPanelProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">YOUR DATA</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>Body Battery: {bodyBatteryCurrent ?? "N/A"}</li>
|
||||
<li>Yesterday Low: {bodyBatteryYesterdayLow ?? "N/A"}</li>
|
||||
<li>HRV: {hrvStatus}</li>
|
||||
<li>
|
||||
Week: {weekIntensity}/{phaseLimit} min
|
||||
</li>
|
||||
<li>Remaining: {remainingMinutes} min</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/components/dashboard/decision-card.tsx
Normal file
17
src/components/dashboard/decision-card.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// ABOUTME: Dashboard card displaying today's training decision.
|
||||
// ABOUTME: Shows decision status, icon, and reason prominently.
|
||||
import type { Decision } from "@/types";
|
||||
|
||||
interface DecisionCardProps {
|
||||
decision: Decision;
|
||||
}
|
||||
|
||||
export function DecisionCard({ decision }: DecisionCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<div className="text-4xl mb-2">{decision.icon}</div>
|
||||
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
||||
<p className="text-gray-600">{decision.reason}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/dashboard/mini-calendar.tsx
Normal file
29
src/components/dashboard/mini-calendar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// ABOUTME: Compact calendar widget for the dashboard.
|
||||
// ABOUTME: Shows current month with color-coded cycle phases.
|
||||
interface MiniCalendarProps {
|
||||
currentDate: Date;
|
||||
cycleDay: number;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export function MiniCalendar({
|
||||
currentDate,
|
||||
cycleDay,
|
||||
phase,
|
||||
}: MiniCalendarProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">
|
||||
Day {cycleDay} • {phase}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{currentDate.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
{/* Full calendar grid will be implemented here */}
|
||||
<p className="text-gray-400 text-xs mt-4">Calendar grid placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/dashboard/nutrition-panel.tsx
Normal file
20
src/components/dashboard/nutrition-panel.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
// ABOUTME: Dashboard panel showing nutrition guidance.
|
||||
// ABOUTME: Displays seed cycling and macro recommendations.
|
||||
import type { NutritionGuidance } from "@/types";
|
||||
|
||||
interface NutritionPanelProps {
|
||||
nutrition: NutritionGuidance;
|
||||
}
|
||||
|
||||
export function NutritionPanel({ nutrition }: NutritionPanelProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>🌱 {nutrition.seeds}</li>
|
||||
<li>🍽️ Carbs: {nutrition.carbRange}</li>
|
||||
<li>🥑 Keto: {nutrition.ketoGuidance}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/dashboard/override-toggles.tsx
Normal file
41
src/components/dashboard/override-toggles.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// ABOUTME: Dashboard component for emergency training overrides.
|
||||
// ABOUTME: Provides toggles for flare, stress, sleep, and PMS modes.
|
||||
import type { OverrideType } from "@/types";
|
||||
|
||||
interface OverrideTogglesProps {
|
||||
activeOverrides: OverrideType[];
|
||||
onToggle: (override: OverrideType) => void;
|
||||
}
|
||||
|
||||
const OVERRIDE_OPTIONS: { type: OverrideType; label: string }[] = [
|
||||
{ type: "flare", label: "Flare Mode" },
|
||||
{ type: "stress", label: "High Stress" },
|
||||
{ type: "sleep", label: "Poor Sleep" },
|
||||
{ type: "pms", label: "PMS Symptoms" },
|
||||
];
|
||||
|
||||
export function OverrideToggles({
|
||||
activeOverrides,
|
||||
onToggle,
|
||||
}: OverrideTogglesProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">OVERRIDES</h3>
|
||||
<ul className="space-y-2">
|
||||
{OVERRIDE_OPTIONS.map(({ type, label }) => (
|
||||
<li key={type}>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeOverrides.includes(type)}
|
||||
onChange={() => onToggle(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/lib/cycle.test.ts
Normal file
62
src/lib/cycle.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// ABOUTME: Unit tests for cycle phase calculation utilities.
|
||||
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
|
||||
|
||||
describe("getCycleDay", () => {
|
||||
it("returns 1 on the first day of the cycle", () => {
|
||||
const lastPeriod = new Date("2025-01-01");
|
||||
const currentDate = new Date("2025-01-01");
|
||||
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns correct day within cycle", () => {
|
||||
const lastPeriod = new Date("2025-01-01");
|
||||
const currentDate = new Date("2025-01-15");
|
||||
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(15);
|
||||
});
|
||||
|
||||
it("wraps around after cycle length", () => {
|
||||
const lastPeriod = new Date("2025-01-01");
|
||||
const currentDate = new Date("2025-02-01"); // 31 days later
|
||||
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPhase", () => {
|
||||
it("returns MENSTRUAL for days 1-3", () => {
|
||||
expect(getPhase(1)).toBe("MENSTRUAL");
|
||||
expect(getPhase(3)).toBe("MENSTRUAL");
|
||||
});
|
||||
|
||||
it("returns FOLLICULAR for days 4-14", () => {
|
||||
expect(getPhase(4)).toBe("FOLLICULAR");
|
||||
expect(getPhase(14)).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("returns OVULATION for days 15-16", () => {
|
||||
expect(getPhase(15)).toBe("OVULATION");
|
||||
expect(getPhase(16)).toBe("OVULATION");
|
||||
});
|
||||
|
||||
it("returns EARLY_LUTEAL for days 17-24", () => {
|
||||
expect(getPhase(17)).toBe("EARLY_LUTEAL");
|
||||
expect(getPhase(24)).toBe("EARLY_LUTEAL");
|
||||
});
|
||||
|
||||
it("returns LATE_LUTEAL for days 25-31", () => {
|
||||
expect(getPhase(25)).toBe("LATE_LUTEAL");
|
||||
expect(getPhase(31)).toBe("LATE_LUTEAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPhaseLimit", () => {
|
||||
it("returns correct weekly limits for each phase", () => {
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(80);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
|
||||
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
|
||||
});
|
||||
});
|
||||
73
src/lib/cycle.ts
Normal file
73
src/lib/cycle.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// ABOUTME: Cycle phase calculation utilities.
|
||||
// ABOUTME: Determines current cycle day and phase from last period date.
|
||||
import type { CyclePhase, PhaseConfig } from "@/types";
|
||||
|
||||
export const PHASE_CONFIGS: PhaseConfig[] = [
|
||||
{
|
||||
name: "MENSTRUAL",
|
||||
days: [1, 3],
|
||||
weeklyLimit: 30,
|
||||
dailyAvg: 10,
|
||||
trainingType: "Gentle rebounding only",
|
||||
},
|
||||
{
|
||||
name: "FOLLICULAR",
|
||||
days: [4, 14],
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
trainingType: "Strength + rebounding",
|
||||
},
|
||||
{
|
||||
name: "OVULATION",
|
||||
days: [15, 16],
|
||||
weeklyLimit: 80,
|
||||
dailyAvg: 40,
|
||||
trainingType: "Peak performance",
|
||||
},
|
||||
{
|
||||
name: "EARLY_LUTEAL",
|
||||
days: [17, 24],
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 14,
|
||||
trainingType: "Moderate training",
|
||||
},
|
||||
{
|
||||
name: "LATE_LUTEAL",
|
||||
days: [25, 31],
|
||||
weeklyLimit: 50,
|
||||
dailyAvg: 8,
|
||||
trainingType: "Gentle rebounding ONLY",
|
||||
},
|
||||
];
|
||||
|
||||
export function getCycleDay(
|
||||
lastPeriodDate: Date,
|
||||
cycleLength: number,
|
||||
currentDate: Date = new Date(),
|
||||
): number {
|
||||
const diffMs = currentDate.getTime() - lastPeriodDate.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
return (diffDays % cycleLength) + 1;
|
||||
}
|
||||
|
||||
export function getPhase(cycleDay: number): CyclePhase {
|
||||
for (const config of PHASE_CONFIGS) {
|
||||
if (cycleDay >= config.days[0] && cycleDay <= config.days[1]) {
|
||||
return config.name;
|
||||
}
|
||||
}
|
||||
// Default to late luteal for any days beyond 31
|
||||
return "LATE_LUTEAL";
|
||||
}
|
||||
|
||||
export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
|
||||
const config = PHASE_CONFIGS.find((c) => c.name === phase);
|
||||
if (!config) {
|
||||
throw new Error(`Unknown phase: ${phase}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function getPhaseLimit(phase: CyclePhase): number {
|
||||
return getPhaseConfig(phase).weeklyLimit;
|
||||
}
|
||||
64
src/lib/decision-engine.ts
Normal file
64
src/lib/decision-engine.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// ABOUTME: Training decision engine based on biometric and cycle data.
|
||||
// ABOUTME: Implements priority-based rules for daily training recommendations.
|
||||
import type { DailyData, Decision } from "@/types";
|
||||
|
||||
export function getTrainingDecision(data: DailyData): Decision {
|
||||
const {
|
||||
hrvStatus,
|
||||
bbYesterdayLow,
|
||||
phase,
|
||||
weekIntensity,
|
||||
phaseLimit,
|
||||
bbCurrent,
|
||||
} = data;
|
||||
|
||||
if (hrvStatus === "Unbalanced") {
|
||||
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
|
||||
}
|
||||
|
||||
if (bbYesterdayLow < 30) {
|
||||
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
|
||||
}
|
||||
|
||||
if (phase === "LATE_LUTEAL") {
|
||||
return {
|
||||
status: "GENTLE",
|
||||
reason: "Gentle rebounding only (10-15min)",
|
||||
icon: "🟡",
|
||||
};
|
||||
}
|
||||
|
||||
if (phase === "MENSTRUAL") {
|
||||
return {
|
||||
status: "GENTLE",
|
||||
reason: "Gentle rebounding only (10min)",
|
||||
icon: "🟡",
|
||||
};
|
||||
}
|
||||
|
||||
if (weekIntensity >= phaseLimit) {
|
||||
return {
|
||||
status: "REST",
|
||||
reason: "WEEKLY LIMIT REACHED - Rest",
|
||||
icon: "🛑",
|
||||
};
|
||||
}
|
||||
|
||||
if (bbCurrent < 75) {
|
||||
return {
|
||||
status: "LIGHT",
|
||||
reason: "Light activity only - BB not recovered",
|
||||
icon: "🟡",
|
||||
};
|
||||
}
|
||||
|
||||
if (bbCurrent < 85) {
|
||||
return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "TRAIN",
|
||||
reason: "OK to train - follow phase plan",
|
||||
icon: "✅",
|
||||
};
|
||||
}
|
||||
83
src/lib/email.ts
Normal file
83
src/lib/email.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// ABOUTME: Email sending utilities using Resend.
|
||||
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
||||
import { Resend } from "resend";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
||||
|
||||
export interface DailyEmailData {
|
||||
to: string;
|
||||
cycleDay: number;
|
||||
phase: string;
|
||||
decision: {
|
||||
status: string;
|
||||
reason: string;
|
||||
icon: string;
|
||||
};
|
||||
bodyBatteryCurrent: number | null;
|
||||
bodyBatteryYesterdayLow: number | null;
|
||||
hrvStatus: string;
|
||||
weekIntensity: number;
|
||||
phaseLimit: number;
|
||||
remainingMinutes: number;
|
||||
seeds: string;
|
||||
carbRange: string;
|
||||
ketoGuidance: string;
|
||||
}
|
||||
|
||||
export async function sendDailyEmail(data: DailyEmailData): Promise<void> {
|
||||
const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`;
|
||||
|
||||
const body = `Good morning!
|
||||
|
||||
📅 CYCLE DAY: ${data.cycleDay} (${data.phase})
|
||||
|
||||
💪 TODAY'S PLAN:
|
||||
${data.decision.icon} ${data.decision.reason}
|
||||
|
||||
📊 YOUR DATA:
|
||||
• Body Battery Now: ${data.bodyBatteryCurrent ?? "N/A"}
|
||||
• Yesterday's Low: ${data.bodyBatteryYesterdayLow ?? "N/A"}
|
||||
• HRV Status: ${data.hrvStatus}
|
||||
• Week Intensity: ${data.weekIntensity} / ${data.phaseLimit} minutes
|
||||
• Remaining: ${data.remainingMinutes} minutes
|
||||
|
||||
🌱 SEEDS: ${data.seeds}
|
||||
|
||||
🍽️ MACROS: ${data.carbRange}
|
||||
🥑 KETO: ${data.ketoGuidance}
|
||||
|
||||
---
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: EMAIL_FROM,
|
||||
to: data.to,
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPeriodConfirmationEmail(
|
||||
to: string,
|
||||
lastPeriodDate: Date,
|
||||
cycleLength: number,
|
||||
): Promise<void> {
|
||||
const subject = "🔵 Period Tracking Updated";
|
||||
|
||||
const body = `Your cycle has been reset. Last period: ${lastPeriodDate.toLocaleDateString()}
|
||||
Phase calendar updated for next ${cycleLength} days.
|
||||
|
||||
Your calendar will update automatically within 24 hours.
|
||||
|
||||
---
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: EMAIL_FROM,
|
||||
to,
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
}
|
||||
49
src/lib/encryption.ts
Normal file
49
src/lib/encryption.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// ABOUTME: AES-256 encryption utilities for sensitive data like Garmin tokens.
|
||||
// ABOUTME: Provides encrypt/decrypt functions using environment-based keys.
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.ENCRYPTION_KEY;
|
||||
if (!key) {
|
||||
throw new Error("ENCRYPTION_KEY environment variable is required");
|
||||
}
|
||||
// Ensure key is exactly 32 bytes for AES-256
|
||||
return Buffer.from(key.padEnd(32, "0").slice(0, 32));
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:authTag:encrypted
|
||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const [ivHex, authTagHex, encrypted] = ciphertext.split(":");
|
||||
|
||||
if (!ivHex || !authTagHex || !encrypted) {
|
||||
throw new Error("Invalid ciphertext format");
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
39
src/lib/garmin.ts
Normal file
39
src/lib/garmin.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
|
||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
|
||||
import type { GarminTokens } from "@/types";
|
||||
|
||||
const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy";
|
||||
|
||||
interface GarminApiOptions {
|
||||
oauth2Token: string;
|
||||
}
|
||||
|
||||
export async function fetchGarminData(
|
||||
endpoint: string,
|
||||
options: GarminApiOptions,
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(`${GARMIN_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.oauth2Token}`,
|
||||
NK: "NT",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Garmin API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function isTokenExpired(tokens: GarminTokens): boolean {
|
||||
const expiresAt = new Date(tokens.expires_at);
|
||||
return expiresAt <= new Date();
|
||||
}
|
||||
|
||||
export function daysUntilExpiry(tokens: GarminTokens): number {
|
||||
const expiresAt = new Date(tokens.expires_at);
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
97
src/lib/ics.ts
Normal file
97
src/lib/ics.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// ABOUTME: ICS calendar feed generation for cycle phase events.
|
||||
// ABOUTME: Creates subscribable calendar with phase blocks and warnings.
|
||||
import { createEvents, type EventAttributes } from "ics";
|
||||
|
||||
import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle";
|
||||
|
||||
const PHASE_EMOJIS: Record<string, string> = {
|
||||
MENSTRUAL: "🔵",
|
||||
FOLLICULAR: "🟢",
|
||||
OVULATION: "🟣",
|
||||
EARLY_LUTEAL: "🟡",
|
||||
LATE_LUTEAL: "🔴",
|
||||
};
|
||||
|
||||
interface IcsGeneratorOptions {
|
||||
lastPeriodDate: Date;
|
||||
cycleLength: number;
|
||||
monthsAhead?: number;
|
||||
}
|
||||
|
||||
export function generateIcsFeed(options: IcsGeneratorOptions): string {
|
||||
const { lastPeriodDate, cycleLength, monthsAhead = 3 } = options;
|
||||
const events: EventAttributes[] = [];
|
||||
|
||||
const endDate = new Date();
|
||||
endDate.setMonth(endDate.getMonth() + monthsAhead);
|
||||
|
||||
const currentDate = new Date(lastPeriodDate);
|
||||
let currentPhase = getPhase(
|
||||
getCycleDay(lastPeriodDate, cycleLength, currentDate),
|
||||
);
|
||||
let phaseStartDate = new Date(currentDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate);
|
||||
const phase = getPhase(cycleDay);
|
||||
|
||||
// Add warning events
|
||||
if (cycleDay === 22) {
|
||||
events.push({
|
||||
start: dateToArray(currentDate),
|
||||
end: dateToArray(currentDate),
|
||||
title: "⚠️ Late Luteal Phase Starts in 3 Days",
|
||||
description: "Begin reducing training intensity",
|
||||
});
|
||||
}
|
||||
|
||||
if (cycleDay === 25) {
|
||||
events.push({
|
||||
start: dateToArray(currentDate),
|
||||
end: dateToArray(currentDate),
|
||||
title: "🔴 CRITICAL PHASE - Gentle Rebounding Only!",
|
||||
description: "Late luteal phase - protect your cycle",
|
||||
});
|
||||
}
|
||||
|
||||
// Track phase changes
|
||||
if (phase !== currentPhase) {
|
||||
// Close previous phase event
|
||||
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
|
||||
currentPhase = phase;
|
||||
phaseStartDate = new Date(currentDate);
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// Close final phase
|
||||
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
|
||||
|
||||
const { value, error } = createEvents(events);
|
||||
if (error) {
|
||||
throw new Error(`ICS generation error: ${error}`);
|
||||
}
|
||||
|
||||
return value || "";
|
||||
}
|
||||
|
||||
function createPhaseEvent(
|
||||
phase: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): EventAttributes {
|
||||
const config = PHASE_CONFIGS.find((c) => c.name === phase);
|
||||
const emoji = PHASE_EMOJIS[phase] || "📅";
|
||||
|
||||
return {
|
||||
start: dateToArray(startDate),
|
||||
end: dateToArray(endDate),
|
||||
title: `${emoji} ${phase.replace("_", " ")}`,
|
||||
description: config?.trainingType || "",
|
||||
};
|
||||
}
|
||||
|
||||
function dateToArray(date: Date): [number, number, number, number, number] {
|
||||
return [date.getFullYear(), date.getMonth() + 1, date.getDate(), 0, 0];
|
||||
}
|
||||
44
src/lib/nutrition.ts
Normal file
44
src/lib/nutrition.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// ABOUTME: Nutrition guidance based on cycle day.
|
||||
// ABOUTME: Provides seed cycling and macro recommendations by phase.
|
||||
import type { NutritionGuidance } from "@/types";
|
||||
|
||||
export function getNutritionGuidance(cycleDay: number): NutritionGuidance {
|
||||
// Seed cycling
|
||||
const seeds =
|
||||
cycleDay <= 14
|
||||
? "Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)"
|
||||
: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)";
|
||||
|
||||
// Macro guidance by day range
|
||||
let carbRange: string;
|
||||
let ketoGuidance: string;
|
||||
|
||||
if (cycleDay >= 1 && cycleDay <= 3) {
|
||||
carbRange = "100-150g";
|
||||
ketoGuidance = "No - body needs carbs during menstruation";
|
||||
} else if (cycleDay >= 4 && cycleDay <= 6) {
|
||||
carbRange = "75-100g";
|
||||
ketoGuidance = "No - transition phase";
|
||||
} else if (cycleDay >= 7 && cycleDay <= 14) {
|
||||
carbRange = "20-100g";
|
||||
ketoGuidance = "OPTIONAL - optimal keto window";
|
||||
} else if (cycleDay >= 15 && cycleDay <= 16) {
|
||||
carbRange = "100-150g";
|
||||
ketoGuidance = "No - exit keto, need carbs for ovulation";
|
||||
} else if (cycleDay >= 17 && cycleDay <= 24) {
|
||||
carbRange = "75-125g";
|
||||
ketoGuidance = "No - progesterone needs carbs";
|
||||
} else {
|
||||
carbRange = "100-150g+";
|
||||
ketoGuidance = "NEVER - mood/hormones need carbs for PMS";
|
||||
}
|
||||
|
||||
return { seeds, carbRange, ketoGuidance };
|
||||
}
|
||||
|
||||
export function getSeedSwitchAlert(cycleDay: number): string | null {
|
||||
if (cycleDay === 15) {
|
||||
return "🌱 SWITCH TODAY! Start Sesame + Sunflower";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
10
src/lib/pocketbase.ts
Normal file
10
src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// ABOUTME: PocketBase client initialization and utilities.
|
||||
// ABOUTME: Provides typed access to the PocketBase backend for auth and data.
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
const POCKETBASE_URL = process.env.POCKETBASE_URL || "http://localhost:8090";
|
||||
|
||||
export const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
// Disable auto-cancellation for server-side usage
|
||||
pb.autoCancellation(false);
|
||||
8
src/lib/utils.ts
Normal file
8
src/lib/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ABOUTME: Utility functions for className merging with Tailwind CSS.
|
||||
// ABOUTME: Provides cn() helper for combining conditional class names.
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
103
src/types/index.ts
Normal file
103
src/types/index.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// ABOUTME: Central type definitions for the PhaseFlow application.
|
||||
// ABOUTME: Contains interfaces for users, daily logs, decisions, and cycle data.
|
||||
|
||||
export type CyclePhase =
|
||||
| "MENSTRUAL"
|
||||
| "FOLLICULAR"
|
||||
| "OVULATION"
|
||||
| "EARLY_LUTEAL"
|
||||
| "LATE_LUTEAL";
|
||||
|
||||
export type HrvStatus = "Balanced" | "Unbalanced" | "Unknown";
|
||||
|
||||
export type DecisionStatus = "REST" | "GENTLE" | "LIGHT" | "REDUCED" | "TRAIN";
|
||||
|
||||
export type OverrideType = "flare" | "stress" | "sleep" | "pms";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
// Garmin
|
||||
garminConnected: boolean;
|
||||
garminOauth1Token: string; // encrypted JSON
|
||||
garminOauth2Token: string; // encrypted JSON
|
||||
garminTokenExpiresAt: Date;
|
||||
|
||||
// Calendar
|
||||
calendarToken: string; // random secret for ICS URL
|
||||
|
||||
// Cycle
|
||||
lastPeriodDate: Date;
|
||||
cycleLength: number; // default: 31
|
||||
|
||||
// Preferences
|
||||
notificationTime: string; // "07:00"
|
||||
timezone: string;
|
||||
|
||||
// Overrides
|
||||
activeOverrides: OverrideType[];
|
||||
|
||||
created: Date;
|
||||
updated: Date;
|
||||
}
|
||||
|
||||
export interface DailyLog {
|
||||
id: string;
|
||||
user: string; // relation
|
||||
date: Date;
|
||||
cycleDay: number;
|
||||
phase: CyclePhase;
|
||||
bodyBatteryCurrent: number | null;
|
||||
bodyBatteryYesterdayLow: number | null;
|
||||
hrvStatus: HrvStatus;
|
||||
weekIntensityMinutes: number;
|
||||
phaseLimit: number;
|
||||
remainingMinutes: number;
|
||||
trainingDecision: string;
|
||||
decisionReason: string;
|
||||
notificationSentAt: Date | null;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
export interface PeriodLog {
|
||||
id: string;
|
||||
user: string; // relation
|
||||
startDate: Date;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
status: DecisionStatus;
|
||||
reason: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface DailyData {
|
||||
hrvStatus: HrvStatus;
|
||||
bbYesterdayLow: number;
|
||||
phase: CyclePhase;
|
||||
weekIntensity: number;
|
||||
phaseLimit: number;
|
||||
bbCurrent: number;
|
||||
}
|
||||
|
||||
export interface GarminTokens {
|
||||
oauth1: string;
|
||||
oauth2: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface PhaseConfig {
|
||||
name: CyclePhase;
|
||||
days: [number, number]; // start and end day
|
||||
weeklyLimit: number;
|
||||
dailyAvg: number;
|
||||
trainingType: string;
|
||||
}
|
||||
|
||||
export interface NutritionGuidance {
|
||||
seeds: string;
|
||||
carbRange: string;
|
||||
ketoGuidance: string;
|
||||
}
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// ABOUTME: Vitest configuration for unit and integration testing.
|
||||
// ABOUTME: Configures jsdom environment for React component testing.
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user