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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user