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:
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user