Files
family-planner/apps/client/src/features/calendar/CalendarGrid.tsx

110 lines
3.7 KiB
TypeScript
Raw Normal View History

2026-03-29 21:57:23 -05:00
import { useMemo } from 'react';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isToday, isSameDay, format,
} from 'date-fns';
import { motion } from 'framer-motion';
import { clsx } from 'clsx';
import type { CalendarEvent, Member } from '@/lib/api';
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface Props {
month: Date;
events: CalendarEvent[];
members: Member[];
onDayClick: (date: Date) => void;
onEventClick: (event: CalendarEvent) => void;
}
function eventColor(event: CalendarEvent, members: Member[]): string {
if (event.color) return event.color;
if (event.member_id) {
return members.find((m) => m.id === event.member_id)?.color ?? '#6366f1';
}
return '#6366f1';
}
export function CalendarGrid({ month, events, members, onDayClick, onEventClick }: Props) {
const days = useMemo(() => {
const start = startOfWeek(startOfMonth(month));
const end = endOfWeek(endOfMonth(month));
return eachDayOfInterval({ start, end });
}, [month]);
function dayEvents(day: Date) {
return events.filter((e) => {
const start = new Date(e.start_at);
const end = new Date(e.end_at);
// All-day or events that touch this day
return isSameDay(start, day) || (start <= day && end >= day);
});
}
return (
<div className="flex flex-col h-full">
{/* Weekday headers */}
<div className="grid grid-cols-7 border-b border-theme">
{WEEKDAYS.map((d) => (
<div key={d} className="py-2 text-center text-xs font-semibold text-muted uppercase tracking-wide">
{d}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7 flex-1" style={{ gridTemplateRows: `repeat(${days.length / 7}, 1fr)` }}>
{days.map((day, i) => {
const de = dayEvents(day);
const inMonth = isSameMonth(day, month);
const today = isToday(day);
return (
<motion.div
key={i}
whileHover={{ backgroundColor: 'var(--color-surface-raised)' }}
onClick={() => onDayClick(day)}
className={clsx(
'relative min-h-[80px] border-b border-r border-theme p-1.5 cursor-pointer',
'transition-colors duration-100',
!inMonth && 'opacity-40',
)}
>
{/* Date number */}
<div className="flex justify-end mb-1">
<span
className={clsx(
'h-6 w-6 flex items-center justify-center rounded-full text-xs font-semibold',
today
? 'bg-accent text-white'
: 'text-primary hover:bg-surface-raised'
)}
>
{format(day, 'd')}
</span>
</div>
{/* Event chips */}
<div className="flex flex-col gap-0.5">
{de.slice(0, 3).map((ev) => (
<button
key={ev.id}
onClick={(e) => { e.stopPropagation(); onEventClick(ev); }}
className="w-full text-left px-1.5 py-0.5 rounded text-xs font-medium text-white truncate leading-5"
style={{ backgroundColor: eventColor(ev, members) }}
>
{ev.all_day ? '' : `${format(new Date(ev.start_at), 'h:mm')} `}{ev.title}
</button>
))}
{de.length > 3 && (
<span className="text-xs text-muted px-1">+{de.length - 3} more</span>
)}
</div>
</motion.div>
);
})}
</div>
</div>
);
}