diff --git a/AGENTS.md b/AGENTS.md index 4d3c786..45c7b42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,19 @@ Run these after implementing to get immediate feedback: - Path aliases: `@/*` maps to `./src/*` - 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 - TDD required: Write tests before implementation diff --git a/package.json b/package.json index 349ca01..01e68fe 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "db:setup": "npx tsx scripts/setup-db.ts" }, "dependencies": { "class-variance-authority": "^0.7.1", diff --git a/scripts/setup-db.test.ts b/scripts/setup-db.test.ts new file mode 100644 index 0000000..3d41a9f --- /dev/null +++ b/scripts/setup-db.test.ts @@ -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); + }); +}); diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts new file mode 100644 index 0000000..33d17cc --- /dev/null +++ b/scripts/setup-db.ts @@ -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 { + 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 { + const collection = await pb.collections.getOne(nameOrId); + return collection.id; +} + +/** + * Creates a collection in PocketBase. + */ +async function createCollection( + pb: PocketBase, + collection: CollectionDefinition, +): Promise { + // Resolve any collection names to actual IDs for relation fields + const fields = await Promise.all( + collection.fields.map(async (field) => { + const baseField: Record = { + 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 { + // 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); + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 72cc07d..f2a2730 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,7 +32,7 @@ export default function RootLayout({ > Skip to main content diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7a715bc..e724814 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -200,7 +200,7 @@ export default function LoginPage() { >

PhaseFlow

-
Loading...
+
Loading...
); @@ -217,7 +217,7 @@ export default function LoginPage() { {error && (
{error}
@@ -241,7 +241,7 @@ export default function LoginPage() {
@@ -251,7 +251,7 @@ export default function LoginPage() { value={email} onChange={(e) => handleInputChange(setEmail, e.target.value)} 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 />
@@ -259,7 +259,7 @@ export default function LoginPage() {
@@ -269,7 +269,7 @@ export default function LoginPage() { value={password} onChange={(e) => handleInputChange(setPassword, e.target.value)} 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 />
diff --git a/src/app/settings/garmin/page.tsx b/src/app/settings/garmin/page.tsx index dd581c1..0750707 100644 --- a/src/app/settings/garmin/page.tsx +++ b/src/app/settings/garmin/page.tsx @@ -156,7 +156,7 @@ export default function GarminSettingsPage() {

Settings > Garmin Connection

-

Loading...

+

Loading...

); } @@ -176,28 +176,30 @@ export default function GarminSettingsPage() { {error && (
{error}
)} {success && ( -
+
{success}
)}
{/* Connection Status Section */} -
+

Connection Status

{status?.connected && !status.expired ? (
- Connected + + Connected +
{status.warningLevel && ( @@ -205,8 +207,8 @@ export default function GarminSettingsPage() { data-testid="expiry-warning" className={`px-4 py-3 rounded ${ status.warningLevel === "critical" - ? "bg-red-50 border border-red-200 text-red-700" - : "bg-yellow-50 border border-yellow-200 text-yellow-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 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-400" }`} > {status.warningLevel === "critical" @@ -215,7 +217,7 @@ export default function GarminSettingsPage() {
)} -

+

Token expires in{" "} {status.daysUntilExpiry} days @@ -235,33 +237,35 @@ export default function GarminSettingsPage() {

- Token Expired + + Token Expired +
-

+

Your Garmin tokens have expired. Please generate new tokens and paste them below.

) : (
- - Not Connected + + Not Connected
)}
{/* Token Input Section */} {showTokenInput && ( -
+

Connect Garmin

-
+

Instructions:

  1. Run{" "} - + python3 scripts/garmin_auth.py {" "} locally @@ -274,7 +278,7 @@ export default function GarminSettingsPage() {
    @@ -285,7 +289,7 @@ export default function GarminSettingsPage() { onChange={(e) => handleTokenChange(e.target.value)} disabled={saving} 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" />
    diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index a18273e..1e9b802 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -132,7 +132,7 @@ export default function SettingsPage() { return (

    Settings

    -

    Loading...

    +

    Loading...

    ); } @@ -152,31 +152,33 @@ export default function SettingsPage() { {error && (
    {error}
    )} {success && ( -
    +
    {success}
    )}
    - Email -

    {userData?.email}

    + + Email + +

    {userData?.email}

    -
    +
    - + Garmin Connection -

    +

    {userData?.garminConnected ? "Connected to Garmin" : "Not connected"} @@ -195,7 +197,7 @@ export default function SettingsPage() {

    @@ -209,10 +211,10 @@ export default function SettingsPage() { handleInputChange(setCycleLength, Number(e.target.value)) } 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 /> -

    +

    Typical range: 21-45 days

    @@ -220,7 +222,7 @@ export default function SettingsPage() {
    @@ -232,10 +234,10 @@ export default function SettingsPage() { handleInputChange(setNotificationTime, e.target.value) } 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 /> -

    +

    Time to receive daily email notification

    @@ -243,7 +245,7 @@ export default function SettingsPage() {
    @@ -253,11 +255,11 @@ export default function SettingsPage() { value={timezone} onChange={(e) => handleInputChange(setTimezone, e.target.value)} 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" required /> -

    +

    IANA timezone (e.g., America/New_York, Europe/London)

    @@ -273,8 +275,8 @@ export default function SettingsPage() {
    -
    -

    Account

    +
    +

    Account

    @@ -125,7 +128,7 @@ export function MiniCalendar({