Compare commits
5 Commits
c8a738d0c4
...
dbf0c32588
| Author | SHA1 | Date | |
|---|---|---|---|
| dbf0c32588 | |||
| 6bd5eb663b | |||
| 30c5955a61 | |||
| ce80fb1ede | |||
| ca35b36efa |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
AGENTS.md
13
AGENTS.md
@@ -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
36
e2e/smoke.spec.ts
Normal 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
68
flake.lock
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
30
flake.nix
30
flake.nix
@@ -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";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
46
playwright.config.ts
Normal 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
43
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -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
164
scripts/setup-db.test.ts
Normal 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
287
scripts/setup-db.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
2
spec.md
2
spec.md
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
130
specs/testing.md
130
specs/testing.md
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 > Garmin Connection
|
Settings > 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user