Files
phaseflow/src/components/dashboard/mini-calendar.tsx
Petru Paler a977934c23 Fix critical bug: cycle phase boundaries now scale with cycle length
CRITICAL BUG FIX:
- Phase boundaries were hardcoded for 31-day cycle, breaking correct
  phase calculations for users with different cycle lengths (28, 35, etc.)
- Added getPhaseBoundaries(cycleLength) function in cycle.ts
- Updated getPhase() to accept cycleLength parameter (default 31)
- Updated all callers (API routes, components) to pass cycleLength
- Added 13 new tests for phase boundaries with 28, 31, and 35-day cycles

ICS IMPROVEMENTS:
- Fixed emojis to match calendar.md spec: 🩸🌱🌸🌙🌑
- Added CATEGORIES field for calendar app colors per spec:
  MENSTRUAL=Red, FOLLICULAR=Green, OVULATION=Pink,
  EARLY_LUTEAL=Yellow, LATE_LUTEAL=Orange
- Added 5 new tests for CATEGORIES

Updated IMPLEMENTATION_PLAN.md with discovered issues and test counts.

825 tests passing (up from 807)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:39:09 +00:00

192 lines
5.6 KiB
TypeScript

// ABOUTME: Compact calendar widget for the dashboard.
// ABOUTME: Shows current month with color-coded cycle phases and navigation.
"use client";
import { getCycleDay, getPhase } from "@/lib/cycle";
import type { CyclePhase } from "@/types";
interface MiniCalendarProps {
lastPeriodDate: Date;
cycleLength: number;
year?: number;
month?: number;
onMonthChange?: (year: number, month: number) => void;
}
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",
};
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" },
];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
export function MiniCalendar({
lastPeriodDate,
cycleLength,
year,
month,
onMonthChange,
}: MiniCalendarProps) {
const today = new Date();
const displayYear = year ?? today.getFullYear();
const displayMonth = month ?? today.getMonth();
const daysInMonth = getDaysInMonth(displayYear, displayMonth);
const firstDayOfWeek = getFirstDayOfMonth(displayYear, displayMonth);
// Calculate current cycle day and phase for the header
const currentCycleDay = getCycleDay(lastPeriodDate, cycleLength, today);
const currentPhase = getPhase(currentCycleDay, cycleLength);
const handlePreviousMonth = () => {
if (displayMonth === 0) {
onMonthChange?.(displayYear - 1, 11);
} else {
onMonthChange?.(displayYear, displayMonth - 1);
}
};
const handleNextMonth = () => {
if (displayMonth === 11) {
onMonthChange?.(displayYear + 1, 0);
} else {
onMonthChange?.(displayYear, displayMonth + 1);
}
};
const handleTodayClick = () => {
onMonthChange?.(today.getFullYear(), today.getMonth());
};
// Build array of day cells
const days: (Date | null)[] = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < firstDayOfWeek; i++) {
days.push(null);
}
// Add the actual days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(displayYear, displayMonth, day));
}
return (
<div className="rounded-lg border p-4">
{/* Cycle info header */}
<h3 className="font-semibold mb-2">
Day {currentCycleDay} · {currentPhase}
</h3>
{/* Navigation with month/year */}
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={handlePreviousMonth}
className="p-1 hover:bg-gray-100 rounded text-sm"
aria-label="Previous month"
>
</button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{new Date(displayYear, displayMonth).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</span>
<button
type="button"
onClick={handleTodayClick}
className="px-2 py-0.5 text-xs border rounded hover:bg-gray-100"
>
Today
</button>
</div>
<button
type="button"
onClick={handleNextMonth}
className="p-1 hover:bg-gray-100 rounded text-sm"
aria-label="Next month"
>
</button>
</div>
{/* Compact day of week headers */}
<div className="grid grid-cols-7 gap-0.5 mb-1">
{COMPACT_DAY_NAMES.map((dayName, index) => (
<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"
>
{dayName}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-0.5">
{days.map((date, index) => {
if (!date) {
// biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month
return <div key={`empty-${index}`} className="p-1" />;
}
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
const phase = getPhase(cycleDay, cycleLength);
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
return (
<button
type="button"
key={date.toISOString()}
className={`p-1 text-xs rounded ${PHASE_COLORS[phase]} ${
isToday ? "ring-2 ring-black font-bold" : ""
}`}
>
{date.getDate()}
</button>
);
})}
</div>
{/* Compact phase legend */}
<div
data-testid="phase-legend"
className="mt-3 flex flex-wrap gap-2 justify-center"
>
{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>
</div>
))}
</div>
</div>
);
}