Implement MiniCalendar dashboard widget (P2.14)

Complete the MiniCalendar component with:
- Full calendar grid showing all days of the month
- Phase colors applied to each day
- Today highlighting with ring indicator
- Navigation buttons (prev/next month, Today)
- Compact phase legend
- Integration into dashboard page (shows when lastPeriodDate exists)

Adds 23 new tests for the MiniCalendar component covering:
- Calendar grid rendering
- Phase color application
- Navigation functionality
- Cycle rollover handling
- Custom year/month props

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 08:47:28 +00:00
parent 5a0cdf7450
commit b2915bca9c
5 changed files with 513 additions and 34 deletions

View File

@@ -1,29 +1,191 @@
// ABOUTME: Compact calendar widget for the dashboard.
// ABOUTME: Shows current month with color-coded cycle phases.
// 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 {
currentDate: Date;
cycleDay: number;
phase: string;
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({
currentDate,
cycleDay,
phase,
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);
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">
<h3 className="font-semibold mb-4">
Day {cycleDay} {phase}
{/* Cycle info header */}
<h3 className="font-semibold mb-2">
Day {currentCycleDay} · {currentPhase}
</h3>
<p className="text-sm text-gray-500">
{currentDate.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
{/* 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);
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>
);
})}
</p>
{/* Full calendar grid will be implemented here */}
<p className="text-gray-400 text-xs mt-4">Calendar grid placeholder</p>
</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>
);
}