Add database setup script and fix dark mode visibility

- Add scripts/setup-db.ts to programmatically create missing PocketBase
  collections (period_logs, dailyLogs) with proper relation fields
- Fix dark mode visibility across settings, login, calendar, and dashboard
  components by using semantic CSS tokens and dark: variants
- Add db:setup npm script and document usage in AGENTS.md
- Update vitest config to include scripts directory tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 21:23:20 +00:00
parent ca35b36efa
commit ce80fb1ede
11 changed files with 547 additions and 67 deletions

View File

@@ -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

View File

@@ -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",

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

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

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

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

View File

@@ -32,7 +32,7 @@ export default function RootLayout({
>
<a
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
</a>

View File

@@ -200,7 +200,7 @@ export default function LoginPage() {
>
<div className="w-full max-w-md space-y-8 p-8">
<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>
</main>
);
@@ -217,7 +217,7 @@ export default function LoginPage() {
{error && (
<div
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}
</div>
@@ -241,7 +241,7 @@ export default function LoginPage() {
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Email
</label>
@@ -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
/>
</div>
@@ -259,7 +259,7 @@ export default function LoginPage() {
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Password
</label>
@@ -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
/>
</div>

View File

@@ -156,7 +156,7 @@ export default function GarminSettingsPage() {
<h1 className="text-2xl font-bold mb-8">
Settings &gt; Garmin Connection
</h1>
<p className="text-gray-500">Loading...</p>
<p className="text-muted-foreground">Loading...</p>
</div>
);
}
@@ -176,28 +176,30 @@ export default function GarminSettingsPage() {
{error && (
<div
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}
</div>
)}
{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}
</div>
)}
<div className="max-w-lg space-y-6">
{/* 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>
{status?.connected && !status.expired ? (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<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>
{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() {
</div>
)}
<p className="text-gray-600">
<p className="text-muted-foreground">
Token expires in{" "}
<span className="font-medium">
{status.daysUntilExpiry} days
@@ -235,33 +237,35 @@ export default function GarminSettingsPage() {
<div className="space-y-4">
<div className="flex items-center space-x-2">
<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>
<p className="text-gray-600">
<p className="text-muted-foreground">
Your Garmin tokens have expired. Please generate new tokens and
paste them below.
</p>
</div>
) : (
<div className="flex items-center space-x-2">
<span className="w-3 h-3 bg-gray-400 rounded-full" />
<span className="text-gray-600">Not Connected</span>
<span className="w-3 h-3 bg-muted-foreground rounded-full" />
<span className="text-muted-foreground">Not Connected</span>
</div>
)}
</div>
{/* Token Input Section */}
{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>
<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>
<ol className="list-decimal list-inside space-y-1">
<li>
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
</code>{" "}
locally
@@ -274,7 +278,7 @@ export default function GarminSettingsPage() {
<div>
<label
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)
</label>
@@ -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"
/>
</div>

View File

@@ -132,7 +132,7 @@ export default function SettingsPage() {
return (
<main id="main-content" className="container mx-auto p-8">
<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>
);
}
@@ -152,31 +152,33 @@ export default function SettingsPage() {
{error && (
<div
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}
</div>
)}
{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}
</div>
)}
<div className="max-w-lg">
<div className="mb-6">
<span className="block text-sm font-medium text-gray-700">Email</span>
<p className="mt-1 text-gray-900">{userData?.email}</p>
<span className="block text-sm font-medium text-foreground">
Email
</span>
<p className="mt-1 text-foreground">{userData?.email}</p>
</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>
<span className="block text-sm font-medium text-gray-700">
<span className="block text-sm font-medium text-foreground">
Garmin Connection
</span>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
{userData?.garminConnected
? "Connected to Garmin"
: "Not connected"}
@@ -195,7 +197,7 @@ export default function SettingsPage() {
<div>
<label
htmlFor="cycleLength"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Cycle Length (days)
</label>
@@ -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
/>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
Typical range: 21-45 days
</p>
</div>
@@ -220,7 +222,7 @@ export default function SettingsPage() {
<div>
<label
htmlFor="notificationTime"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Notification Time
</label>
@@ -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
/>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-muted-foreground">
Time to receive daily email notification
</p>
</div>
@@ -243,7 +245,7 @@ export default function SettingsPage() {
<div>
<label
htmlFor="timezone"
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-foreground"
>
Timezone
</label>
@@ -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
/>
<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)
</p>
</div>
@@ -273,8 +275,8 @@ export default function SettingsPage() {
</div>
</form>
<div className="mt-8 pt-8 border-t border-gray-200">
<h2 className="text-lg font-medium text-gray-900 mb-4">Account</h2>
<div className="mt-8 pt-8 border-t border-input">
<h2 className="text-lg font-medium text-foreground mb-4">Account</h2>
<button
type="button"
onClick={handleLogout}

View File

@@ -12,21 +12,27 @@ function getStatusColors(status: Decision["status"]): {
} {
switch (status) {
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 "LIGHT":
case "REDUCED":
return {
background: "bg-yellow-100 border-yellow-300",
text: "text-yellow-700",
background:
"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":
return {
background: "bg-green-100 border-green-300",
text: "text-green-700",
background:
"bg-green-100 dark:bg-green-900/50 border-green-300 dark:border-green-700",
text: "text-green-700 dark:text-green-300",
};
default:
return { background: "border", text: "text-gray-600" };
return { background: "border", text: "text-muted-foreground" };
}
}

View File

@@ -14,21 +14,24 @@ interface MiniCalendarProps {
}
const PHASE_COLORS: Record<CyclePhase, string> = {
MENSTRUAL: "bg-blue-100",
FOLLICULAR: "bg-green-100",
OVULATION: "bg-purple-100",
EARLY_LUTEAL: "bg-yellow-100",
LATE_LUTEAL: "bg-red-100",
MENSTRUAL: "bg-blue-100 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100",
FOLLICULAR:
"bg-green-100 dark:bg-green-900/50 text-green-900 dark:text-green-100",
OVULATION:
"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 PHASE_LEGEND = [
{ name: "Menstrual", color: "bg-blue-100" },
{ name: "Follicular", color: "bg-green-100" },
{ name: "Ovulation", color: "bg-purple-100" },
{ name: "Early Luteal", color: "bg-yellow-100" },
{ name: "Late Luteal", color: "bg-red-100" },
{ name: "Menstrual", color: "bg-blue-100 dark:bg-blue-900/50" },
{ name: "Follicular", color: "bg-green-100 dark:bg-green-900/50" },
{ name: "Ovulation", color: "bg-purple-100 dark:bg-purple-900/50" },
{ name: "Early Luteal", color: "bg-yellow-100 dark:bg-yellow-900/50" },
{ name: "Late Luteal", color: "bg-red-100 dark:bg-red-900/50" },
];
function getDaysInMonth(year: number, month: number): number {
@@ -102,7 +105,7 @@ export function MiniCalendar({
<button
type="button"
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"
>
@@ -117,7 +120,7 @@ export function MiniCalendar({
<button
type="button"
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
</button>
@@ -125,7 +128,7 @@ export function MiniCalendar({
<button
type="button"
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"
>
@@ -138,7 +141,7 @@ export function MiniCalendar({
<div
// biome-ignore lint/suspicious/noArrayIndexKey: Day names are fixed and index is stable
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}
</div>
@@ -165,7 +168,7 @@ export function MiniCalendar({
type="button"
key={date.toISOString()}
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()}
@@ -182,7 +185,7 @@ export function MiniCalendar({
{PHASE_LEGEND.map((phase) => (
<div key={phase.name} className="flex items-center gap-0.5">
<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>

View File

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