Initial project setup for PhaseFlow

Set up Next.js 16 project with TypeScript for a training decision app
that integrates menstrual cycle phases with Garmin biometrics for
Hashimoto's thyroiditis management.

Stack: Next.js 16, React 19, Tailwind/shadcn, PocketBase, Drizzle,
Zod, Resend, Vitest, Biome, Lefthook, Nix dev environment.

Includes:
- 7 page routes (dashboard, login, settings, calendar, history, plan)
- 12 API endpoints (garmin, user, cycle, calendar, overrides, cron)
- Core lib utilities (decision engine, cycle phases, nutrition, ICS)
- Type definitions and component scaffolding
- Python script for Garmin token bootstrapping
- Initial unit tests for cycle utilities

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-09 16:50:39 +00:00
commit f15e093254
63 changed files with 6061 additions and 0 deletions

97
src/lib/ics.ts Normal file
View File

@@ -0,0 +1,97 @@
// ABOUTME: ICS calendar feed generation for cycle phase events.
// ABOUTME: Creates subscribable calendar with phase blocks and warnings.
import { createEvents, type EventAttributes } from "ics";
import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle";
const PHASE_EMOJIS: Record<string, string> = {
MENSTRUAL: "🔵",
FOLLICULAR: "🟢",
OVULATION: "🟣",
EARLY_LUTEAL: "🟡",
LATE_LUTEAL: "🔴",
};
interface IcsGeneratorOptions {
lastPeriodDate: Date;
cycleLength: number;
monthsAhead?: number;
}
export function generateIcsFeed(options: IcsGeneratorOptions): string {
const { lastPeriodDate, cycleLength, monthsAhead = 3 } = options;
const events: EventAttributes[] = [];
const endDate = new Date();
endDate.setMonth(endDate.getMonth() + monthsAhead);
const currentDate = new Date(lastPeriodDate);
let currentPhase = getPhase(
getCycleDay(lastPeriodDate, cycleLength, currentDate),
);
let phaseStartDate = new Date(currentDate);
while (currentDate <= endDate) {
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate);
const phase = getPhase(cycleDay);
// Add warning events
if (cycleDay === 22) {
events.push({
start: dateToArray(currentDate),
end: dateToArray(currentDate),
title: "⚠️ Late Luteal Phase Starts in 3 Days",
description: "Begin reducing training intensity",
});
}
if (cycleDay === 25) {
events.push({
start: dateToArray(currentDate),
end: dateToArray(currentDate),
title: "🔴 CRITICAL PHASE - Gentle Rebounding Only!",
description: "Late luteal phase - protect your cycle",
});
}
// Track phase changes
if (phase !== currentPhase) {
// Close previous phase event
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
currentPhase = phase;
phaseStartDate = new Date(currentDate);
}
currentDate.setDate(currentDate.getDate() + 1);
}
// Close final phase
events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate));
const { value, error } = createEvents(events);
if (error) {
throw new Error(`ICS generation error: ${error}`);
}
return value || "";
}
function createPhaseEvent(
phase: string,
startDate: Date,
endDate: Date,
): EventAttributes {
const config = PHASE_CONFIGS.find((c) => c.name === phase);
const emoji = PHASE_EMOJIS[phase] || "📅";
return {
start: dateToArray(startDate),
end: dateToArray(endDate),
title: `${emoji} ${phase.replace("_", " ")}`,
description: config?.trainingType || "",
};
}
function dateToArray(date: Date): [number, number, number, number, number] {
return [date.getFullYear(), date.getMonth() + 1, date.getDate(), 0, 0];
}