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:
62
src/lib/cycle.test.ts
Normal file
62
src/lib/cycle.test.ts
Normal 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
73
src/lib/cycle.ts
Normal 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;
|
||||
}
|
||||
64
src/lib/decision-engine.ts
Normal file
64
src/lib/decision-engine.ts
Normal 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
83
src/lib/email.ts
Normal 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
49
src/lib/encryption.ts
Normal 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
39
src/lib/garmin.ts
Normal 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
97
src/lib/ics.ts
Normal 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
44
src/lib/nutrition.ts
Normal 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
10
src/lib/pocketbase.ts
Normal 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
8
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user