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

62
src/lib/cycle.test.ts Normal file
View File

@@ -0,0 +1,62 @@
// ABOUTME: Unit tests for cycle phase calculation utilities.
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions.
import { describe, expect, it } from "vitest";
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
describe("getCycleDay", () => {
it("returns 1 on the first day of the cycle", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-01-01");
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
});
it("returns correct day within cycle", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-01-15");
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(15);
});
it("wraps around after cycle length", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-02-01"); // 31 days later
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
});
});
describe("getPhase", () => {
it("returns MENSTRUAL for days 1-3", () => {
expect(getPhase(1)).toBe("MENSTRUAL");
expect(getPhase(3)).toBe("MENSTRUAL");
});
it("returns FOLLICULAR for days 4-14", () => {
expect(getPhase(4)).toBe("FOLLICULAR");
expect(getPhase(14)).toBe("FOLLICULAR");
});
it("returns OVULATION for days 15-16", () => {
expect(getPhase(15)).toBe("OVULATION");
expect(getPhase(16)).toBe("OVULATION");
});
it("returns EARLY_LUTEAL for days 17-24", () => {
expect(getPhase(17)).toBe("EARLY_LUTEAL");
expect(getPhase(24)).toBe("EARLY_LUTEAL");
});
it("returns LATE_LUTEAL for days 25-31", () => {
expect(getPhase(25)).toBe("LATE_LUTEAL");
expect(getPhase(31)).toBe("LATE_LUTEAL");
});
});
describe("getPhaseLimit", () => {
it("returns correct weekly limits for each phase", () => {
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
expect(getPhaseLimit("OVULATION")).toBe(80);
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
});
});

73
src/lib/cycle.ts Normal file
View File

@@ -0,0 +1,73 @@
// ABOUTME: Cycle phase calculation utilities.
// ABOUTME: Determines current cycle day and phase from last period date.
import type { CyclePhase, PhaseConfig } from "@/types";
export const PHASE_CONFIGS: PhaseConfig[] = [
{
name: "MENSTRUAL",
days: [1, 3],
weeklyLimit: 30,
dailyAvg: 10,
trainingType: "Gentle rebounding only",
},
{
name: "FOLLICULAR",
days: [4, 14],
weeklyLimit: 120,
dailyAvg: 17,
trainingType: "Strength + rebounding",
},
{
name: "OVULATION",
days: [15, 16],
weeklyLimit: 80,
dailyAvg: 40,
trainingType: "Peak performance",
},
{
name: "EARLY_LUTEAL",
days: [17, 24],
weeklyLimit: 100,
dailyAvg: 14,
trainingType: "Moderate training",
},
{
name: "LATE_LUTEAL",
days: [25, 31],
weeklyLimit: 50,
dailyAvg: 8,
trainingType: "Gentle rebounding ONLY",
},
];
export function getCycleDay(
lastPeriodDate: Date,
cycleLength: number,
currentDate: Date = new Date(),
): number {
const diffMs = currentDate.getTime() - lastPeriodDate.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
return (diffDays % cycleLength) + 1;
}
export function getPhase(cycleDay: number): CyclePhase {
for (const config of PHASE_CONFIGS) {
if (cycleDay >= config.days[0] && cycleDay <= config.days[1]) {
return config.name;
}
}
// Default to late luteal for any days beyond 31
return "LATE_LUTEAL";
}
export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
const config = PHASE_CONFIGS.find((c) => c.name === phase);
if (!config) {
throw new Error(`Unknown phase: ${phase}`);
}
return config;
}
export function getPhaseLimit(phase: CyclePhase): number {
return getPhaseConfig(phase).weeklyLimit;
}

View File

@@ -0,0 +1,64 @@
// ABOUTME: Training decision engine based on biometric and cycle data.
// ABOUTME: Implements priority-based rules for daily training recommendations.
import type { DailyData, Decision } from "@/types";
export function getTrainingDecision(data: DailyData): Decision {
const {
hrvStatus,
bbYesterdayLow,
phase,
weekIntensity,
phaseLimit,
bbCurrent,
} = data;
if (hrvStatus === "Unbalanced") {
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
}
if (bbYesterdayLow < 30) {
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
}
if (phase === "LATE_LUTEAL") {
return {
status: "GENTLE",
reason: "Gentle rebounding only (10-15min)",
icon: "🟡",
};
}
if (phase === "MENSTRUAL") {
return {
status: "GENTLE",
reason: "Gentle rebounding only (10min)",
icon: "🟡",
};
}
if (weekIntensity >= phaseLimit) {
return {
status: "REST",
reason: "WEEKLY LIMIT REACHED - Rest",
icon: "🛑",
};
}
if (bbCurrent < 75) {
return {
status: "LIGHT",
reason: "Light activity only - BB not recovered",
icon: "🟡",
};
}
if (bbCurrent < 85) {
return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" };
}
return {
status: "TRAIN",
reason: "OK to train - follow phase plan",
icon: "✅",
};
}

83
src/lib/email.ts Normal file
View File

@@ -0,0 +1,83 @@
// ABOUTME: Email sending utilities using Resend.
// ABOUTME: Sends daily training notifications and period confirmation emails.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
export interface DailyEmailData {
to: string;
cycleDay: number;
phase: string;
decision: {
status: string;
reason: string;
icon: string;
};
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
hrvStatus: string;
weekIntensity: number;
phaseLimit: number;
remainingMinutes: number;
seeds: string;
carbRange: string;
ketoGuidance: string;
}
export async function sendDailyEmail(data: DailyEmailData): Promise<void> {
const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`;
const body = `Good morning!
📅 CYCLE DAY: ${data.cycleDay} (${data.phase})
💪 TODAY'S PLAN:
${data.decision.icon} ${data.decision.reason}
📊 YOUR DATA:
• Body Battery Now: ${data.bodyBatteryCurrent ?? "N/A"}
• Yesterday's Low: ${data.bodyBatteryYesterdayLow ?? "N/A"}
• HRV Status: ${data.hrvStatus}
• Week Intensity: ${data.weekIntensity} / ${data.phaseLimit} minutes
• Remaining: ${data.remainingMinutes} minutes
🌱 SEEDS: ${data.seeds}
🍽️ MACROS: ${data.carbRange}
🥑 KETO: ${data.ketoGuidance}
---
Auto-generated by PhaseFlow`;
await resend.emails.send({
from: EMAIL_FROM,
to: data.to,
subject,
text: body,
});
}
export async function sendPeriodConfirmationEmail(
to: string,
lastPeriodDate: Date,
cycleLength: number,
): Promise<void> {
const subject = "🔵 Period Tracking Updated";
const body = `Your cycle has been reset. Last period: ${lastPeriodDate.toLocaleDateString()}
Phase calendar updated for next ${cycleLength} days.
Your calendar will update automatically within 24 hours.
---
Auto-generated by PhaseFlow`;
await resend.emails.send({
from: EMAIL_FROM,
to,
subject,
text: body,
});
}

49
src/lib/encryption.ts Normal file
View File

@@ -0,0 +1,49 @@
// ABOUTME: AES-256 encryption utilities for sensitive data like Garmin tokens.
// ABOUTME: Provides encrypt/decrypt functions using environment-based keys.
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key) {
throw new Error("ENCRYPTION_KEY environment variable is required");
}
// Ensure key is exactly 32 bytes for AES-256
return Buffer.from(key.padEnd(32, "0").slice(0, 32));
}
export function encrypt(plaintext: string): string {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
export function decrypt(ciphertext: string): string {
const key = getEncryptionKey();
const [ivHex, authTagHex, encrypted] = ciphertext.split(":");
if (!ivHex || !authTagHex || !encrypted) {
throw new Error("Invalid ciphertext format");
}
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}

39
src/lib/garmin.ts Normal file
View File

@@ -0,0 +1,39 @@
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
import type { GarminTokens } from "@/types";
const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy";
interface GarminApiOptions {
oauth2Token: string;
}
export async function fetchGarminData(
endpoint: string,
options: GarminApiOptions,
): Promise<unknown> {
const response = await fetch(`${GARMIN_BASE_URL}${endpoint}`, {
headers: {
Authorization: `Bearer ${options.oauth2Token}`,
NK: "NT",
},
});
if (!response.ok) {
throw new Error(`Garmin API error: ${response.status}`);
}
return response.json();
}
export function isTokenExpired(tokens: GarminTokens): boolean {
const expiresAt = new Date(tokens.expires_at);
return expiresAt <= new Date();
}
export function daysUntilExpiry(tokens: GarminTokens): number {
const expiresAt = new Date(tokens.expires_at);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}

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];
}

44
src/lib/nutrition.ts Normal file
View File

@@ -0,0 +1,44 @@
// ABOUTME: Nutrition guidance based on cycle day.
// ABOUTME: Provides seed cycling and macro recommendations by phase.
import type { NutritionGuidance } from "@/types";
export function getNutritionGuidance(cycleDay: number): NutritionGuidance {
// Seed cycling
const seeds =
cycleDay <= 14
? "Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)"
: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)";
// Macro guidance by day range
let carbRange: string;
let ketoGuidance: string;
if (cycleDay >= 1 && cycleDay <= 3) {
carbRange = "100-150g";
ketoGuidance = "No - body needs carbs during menstruation";
} else if (cycleDay >= 4 && cycleDay <= 6) {
carbRange = "75-100g";
ketoGuidance = "No - transition phase";
} else if (cycleDay >= 7 && cycleDay <= 14) {
carbRange = "20-100g";
ketoGuidance = "OPTIONAL - optimal keto window";
} else if (cycleDay >= 15 && cycleDay <= 16) {
carbRange = "100-150g";
ketoGuidance = "No - exit keto, need carbs for ovulation";
} else if (cycleDay >= 17 && cycleDay <= 24) {
carbRange = "75-125g";
ketoGuidance = "No - progesterone needs carbs";
} else {
carbRange = "100-150g+";
ketoGuidance = "NEVER - mood/hormones need carbs for PMS";
}
return { seeds, carbRange, ketoGuidance };
}
export function getSeedSwitchAlert(cycleDay: number): string | null {
if (cycleDay === 15) {
return "🌱 SWITCH TODAY! Start Sesame + Sunflower";
}
return null;
}

10
src/lib/pocketbase.ts Normal file
View File

@@ -0,0 +1,10 @@
// ABOUTME: PocketBase client initialization and utilities.
// ABOUTME: Provides typed access to the PocketBase backend for auth and data.
import PocketBase from "pocketbase";
const POCKETBASE_URL = process.env.POCKETBASE_URL || "http://localhost:8090";
export const pb = new PocketBase(POCKETBASE_URL);
// Disable auto-cancellation for server-side usage
pb.autoCancellation(false);

8
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,8 @@
// ABOUTME: Utility functions for className merging with Tailwind CSS.
// ABOUTME: Provides cn() helper for combining conditional class names.
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}