Compare commits

..

5 Commits

Author SHA1 Message Date
dbf0c32588 Fix garmin status showing stale connection state
All checks were successful
Deploy / deploy (push) Successful in 1m37s
Fetch fresh user data from database in status endpoint instead of
relying on auth store cookie, which may be stale after token save.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:45:35 +00:00
6bd5eb663b Add Playwright E2E testing infrastructure
- Add playwright-web-flake to flake.nix for NixOS browser support
- Pin @playwright/test@1.56.1 to match nixpkgs version
- Create playwright.config.ts with Chromium-only, auto-start dev server
- Add e2e/smoke.spec.ts with initial smoke tests
- Add .mcp.json for Claude browser control via MCP
- Update .gitignore for playwright artifacts
- Remove E2E test skip from spec.md Known Limitations
- Update specs/testing.md to require three-tier testing approach

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:43:24 +00:00
30c5955a61 Fix garmin token expires_at format for PocketBase compatibility
Output expires_at as ISO 8601 date string instead of Unix timestamp.
PocketBase date fields expect ISO format, and the integer was causing
token saves to fail silently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:25:38 +00:00
ce80fb1ede Add database setup script and fix dark mode visibility
- Add scripts/setup-db.ts to programmatically create missing PocketBase
  collections (period_logs, dailyLogs) with proper relation fields
- Fix dark mode visibility across settings, login, calendar, and dashboard
  components by using semantic CSS tokens and dark: variants
- Add db:setup npm script and document usage in AGENTS.md
- Update vitest config to include scripts directory tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:23:20 +00:00
ca35b36efa Fix garth token serialization using TypeAdapter for Pydantic dataclasses
Garth's OAuth1Token and OAuth2Token are Pydantic dataclasses, not BaseModel
subclasses, so they require TypeAdapter for serialization instead of model_dump().
Also adds user-friendly error handling for authentication failures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:01 +00:00
23 changed files with 942 additions and 110 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,10 @@
# testing # testing
/coverage /coverage
# playwright
/playwright-report/
/test-results/
# next.js # next.js
/.next/ /.next/
/out/ /out/

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}

View File

@@ -24,6 +24,19 @@ Run these after implementing to get immediate feedback:
- Path aliases: `@/*` maps to `./src/*` - Path aliases: `@/*` maps to `./src/*`
- Pre-commit hooks: Biome lint + Vitest tests via Lefthook - Pre-commit hooks: Biome lint + Vitest tests via Lefthook
## Database Setup
PocketBase requires these collections: `users`, `period_logs`, `dailyLogs`.
To create missing collections:
```bash
POCKETBASE_ADMIN_EMAIL=admin@example.com \
POCKETBASE_ADMIN_PASSWORD=yourpassword \
pnpm db:setup
```
The script reads `NEXT_PUBLIC_POCKETBASE_URL` from your environment and creates any missing collections. It's safe to run multiple times - existing collections are skipped.
## Codebase Patterns ## Codebase Patterns
- TDD required: Write tests before implementation - TDD required: Write tests before implementation

36
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,36 @@
// ABOUTME: Smoke tests to verify basic application functionality.
// ABOUTME: Tests that the app loads and critical pages are accessible.
import { expect, test } from "@playwright/test";
test.describe("smoke tests", () => {
test("app loads with correct title", async ({ page }) => {
await page.goto("/");
// Verify the app loads by checking the page title
await expect(page).toHaveTitle("PhaseFlow");
});
test("login page is accessible", async ({ page }) => {
await page.goto("/login");
// Verify login page loads
await expect(page).toHaveURL(/\/login/);
});
test("unauthenticated root redirects or shows login option", async ({
page,
}) => {
await page.goto("/");
// The app should either redirect to login or show a login link
// Check for either condition
const url = page.url();
const hasLoginInUrl = url.includes("/login");
const loginLink = page.getByRole("link", { name: /login|sign in/i });
// At least one should be true: either we're on login page or there's a login link
if (!hasLoginInUrl) {
await expect(loginLink).toBeVisible();
}
});
});

68
flake.lock generated
View File

@@ -1,5 +1,23 @@
{ {
"nodes": { "nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767892417, "lastModified": 1767892417,
@@ -16,9 +34,57 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": {
"locked": {
"lastModified": 0,
"narHash": "sha256-u+rxA79a0lyhG+u+oPBRtTDtzz8kvkc9a6SWSt9ekVc=",
"path": "/nix/store/0283cbhm47kd3lr9zmc5fvdrx9qkav8s-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"playwright-web-flake": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1764622772,
"narHash": "sha256-WCvvlB9sH6u8MQUkFnlxx7jDh7kIebTDK/JHi6pPqSA=",
"owner": "pietdevries94",
"repo": "playwright-web-flake",
"rev": "88e0e6c69b9086619b0c4d8713b2bfaf81a21c40",
"type": "github"
},
"original": {
"owner": "pietdevries94",
"ref": "1.56.1",
"repo": "playwright-web-flake",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"playwright-web-flake": "playwright-web-flake"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
} }
} }
}, },

View File

@@ -2,11 +2,13 @@
# ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output. # ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output.
{ {
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
outputs = { nixpkgs, ... }: outputs = { nixpkgs, playwright-web-flake, ... }:
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
playwright-driver = playwright-web-flake.packages.${system}.playwright-driver;
# Custom Python package: garth (not in nixpkgs) # Custom Python package: garth (not in nixpkgs)
garth = pkgs.python3Packages.buildPythonPackage { garth = pkgs.python3Packages.buildPythonPackage {
@@ -48,32 +50,40 @@
devShells.${system} = { devShells.${system} = {
# Default development shell with all tools # Default development shell with all tools
default = pkgs.mkShell { default = pkgs.mkShell {
packages = commonPackages ++ (with pkgs; [ packages = commonPackages ++ [
turbo pkgs.turbo
lefthook pkgs.lefthook
]); playwright-driver
];
# For native modules (sharp, better-sqlite3, etc.) # For native modules (sharp, better-sqlite3, etc.)
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ]; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
# Playwright browser configuration for NixOS (from playwright-web-flake)
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
}; };
# Ralph sandbox shell with minimal permissions # Ralph sandbox shell with minimal permissions
# Used for autonomous Ralph loop execution # Used for autonomous Ralph loop execution
ralph = pkgs.mkShell { ralph = pkgs.mkShell {
packages = commonPackages ++ (with pkgs; [ packages = commonPackages ++ [
# Claude CLI (assumes installed globally or via npm) playwright-driver
# Add any other tools Ralph needs here ];
]);
# Restrictive environment for sandboxed execution # Restrictive environment for sandboxed execution
shellHook = '' shellHook = ''
echo "🔒 Ralph Sandbox Environment" echo "🔒 Ralph Sandbox Environment"
echo " Limited to: nodejs, pnpm, git" echo " Limited to: nodejs, pnpm, git, playwright"
echo "" echo ""
''; '';
# For native modules # For native modules
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ]; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
# Playwright browser configuration for NixOS (from playwright-web-flake)
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
}; };
}; };
}; };

View File

@@ -9,7 +9,11 @@
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"db:setup": "npx tsx scripts/setup-db.ts"
}, },
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -30,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@playwright/test": "1.56.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",

46
playwright.config.ts Normal file
View File

@@ -0,0 +1,46 @@
// ABOUTME: Playwright E2E test configuration for browser-based testing.
// ABOUTME: Configures Chromium-only headless testing with automatic dev server startup.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
// Test directory for E2E tests
testDir: "./e2e",
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry failed tests on CI only
retries: process.env.CI ? 2 : 0,
// Limit parallel workers on CI to avoid resource issues
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [["html", { open: "never" }], ["list"]],
// Shared settings for all projects
use: {
// Base URL for navigation actions like page.goto('/')
baseURL: "http://localhost:3000",
// Collect trace on first retry for debugging
trace: "on-first-retry",
// Take screenshot on failure
screenshot: "only-on-failure",
},
// Configure projects - Chromium only per requirements
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Run dev server before starting tests
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, // 2 minutes for Next.js to start
},
});

43
pnpm-lock.yaml generated
View File

@@ -25,7 +25,7 @@ importers:
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
next: next:
specifier: 16.1.1 specifier: 16.1.1
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
node-cron: node-cron:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -57,6 +57,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.3.11 specifier: 2.3.11
version: 2.3.11 version: 2.3.11
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
version: 4.1.18 version: 4.1.18
@@ -974,6 +977,11 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.53': '@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@@ -1553,6 +1561,11 @@ packages:
picomatch: picomatch:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1778,6 +1791,16 @@ packages:
resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==} resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==}
hasBin: true hasBin: true
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.56.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
engines: {node: '>=18'}
hasBin: true
pocketbase@0.26.5: pocketbase@0.26.5:
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==} resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
@@ -2723,6 +2746,10 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
'@rolldown/pluginutils@1.0.0-beta.53': {} '@rolldown/pluginutils@1.0.0-beta.53': {}
'@rollup/rollup-android-arm-eabi@4.55.1': '@rollup/rollup-android-arm-eabi@4.55.1':
@@ -3213,6 +3240,9 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -3363,7 +3393,7 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
'@next/env': 16.1.1 '@next/env': 16.1.1
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -3383,6 +3413,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.1.1 '@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1 '@next/swc-win32-x64-msvc': 16.1.1
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@playwright/test': 1.56.1
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -3426,6 +3457,14 @@ snapshots:
sonic-boom: 4.2.0 sonic-boom: 4.2.0
thread-stream: 4.0.0 thread-stream: 4.0.0
playwright-core@1.56.1: {}
playwright@1.56.1:
dependencies:
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
pocketbase@0.26.5: {} pocketbase@0.26.5: {}
postcss@8.4.31: postcss@8.4.31:

View File

@@ -10,11 +10,15 @@ Usage:
python3 garmin_auth.py python3 garmin_auth.py
""" """
import json import json
import sys
from datetime import datetime from datetime import datetime
from getpass import getpass from getpass import getpass
try: try:
import garth import garth
from garth.auth_tokens import OAuth1Token, OAuth2Token
from garth.exc import GarthHTTPError
from pydantic import TypeAdapter
except ImportError: except ImportError:
print("Error: garth library not installed.") print("Error: garth library not installed.")
print("Please install it with: pip install garth") print("Please install it with: pip install garth")
@@ -24,16 +28,30 @@ email = input("Garmin email: ")
password = getpass("Garmin password: ") password = getpass("Garmin password: ")
# MFA handled automatically - prompts if needed # MFA handled automatically - prompts if needed
garth.login(email, password) try:
garth.login(email, password)
except GarthHTTPError as e:
if "401" in str(e):
print("\nError: Invalid email or password.", file=sys.stderr)
else:
print(f"\nError: Authentication failed - {e}", file=sys.stderr)
exit(1)
except Exception as e:
print(f"\nError: {e}", file=sys.stderr)
exit(1)
# Serialize Pydantic dataclasses using TypeAdapter
oauth1_adapter = TypeAdapter(OAuth1Token)
oauth2_adapter = TypeAdapter(OAuth2Token)
expires_at_ts = garth.client.oauth2_token.expires_at
tokens = { tokens = {
"oauth1": garth.client.oauth1_token.model_dump(), "oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
"oauth2": garth.client.oauth2_token.model_dump(), "oauth2": oauth2_adapter.dump_python(garth.client.oauth2_token, mode='json'),
"expires_at": garth.client.oauth2_token.expires_at "expires_at": datetime.fromtimestamp(expires_at_ts).isoformat()
} }
print("\n--- Copy everything below this line ---") print("\n--- Copy everything below this line ---")
print(json.dumps(tokens, indent=2)) print(json.dumps(tokens, indent=2))
print("--- Copy everything above this line ---") print("--- Copy everything above this line ---")
expires_dt = datetime.fromtimestamp(tokens['expires_at']) print(f"\nTokens expire: {tokens['expires_at']}")
print(f"\nTokens expire: {expires_dt.isoformat()}")

164
scripts/setup-db.test.ts Normal file
View File

@@ -0,0 +1,164 @@
// ABOUTME: Tests for the database setup script that creates PocketBase collections.
// ABOUTME: Verifies collection definitions and setup logic without hitting real PocketBase.
import { describe, expect, it, vi } from "vitest";
import {
DAILY_LOGS_COLLECTION,
getExistingCollectionNames,
getMissingCollections,
PERIOD_LOGS_COLLECTION,
} from "./setup-db";
describe("PERIOD_LOGS_COLLECTION", () => {
it("has correct name", () => {
expect(PERIOD_LOGS_COLLECTION.name).toBe("period_logs");
});
it("has required fields", () => {
const fieldNames = PERIOD_LOGS_COLLECTION.fields.map((f) => f.name);
expect(fieldNames).toContain("user");
expect(fieldNames).toContain("startDate");
expect(fieldNames).toContain("predictedDate");
});
it("has user field as relation to users", () => {
const userField = PERIOD_LOGS_COLLECTION.fields.find(
(f) => f.name === "user",
);
expect(userField?.type).toBe("relation");
expect(userField?.collectionId).toBe("users");
expect(userField?.maxSelect).toBe(1);
expect(userField?.cascadeDelete).toBe(true);
});
it("has startDate as required date", () => {
const startDateField = PERIOD_LOGS_COLLECTION.fields.find(
(f) => f.name === "startDate",
);
expect(startDateField?.type).toBe("date");
expect(startDateField?.required).toBe(true);
});
it("has predictedDate as optional date", () => {
const predictedDateField = PERIOD_LOGS_COLLECTION.fields.find(
(f) => f.name === "predictedDate",
);
expect(predictedDateField?.type).toBe("date");
expect(predictedDateField?.required).toBe(false);
});
});
describe("DAILY_LOGS_COLLECTION", () => {
it("has correct name", () => {
expect(DAILY_LOGS_COLLECTION.name).toBe("dailyLogs");
});
it("has all required fields", () => {
const fieldNames = DAILY_LOGS_COLLECTION.fields.map((f) => f.name);
// Core fields
expect(fieldNames).toContain("user");
expect(fieldNames).toContain("date");
expect(fieldNames).toContain("cycleDay");
expect(fieldNames).toContain("phase");
// Garmin biometric fields
expect(fieldNames).toContain("bodyBatteryCurrent");
expect(fieldNames).toContain("bodyBatteryYesterdayLow");
expect(fieldNames).toContain("hrvStatus");
expect(fieldNames).toContain("weekIntensityMinutes");
// Decision fields
expect(fieldNames).toContain("phaseLimit");
expect(fieldNames).toContain("remainingMinutes");
expect(fieldNames).toContain("trainingDecision");
expect(fieldNames).toContain("decisionReason");
expect(fieldNames).toContain("notificationSentAt");
});
it("has user field as relation to users", () => {
const userField = DAILY_LOGS_COLLECTION.fields.find(
(f) => f.name === "user",
);
expect(userField?.type).toBe("relation");
expect(userField?.collectionId).toBe("users");
expect(userField?.maxSelect).toBe(1);
expect(userField?.cascadeDelete).toBe(true);
});
it("has trainingDecision as required text", () => {
const field = DAILY_LOGS_COLLECTION.fields.find(
(f) => f.name === "trainingDecision",
);
expect(field?.type).toBe("text");
expect(field?.required).toBe(true);
});
});
describe("getExistingCollectionNames", () => {
it("extracts collection names from PocketBase response", async () => {
const mockPb = {
collections: {
getFullList: vi
.fn()
.mockResolvedValue([
{ name: "users" },
{ name: "period_logs" },
{ name: "_superusers" },
]),
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
const names = await getExistingCollectionNames(mockPb as any);
expect(names).toEqual(["users", "period_logs", "_superusers"]);
});
it("returns empty array when no collections exist", async () => {
const mockPb = {
collections: {
getFullList: vi.fn().mockResolvedValue([]),
},
};
// biome-ignore lint/suspicious/noExplicitAny: test mock
const names = await getExistingCollectionNames(mockPb as any);
expect(names).toEqual([]);
});
});
describe("getMissingCollections", () => {
it("returns both collections when none exist", () => {
const existing = ["users"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(2);
expect(missing.map((c) => c.name)).toContain("period_logs");
expect(missing.map((c) => c.name)).toContain("dailyLogs");
});
it("returns only dailyLogs when period_logs exists", () => {
const existing = ["users", "period_logs"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe("dailyLogs");
});
it("returns only period_logs when dailyLogs exists", () => {
const existing = ["users", "dailyLogs"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(1);
expect(missing[0].name).toBe("period_logs");
});
it("returns empty array when all collections exist", () => {
const existing = ["users", "period_logs", "dailyLogs"];
const missing = getMissingCollections(existing);
expect(missing).toHaveLength(0);
});
});

287
scripts/setup-db.ts Normal file
View File

@@ -0,0 +1,287 @@
// ABOUTME: Database setup script for creating PocketBase collections.
// ABOUTME: Run with: POCKETBASE_ADMIN_EMAIL=... POCKETBASE_ADMIN_PASSWORD=... pnpm db:setup
import PocketBase from "pocketbase";
/**
* Collection field definition for PocketBase.
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
*/
interface CollectionField {
name: string;
type: string;
required?: boolean;
// Relation field properties (top-level, not in options)
collectionId?: string;
maxSelect?: number;
cascadeDelete?: boolean;
}
/**
* Collection definition for PocketBase.
*/
interface CollectionDefinition {
name: string;
type: string;
fields: CollectionField[];
}
/**
* Period logs collection schema - tracks menstrual cycle start dates.
* Note: collectionId will be resolved at runtime to the actual users collection ID.
*/
export const PERIOD_LOGS_COLLECTION: CollectionDefinition = {
name: "period_logs",
type: "base",
fields: [
{
name: "user",
type: "relation",
required: true,
collectionId: "users", // Will be resolved to actual ID at runtime
maxSelect: 1,
cascadeDelete: true,
},
{
name: "startDate",
type: "date",
required: true,
},
{
name: "predictedDate",
type: "date",
required: false,
},
],
};
/**
* Daily logs collection schema - daily training snapshots with biometrics.
* Note: collectionId will be resolved at runtime to the actual users collection ID.
*/
export const DAILY_LOGS_COLLECTION: CollectionDefinition = {
name: "dailyLogs",
type: "base",
fields: [
{
name: "user",
type: "relation",
required: true,
collectionId: "users", // Will be resolved to actual ID at runtime
maxSelect: 1,
cascadeDelete: true,
},
{
name: "date",
type: "date",
required: true,
},
{
name: "cycleDay",
type: "number",
required: true,
},
{
name: "phase",
type: "text",
required: true,
},
{
name: "bodyBatteryCurrent",
type: "number",
required: false,
},
{
name: "bodyBatteryYesterdayLow",
type: "number",
required: false,
},
{
name: "hrvStatus",
type: "text",
required: false,
},
{
name: "weekIntensityMinutes",
type: "number",
required: false,
},
{
name: "phaseLimit",
type: "number",
required: false,
},
{
name: "remainingMinutes",
type: "number",
required: false,
},
{
name: "trainingDecision",
type: "text",
required: true,
},
{
name: "decisionReason",
type: "text",
required: true,
},
{
name: "notificationSentAt",
type: "date",
required: false,
},
],
};
/**
* All collections that should exist in the database.
*/
const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
/**
* Gets the names of existing collections from PocketBase.
*/
export async function getExistingCollectionNames(
pb: PocketBase,
): Promise<string[]> {
const collections = await pb.collections.getFullList();
return collections.map((c) => c.name);
}
/**
* Returns collection definitions that don't exist in the database.
*/
export function getMissingCollections(
existingNames: string[],
): CollectionDefinition[] {
return REQUIRED_COLLECTIONS.filter(
(collection) => !existingNames.includes(collection.name),
);
}
/**
* Resolves collection name to actual collection ID.
*/
async function resolveCollectionId(
pb: PocketBase,
nameOrId: string,
): Promise<string> {
const collection = await pb.collections.getOne(nameOrId);
return collection.id;
}
/**
* Creates a collection in PocketBase.
*/
async function createCollection(
pb: PocketBase,
collection: CollectionDefinition,
): Promise<void> {
// Resolve any collection names to actual IDs for relation fields
const fields = await Promise.all(
collection.fields.map(async (field) => {
const baseField: Record<string, unknown> = {
name: field.name,
type: field.type,
required: field.required ?? false,
};
// For relation fields, resolve collectionId and add relation-specific props
if (field.type === "relation" && field.collectionId) {
const resolvedId = await resolveCollectionId(pb, field.collectionId);
baseField.collectionId = resolvedId;
baseField.maxSelect = field.maxSelect ?? 1;
baseField.cascadeDelete = field.cascadeDelete ?? false;
}
return baseField;
}),
);
await pb.collections.create({
name: collection.name,
type: collection.type,
fields,
});
}
/**
* Main setup function - creates missing collections.
*/
async function main(): Promise<void> {
// Validate environment
const pocketbaseUrl =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://localhost:8090";
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
console.error(
"Error: POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD are required",
);
console.error("Usage:");
console.error(
" POCKETBASE_ADMIN_EMAIL=admin@example.com POCKETBASE_ADMIN_PASSWORD=secret pnpm db:setup",
);
process.exit(1);
}
console.log(`Connecting to PocketBase at ${pocketbaseUrl}...`);
const pb = new PocketBase(pocketbaseUrl);
pb.autoCancellation(false);
// Authenticate as admin
try {
await pb
.collection("_superusers")
.authWithPassword(adminEmail, adminPassword);
console.log("Authenticated as admin");
} catch (error) {
console.error("Failed to authenticate as admin:", error);
process.exit(1);
}
// Get existing collections
const existingNames = await getExistingCollectionNames(pb);
console.log(
`Found ${existingNames.length} existing collections:`,
existingNames,
);
// Find and create missing collections
const missing = getMissingCollections(existingNames);
if (missing.length === 0) {
console.log("All required collections already exist. Nothing to do.");
return;
}
console.log(
`Creating ${missing.length} missing collection(s):`,
missing.map((c) => c.name),
);
for (const collection of missing) {
try {
await createCollection(pb, collection);
console.log(` Created: ${collection.name}`);
} catch (error) {
console.error(` Failed to create ${collection.name}:`, error);
process.exit(1);
}
}
console.log("Database setup complete!");
}
// Run main function when executed directly
const isMainModule = typeof require !== "undefined" && require.main === module;
// For ES modules / tsx execution
if (isMainModule || process.argv[1]?.includes("setup-db")) {
main().catch((error) => {
console.error("Setup failed:", error);
process.exit(1);
});
}

View File

@@ -835,7 +835,7 @@ The following are **out of scope** for MVP:
| Hormonal birth control | May disrupt natural cycle phases | | Hormonal birth control | May disrupt natural cycle phases |
| API versioning | Single version; breaking changes via deprecation | | API versioning | Single version; breaking changes via deprecation |
| Formal API documentation | Endpoints documented in spec only | | Formal API documentation | Endpoints documented in spec only |
| E2E tests | Unit + integration tests only (authorized skip) | | Multi-user support | Single-user design only |
--- ---

View File

@@ -6,7 +6,7 @@ When I make changes to the codebase, I want automated tests to catch regressions
## Testing Strategy ## Testing Strategy
PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are not required for MVP (authorized skip). PhaseFlow uses a **three-tier testing approach**: unit tests, integration tests, and end-to-end tests.
### Test Types ### Test Types
@@ -14,6 +14,7 @@ PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are
|------|-------|-------|----------| |------|-------|-------|----------|
| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` | | Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` |
| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` | | Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` |
| E2E | Full user flows, browser interactions | Playwright | `e2e/*.spec.ts` |
## Framework ## Framework
@@ -148,9 +149,96 @@ describe('GET /api/today', () => {
}); });
``` ```
## End-to-End Tests
Test complete user flows through the browser using Playwright.
### Framework
**Playwright** - Cross-browser E2E testing with auto-waiting and tracing.
**Configuration (`playwright.config.ts`):**
```typescript
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
```
### Priority Targets
| Flow | Tests |
|------|-------|
| Authentication | Login page loads, login redirects, logout works |
| Dashboard | Shows decision, displays cycle info, override toggles work |
| Settings | Garmin token paste, preferences save |
| Calendar | ICS feed accessible, calendar view renders |
| Period logging | "Period Started" updates cycle |
### Example Test
```typescript
// e2e/dashboard.spec.ts
import { expect, test } from "@playwright/test";
test.describe("dashboard", () => {
test("shows training decision for authenticated user", async ({ page }) => {
// Login first (or use auth state)
await page.goto("/");
// Verify decision card is visible
await expect(page.getByTestId("decision-card")).toBeVisible();
// Verify cycle day is displayed
await expect(page.getByText(/Day \d+ of \d+/)).toBeVisible();
});
test("override toggles update decision", async ({ page }) => {
await page.goto("/");
// Enable flare mode
await page.getByLabel("Flare Mode").click();
// Decision should change to gentle
await expect(page.getByText(/GENTLE/)).toBeVisible();
});
});
```
### NixOS Setup
E2E tests require browser binaries. On NixOS, use `playwright-web-flake`:
```nix
# In flake.nix inputs
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
# In devShell
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
```
## File Naming ## File Naming
Tests colocated with source files: Tests colocated with source files (unit/integration) or in `e2e/` directory (E2E):
``` ```
src/ src/
@@ -164,22 +252,33 @@ src/
today/ today/
route.ts route.ts
route.test.ts route.test.ts
e2e/
smoke.spec.ts
dashboard.spec.ts
auth.spec.ts
settings.spec.ts
``` ```
## Running Tests ## Running Tests
```bash ```bash
# Run all tests # Run unit/integration tests
npm test pnpm test:run
# Run with coverage
npm run test:coverage
# Run in watch mode # Run in watch mode
npm run test:watch pnpm test
# Run specific file # Run E2E tests (headless)
npm test -- src/lib/cycle.test.ts pnpm test:e2e
# Run E2E tests (visible browser)
pnpm test:e2e:headed
# Run E2E tests with UI mode
pnpm test:e2e:ui
# Run all tests (unit + E2E)
pnpm test:run && pnpm test:e2e
``` ```
## Coverage Expectations ## Coverage Expectations
@@ -190,15 +289,20 @@ No strict coverage thresholds for MVP, but aim for:
## Success Criteria ## Success Criteria
1. All tests pass in CI before merge 1. All tests (unit, integration, E2E) pass in CI before merge
2. Core decision engine logic has comprehensive tests 2. Core decision engine logic has comprehensive unit tests
3. Phase scaling tested for multiple cycle lengths 3. Phase scaling tested for multiple cycle lengths
4. API auth tested for protected routes 4. API auth tested for protected routes
5. Critical user flows covered by E2E tests
## Acceptance Tests ## Acceptance Tests
- [ ] `npm test` runs without errors - [ ] `pnpm test:run` runs without errors
- [ ] `pnpm test:e2e` runs without errors
- [ ] Unit tests cover decision engine logic - [ ] Unit tests cover decision engine logic
- [ ] Unit tests cover cycle phase calculations - [ ] Unit tests cover cycle phase calculations
- [ ] Integration tests verify API authentication - [ ] Integration tests verify API authentication
- [ ] E2E tests verify login flow
- [ ] E2E tests verify dashboard displays correctly
- [ ] E2E tests verify period logging works
- [ ] Tests run in CI pipeline - [ ] Tests run in CI pipeline

View File

@@ -9,11 +9,26 @@ import type { User } from "@/types";
// Module-level variable to control mock user in tests // Module-level variable to control mock user in tests
let currentMockUser: User | null = null; let currentMockUser: User | null = null;
// Create a mock PocketBase client that returns the current mock user
const createMockPb = () => ({
collection: vi.fn(() => ({
getOne: vi.fn(() =>
Promise.resolve(
currentMockUser
? {
...currentMockUser,
garminTokenExpiresAt:
currentMockUser.garminTokenExpiresAt?.toISOString(),
}
: null,
),
),
})),
});
// Mock PocketBase // Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({ vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({ createPocketBaseClient: vi.fn(() => createMockPb()),
collection: vi.fn(),
})),
})); }));
// Mock the auth-middleware module // Mock the auth-middleware module
@@ -23,7 +38,8 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) { if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
return handler(request, currentMockUser); const mockPb = createMockPb();
return handler(request, currentMockUser, mockPb);
}; };
}), }),
})); }));

View File

@@ -5,8 +5,10 @@ import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware"; import { withAuth } from "@/lib/auth-middleware";
import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin"; import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
export const GET = withAuth(async (_request, user) => { export const GET = withAuth(async (_request, user, pb) => {
const connected = user.garminConnected; // Fetch fresh user data from database (auth store cookie may be stale)
const freshUser = await pb.collection("users").getOne(user.id);
const connected = freshUser.garminConnected;
if (!connected) { if (!connected) {
return NextResponse.json({ return NextResponse.json({
@@ -17,10 +19,9 @@ export const GET = withAuth(async (_request, user) => {
}); });
} }
const expiresAt = const expiresAt = freshUser.garminTokenExpiresAt
user.garminTokenExpiresAt instanceof Date ? String(freshUser.garminTokenExpiresAt)
? user.garminTokenExpiresAt.toISOString() : "";
: String(user.garminTokenExpiresAt);
const tokens = { const tokens = {
oauth1: "", oauth1: "",

View File

@@ -32,7 +32,7 @@ export default function RootLayout({
> >
<a <a
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:text-blue-600 focus:underline" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-background focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:text-blue-600 dark:focus:text-blue-400 focus:underline focus:border focus:border-input"
> >
Skip to main content Skip to main content
</a> </a>

View File

@@ -200,7 +200,7 @@ export default function LoginPage() {
> >
<div className="w-full max-w-md space-y-8 p-8"> <div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1> <h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
<div className="text-center text-gray-500">Loading...</div> <div className="text-center text-muted-foreground">Loading...</div>
</div> </div>
</main> </main>
); );
@@ -217,7 +217,7 @@ export default function LoginPage() {
{error && ( {error && (
<div <div
role="alert" role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded" className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded"
> >
{error} {error}
</div> </div>
@@ -241,7 +241,7 @@ export default function LoginPage() {
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-foreground"
> >
Email Email
</label> </label>
@@ -251,7 +251,7 @@ export default function LoginPage() {
value={email} value={email}
onChange={(e) => handleInputChange(setEmail, e.target.value)} onChange={(e) => handleInputChange(setEmail, e.target.value)}
disabled={isLoading} disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
required required
/> />
</div> </div>
@@ -259,7 +259,7 @@ export default function LoginPage() {
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-foreground"
> >
Password Password
</label> </label>
@@ -269,7 +269,7 @@ export default function LoginPage() {
value={password} value={password}
onChange={(e) => handleInputChange(setPassword, e.target.value)} onChange={(e) => handleInputChange(setPassword, e.target.value)}
disabled={isLoading} disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
required required
/> />
</div> </div>

View File

@@ -156,7 +156,7 @@ export default function GarminSettingsPage() {
<h1 className="text-2xl font-bold mb-8"> <h1 className="text-2xl font-bold mb-8">
Settings &gt; Garmin Connection Settings &gt; Garmin Connection
</h1> </h1>
<p className="text-gray-500">Loading...</p> <p className="text-muted-foreground">Loading...</p>
</div> </div>
); );
} }
@@ -176,28 +176,30 @@ export default function GarminSettingsPage() {
{error && ( {error && (
<div <div
role="alert" role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6" className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
> >
{error} {error}
</div> </div>
)} )}
{success && ( {success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6"> <div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 px-4 py-3 rounded mb-6">
{success} {success}
</div> </div>
)} )}
<div className="max-w-lg space-y-6"> <div className="max-w-lg space-y-6">
{/* Connection Status Section */} {/* Connection Status Section */}
<div className="border border-gray-200 rounded-lg p-6"> <div className="border border-input rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Connection Status</h2> <h2 className="text-lg font-semibold mb-4">Connection Status</h2>
{status?.connected && !status.expired ? ( {status?.connected && !status.expired ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-green-500 rounded-full" /> <span className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-green-700 font-medium">Connected</span> <span className="text-green-700 dark:text-green-400 font-medium">
Connected
</span>
</div> </div>
{status.warningLevel && ( {status.warningLevel && (
@@ -205,8 +207,8 @@ export default function GarminSettingsPage() {
data-testid="expiry-warning" data-testid="expiry-warning"
className={`px-4 py-3 rounded ${ className={`px-4 py-3 rounded ${
status.warningLevel === "critical" status.warningLevel === "critical"
? "bg-red-50 border border-red-200 text-red-700" ? "bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400"
: "bg-yellow-50 border border-yellow-200 text-yellow-700" : "bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-400"
}`} }`}
> >
{status.warningLevel === "critical" {status.warningLevel === "critical"
@@ -215,7 +217,7 @@ export default function GarminSettingsPage() {
</div> </div>
)} )}
<p className="text-gray-600"> <p className="text-muted-foreground">
Token expires in{" "} Token expires in{" "}
<span className="font-medium"> <span className="font-medium">
{status.daysUntilExpiry} days {status.daysUntilExpiry} days
@@ -235,33 +237,35 @@ export default function GarminSettingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-red-500 rounded-full" /> <span className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-red-700 font-medium">Token Expired</span> <span className="text-red-700 dark:text-red-400 font-medium">
Token Expired
</span>
</div> </div>
<p className="text-gray-600"> <p className="text-muted-foreground">
Your Garmin tokens have expired. Please generate new tokens and Your Garmin tokens have expired. Please generate new tokens and
paste them below. paste them below.
</p> </p>
</div> </div>
) : ( ) : (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-gray-400 rounded-full" /> <span className="w-3 h-3 bg-muted-foreground rounded-full" />
<span className="text-gray-600">Not Connected</span> <span className="text-muted-foreground">Not Connected</span>
</div> </div>
)} )}
</div> </div>
{/* Token Input Section */} {/* Token Input Section */}
{showTokenInput && ( {showTokenInput && (
<div className="border border-gray-200 rounded-lg p-6"> <div className="border border-input rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2> <h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm"> <div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">
<p className="font-medium mb-2">Instructions:</p> <p className="font-medium mb-2">Instructions:</p>
<ol className="list-decimal list-inside space-y-1"> <ol className="list-decimal list-inside space-y-1">
<li> <li>
Run{" "} Run{" "}
<code className="bg-blue-100 px-1 rounded"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">
python3 scripts/garmin_auth.py python3 scripts/garmin_auth.py
</code>{" "} </code>{" "}
locally locally
@@ -274,7 +278,7 @@ export default function GarminSettingsPage() {
<div> <div>
<label <label
htmlFor="tokenInput" htmlFor="tokenInput"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-foreground mb-1"
> >
Paste Tokens (JSON) Paste Tokens (JSON)
</label> </label>
@@ -285,7 +289,7 @@ export default function GarminSettingsPage() {
onChange={(e) => handleTokenChange(e.target.value)} onChange={(e) => handleTokenChange(e.target.value)}
disabled={saving} disabled={saving}
placeholder='{"oauth1": {...}, "oauth2": {...}, "expires_at": "..."}' placeholder='{"oauth1": {...}, "oauth2": {...}, "expires_at": "..."}'
className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono text-sm" className="block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed font-mono text-sm"
/> />
</div> </div>

View File

@@ -132,7 +132,7 @@ export default function SettingsPage() {
return ( return (
<main id="main-content" className="container mx-auto p-8"> <main id="main-content" className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Settings</h1> <h1 className="text-2xl font-bold mb-8">Settings</h1>
<p className="text-gray-500">Loading...</p> <p className="text-muted-foreground">Loading...</p>
</main> </main>
); );
} }
@@ -152,31 +152,33 @@ export default function SettingsPage() {
{error && ( {error && (
<div <div
role="alert" role="alert"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6" className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
> >
{error} {error}
</div> </div>
)} )}
{success && ( {success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6"> <div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 px-4 py-3 rounded mb-6">
{success} {success}
</div> </div>
)} )}
<div className="max-w-lg"> <div className="max-w-lg">
<div className="mb-6"> <div className="mb-6">
<span className="block text-sm font-medium text-gray-700">Email</span> <span className="block text-sm font-medium text-foreground">
<p className="mt-1 text-gray-900">{userData?.email}</p> Email
</span>
<p className="mt-1 text-foreground">{userData?.email}</p>
</div> </div>
<div className="mb-6 p-4 border border-gray-200 rounded-lg"> <div className="mb-6 p-4 border border-input rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<span className="block text-sm font-medium text-gray-700"> <span className="block text-sm font-medium text-foreground">
Garmin Connection Garmin Connection
</span> </span>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-muted-foreground">
{userData?.garminConnected {userData?.garminConnected
? "Connected to Garmin" ? "Connected to Garmin"
: "Not connected"} : "Not connected"}
@@ -195,7 +197,7 @@ export default function SettingsPage() {
<div> <div>
<label <label
htmlFor="cycleLength" htmlFor="cycleLength"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-foreground"
> >
Cycle Length (days) Cycle Length (days)
</label> </label>
@@ -209,10 +211,10 @@ export default function SettingsPage() {
handleInputChange(setCycleLength, Number(e.target.value)) handleInputChange(setCycleLength, Number(e.target.value))
} }
disabled={saving} disabled={saving}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
required required
/> />
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-muted-foreground">
Typical range: 21-45 days Typical range: 21-45 days
</p> </p>
</div> </div>
@@ -220,7 +222,7 @@ export default function SettingsPage() {
<div> <div>
<label <label
htmlFor="notificationTime" htmlFor="notificationTime"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-foreground"
> >
Notification Time Notification Time
</label> </label>
@@ -232,10 +234,10 @@ export default function SettingsPage() {
handleInputChange(setNotificationTime, e.target.value) handleInputChange(setNotificationTime, e.target.value)
} }
disabled={saving} disabled={saving}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed [color-scheme:light] dark:[color-scheme:dark]"
required required
/> />
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-muted-foreground">
Time to receive daily email notification Time to receive daily email notification
</p> </p>
</div> </div>
@@ -243,7 +245,7 @@ export default function SettingsPage() {
<div> <div>
<label <label
htmlFor="timezone" htmlFor="timezone"
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-foreground"
> >
Timezone Timezone
</label> </label>
@@ -253,11 +255,11 @@ export default function SettingsPage() {
value={timezone} value={timezone}
onChange={(e) => handleInputChange(setTimezone, e.target.value)} onChange={(e) => handleInputChange(setTimezone, e.target.value)}
disabled={saving} disabled={saving}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
placeholder="America/New_York" placeholder="America/New_York"
required required
/> />
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-muted-foreground">
IANA timezone (e.g., America/New_York, Europe/London) IANA timezone (e.g., America/New_York, Europe/London)
</p> </p>
</div> </div>
@@ -273,8 +275,8 @@ export default function SettingsPage() {
</div> </div>
</form> </form>
<div className="mt-8 pt-8 border-t border-gray-200"> <div className="mt-8 pt-8 border-t border-input">
<h2 className="text-lg font-medium text-gray-900 mb-4">Account</h2> <h2 className="text-lg font-medium text-foreground mb-4">Account</h2>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}

View File

@@ -12,21 +12,27 @@ function getStatusColors(status: Decision["status"]): {
} { } {
switch (status) { switch (status) {
case "REST": case "REST":
return { background: "bg-red-100 border-red-300", text: "text-red-700" }; return {
background:
"bg-red-100 dark:bg-red-900/50 border-red-300 dark:border-red-700",
text: "text-red-700 dark:text-red-300",
};
case "GENTLE": case "GENTLE":
case "LIGHT": case "LIGHT":
case "REDUCED": case "REDUCED":
return { return {
background: "bg-yellow-100 border-yellow-300", background:
text: "text-yellow-700", "bg-yellow-100 dark:bg-yellow-900/50 border-yellow-300 dark:border-yellow-700",
text: "text-yellow-700 dark:text-yellow-300",
}; };
case "TRAIN": case "TRAIN":
return { return {
background: "bg-green-100 border-green-300", background:
text: "text-green-700", "bg-green-100 dark:bg-green-900/50 border-green-300 dark:border-green-700",
text: "text-green-700 dark:text-green-300",
}; };
default: default:
return { background: "border", text: "text-gray-600" }; return { background: "border", text: "text-muted-foreground" };
} }
} }

View File

@@ -14,21 +14,24 @@ interface MiniCalendarProps {
} }
const PHASE_COLORS: Record<CyclePhase, string> = { const PHASE_COLORS: Record<CyclePhase, string> = {
MENSTRUAL: "bg-blue-100", MENSTRUAL: "bg-blue-100 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100",
FOLLICULAR: "bg-green-100", FOLLICULAR:
OVULATION: "bg-purple-100", "bg-green-100 dark:bg-green-900/50 text-green-900 dark:text-green-100",
EARLY_LUTEAL: "bg-yellow-100", OVULATION:
LATE_LUTEAL: "bg-red-100", "bg-purple-100 dark:bg-purple-900/50 text-purple-900 dark:text-purple-100",
EARLY_LUTEAL:
"bg-yellow-100 dark:bg-yellow-900/50 text-yellow-900 dark:text-yellow-100",
LATE_LUTEAL: "bg-red-100 dark:bg-red-900/50 text-red-900 dark:text-red-100",
}; };
const COMPACT_DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"]; const COMPACT_DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
const PHASE_LEGEND = [ const PHASE_LEGEND = [
{ name: "Menstrual", color: "bg-blue-100" }, { name: "Menstrual", color: "bg-blue-100 dark:bg-blue-900/50" },
{ name: "Follicular", color: "bg-green-100" }, { name: "Follicular", color: "bg-green-100 dark:bg-green-900/50" },
{ name: "Ovulation", color: "bg-purple-100" }, { name: "Ovulation", color: "bg-purple-100 dark:bg-purple-900/50" },
{ name: "Early Luteal", color: "bg-yellow-100" }, { name: "Early Luteal", color: "bg-yellow-100 dark:bg-yellow-900/50" },
{ name: "Late Luteal", color: "bg-red-100" }, { name: "Late Luteal", color: "bg-red-100 dark:bg-red-900/50" },
]; ];
function getDaysInMonth(year: number, month: number): number { function getDaysInMonth(year: number, month: number): number {
@@ -102,7 +105,7 @@ export function MiniCalendar({
<button <button
type="button" type="button"
onClick={handlePreviousMonth} onClick={handlePreviousMonth}
className="p-1 hover:bg-gray-100 rounded text-sm" className="p-1 hover:bg-muted rounded text-sm"
aria-label="Previous month" aria-label="Previous month"
> >
@@ -117,7 +120,7 @@ export function MiniCalendar({
<button <button
type="button" type="button"
onClick={handleTodayClick} onClick={handleTodayClick}
className="px-2 py-0.5 text-xs border rounded hover:bg-gray-100" className="px-2 py-0.5 text-xs border rounded hover:bg-muted"
> >
Today Today
</button> </button>
@@ -125,7 +128,7 @@ export function MiniCalendar({
<button <button
type="button" type="button"
onClick={handleNextMonth} onClick={handleNextMonth}
className="p-1 hover:bg-gray-100 rounded text-sm" className="p-1 hover:bg-muted rounded text-sm"
aria-label="Next month" aria-label="Next month"
> >
@@ -138,7 +141,7 @@ export function MiniCalendar({
<div <div
// biome-ignore lint/suspicious/noArrayIndexKey: Day names are fixed and index is stable // biome-ignore lint/suspicious/noArrayIndexKey: Day names are fixed and index is stable
key={`day-header-${index}`} key={`day-header-${index}`}
className="text-center text-xs font-medium text-gray-500" className="text-center text-xs font-medium text-muted-foreground"
> >
{dayName} {dayName}
</div> </div>
@@ -165,7 +168,7 @@ export function MiniCalendar({
type="button" type="button"
key={date.toISOString()} key={date.toISOString()}
className={`p-1 text-xs rounded ${PHASE_COLORS[phase]} ${ className={`p-1 text-xs rounded ${PHASE_COLORS[phase]} ${
isToday ? "ring-2 ring-black font-bold" : "" isToday ? "ring-2 ring-foreground font-bold" : ""
}`} }`}
> >
{date.getDate()} {date.getDate()}
@@ -182,7 +185,7 @@ export function MiniCalendar({
{PHASE_LEGEND.map((phase) => ( {PHASE_LEGEND.map((phase) => (
<div key={phase.name} className="flex items-center gap-0.5"> <div key={phase.name} className="flex items-center gap-0.5">
<div className={`w-2 h-2 rounded ${phase.color}`} /> <div className={`w-2 h-2 rounded ${phase.color}`} />
<span className="text-xs text-gray-600">{phase.name}</span> <span className="text-xs text-muted-foreground">{phase.name}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -13,7 +13,7 @@ export default defineConfig({
}, },
test: { test: {
environment: "jsdom", environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"], include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"],
setupFiles: ["./src/test-setup.ts"], setupFiles: ["./src/test-setup.ts"],
}, },
}); });