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>
192 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|