From a5ac047b0b0392343e4ac035227793d813baa514 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 19:42:00 +0800 Subject: [PATCH 1/6] Add mini monthly calendar to sidebar for quick date navigation New MiniCalendar component with independent month browsing, today/selected highlights, firstDayOfWeek support, and month sync with main calendar. Replaces old "Calendars" header with the mini-cal + "MY CALENDARS" heading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/calendar/CalendarPage.tsx | 16 +- .../components/calendar/CalendarSidebar.tsx | 43 +++-- .../src/components/calendar/MiniCalendar.tsx | 153 ++++++++++++++++++ 3 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/calendar/MiniCalendar.tsx diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index dff063a..00d0ade 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -51,7 +51,9 @@ export default function CalendarPage() { const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); + const firstDayOfWeek = settings?.first_day_of_week ?? 0; const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true }); + const [currentDate, setCurrentDate] = useState(() => format(new Date(), 'yyyy-MM-dd')); const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); @@ -107,6 +109,10 @@ export default function CalendarPage() { localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth)); }, [sidebarWidth]); + const handleMiniCalClick = useCallback((dateStr: string) => { + calendarRef.current?.getApi().gotoDate(dateStr); + }, []); + // Location data for event panel const { data: locations = [] } = useQuery({ queryKey: ['locations'], @@ -513,6 +519,8 @@ export default function CalendarPage() { setVisibleRange((prev) => prev.start === start && prev.end === end ? prev : { start, end } ); + // Track current date anchor for mini calendar sync + setCurrentDate(format(arg.view.currentStart, 'yyyy-MM-dd')); }; const navigatePrev = () => calendarRef.current?.getApi().prev(); @@ -591,7 +599,7 @@ export default function CalendarPage() { return (
- +
setMobileSidebarOpen(false)} /> - { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} /> + { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} onDateClick={handleMiniCalClick} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} /> )} @@ -712,12 +720,12 @@ export default function CalendarPage() { >
void; onSharedVisibilityChange?: (visibleIds: Set) => void; width: number; + onDateClick?: (dateStr: string) => void; + currentDate?: string; + firstDayOfWeek?: number; } -const CalendarSidebar = forwardRef(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) { +const CalendarSidebar = forwardRef(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width, onDateClick, currentDate, firstDayOfWeek }, ref) { const queryClient = useQueryClient(); const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars(); const [showForm, setShowForm] = useState(false); @@ -95,20 +99,32 @@ const CalendarSidebar = forwardRef(functio return (
-
- Calendars - -
+ {onDateClick && ( +
+ +
+ )}
{/* Owned calendars list (non-shared only) */} -
+
+
+ + My Calendars + + +
+
{calendars.filter((c) => !c.is_shared).map((cal) => (
(functio
))}
+
{/* Shared calendars section -- owned + member */} {(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && ( diff --git a/frontend/src/components/calendar/MiniCalendar.tsx b/frontend/src/components/calendar/MiniCalendar.tsx new file mode 100644 index 0000000..123e121 --- /dev/null +++ b/frontend/src/components/calendar/MiniCalendar.tsx @@ -0,0 +1,153 @@ +import { useState, useEffect, useMemo, memo } from 'react'; +import { + startOfMonth, endOfMonth, startOfWeek, endOfWeek, + eachDayOfInterval, format, isSameDay, isSameMonth, isToday, + addMonths, subMonths, +} from 'date-fns'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface MiniCalendarProps { + onDateClick: (dateStr: string) => void; + currentDate?: string; + firstDayOfWeek?: number; +} + +const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +function buildGrid(displayed: Date, firstDay: number) { + const monthStart = startOfMonth(displayed); + const monthEnd = endOfMonth(displayed); + const gridStart = startOfWeek(monthStart, { weekStartsOn: firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 }); + const gridEnd = endOfWeek(monthEnd, { weekStartsOn: firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 }); + return eachDayOfInterval({ start: gridStart, end: gridEnd }); +} + +function getOrderedLabels(firstDay: number) { + return [...DAY_LABELS.slice(firstDay), ...DAY_LABELS.slice(0, firstDay)]; +} + +const MiniCalendar = memo(function MiniCalendar({ + onDateClick, + currentDate, + firstDayOfWeek = 1, +}: MiniCalendarProps) { + const [displayedMonth, setDisplayedMonth] = useState(() => + currentDate ? startOfMonth(new Date(currentDate)) : startOfMonth(new Date()) + ); + const [selectedDate, setSelectedDate] = useState( + currentDate ?? null + ); + + // Sync displayed month when main calendar navigates across months + useEffect(() => { + if (!currentDate) return; + const incoming = startOfMonth(new Date(currentDate)); + setDisplayedMonth((prev) => + prev.getTime() === incoming.getTime() ? prev : incoming + ); + setSelectedDate(currentDate); + }, [currentDate]); + + const days = useMemo( + () => buildGrid(displayedMonth, firstDayOfWeek), + [displayedMonth, firstDayOfWeek] + ); + + const orderedLabels = useMemo( + () => getOrderedLabels(firstDayOfWeek), + [firstDayOfWeek] + ); + + const handlePrev = () => setDisplayedMonth((m) => subMonths(m, 1)); + const handleNext = () => setDisplayedMonth((m) => addMonths(m, 1)); + + const handleDayClick = (day: Date) => { + const dateStr = format(day, 'yyyy-MM-dd'); + setSelectedDate(dateStr); + // If clicking a day in another month, also shift the displayed month + if (!isSameMonth(day, displayedMonth)) { + setDisplayedMonth(startOfMonth(day)); + } + onDateClick(dateStr); + }; + + const selectedDateObj = selectedDate ? new Date(selectedDate) : null; + + return ( +
+ {/* Month header with navigation */} +
+ + + {format(displayedMonth, 'MMMM yyyy')} + + +
+ + {/* Day-of-week headers */} +
+ {orderedLabels.map((label) => ( + + {label} + + ))} +
+ + {/* Day grid */} +
+ {days.map((day) => { + const isCurrentMonth = isSameMonth(day, displayedMonth); + const today = isToday(day); + const isSelected = selectedDateObj ? isSameDay(day, selectedDateObj) : false; + + return ( + + ); + })} +
+
+ ); +}); + +export default MiniCalendar; From b939843249cdfe450e66157f837a4cd350206d0b Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 19:48:32 +0800 Subject: [PATCH 2/6] Fix review findings: safe date parsing, useCallback discipline, dead class cleanup W-01: Wrap handlePrev/handleNext/handleDayClick in useCallback W-02: Use date-fns parse() instead of new Date() for timezone-safe parsing W-03: Change default firstDayOfWeek from 1 to 0 to match CalendarPage S-01: Use format(day, 'yyyy-MM-dd') as React key instead of toISOString() S-02: Remove dead Tailwind color classes overridden by inline styles Perf: Guard setSelectedDate with comparison to skip no-op re-renders Perf: Memoize selectedDateObj via useMemo to avoid re-parsing each render Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/calendar/MiniCalendar.tsx | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/calendar/MiniCalendar.tsx b/frontend/src/components/calendar/MiniCalendar.tsx index 123e121..87871d1 100644 --- a/frontend/src/components/calendar/MiniCalendar.tsx +++ b/frontend/src/components/calendar/MiniCalendar.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useMemo, memo } from 'react'; +import { useState, useEffect, useMemo, useCallback, memo } from 'react'; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, format, isSameDay, isSameMonth, isToday, - addMonths, subMonths, + addMonths, subMonths, parse, } from 'date-fns'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -30,10 +30,17 @@ function getOrderedLabels(firstDay: number) { const MiniCalendar = memo(function MiniCalendar({ onDateClick, currentDate, - firstDayOfWeek = 1, + firstDayOfWeek = 0, }: MiniCalendarProps) { + const REF_DATE = useMemo(() => new Date(), []); + + const parseDate = useCallback( + (dateStr: string) => parse(dateStr, 'yyyy-MM-dd', REF_DATE), + [REF_DATE] + ); + const [displayedMonth, setDisplayedMonth] = useState(() => - currentDate ? startOfMonth(new Date(currentDate)) : startOfMonth(new Date()) + currentDate ? startOfMonth(parseDate(currentDate)) : startOfMonth(new Date()) ); const [selectedDate, setSelectedDate] = useState( currentDate ?? null @@ -42,12 +49,12 @@ const MiniCalendar = memo(function MiniCalendar({ // Sync displayed month when main calendar navigates across months useEffect(() => { if (!currentDate) return; - const incoming = startOfMonth(new Date(currentDate)); + const incoming = startOfMonth(parseDate(currentDate)); setDisplayedMonth((prev) => prev.getTime() === incoming.getTime() ? prev : incoming ); - setSelectedDate(currentDate); - }, [currentDate]); + setSelectedDate((prev) => prev === currentDate ? prev : currentDate); + }, [currentDate, parseDate]); const days = useMemo( () => buildGrid(displayedMonth, firstDayOfWeek), @@ -59,10 +66,10 @@ const MiniCalendar = memo(function MiniCalendar({ [firstDayOfWeek] ); - const handlePrev = () => setDisplayedMonth((m) => subMonths(m, 1)); - const handleNext = () => setDisplayedMonth((m) => addMonths(m, 1)); + const handlePrev = useCallback(() => setDisplayedMonth((m) => subMonths(m, 1)), []); + const handleNext = useCallback(() => setDisplayedMonth((m) => addMonths(m, 1)), []); - const handleDayClick = (day: Date) => { + const handleDayClick = useCallback((day: Date) => { const dateStr = format(day, 'yyyy-MM-dd'); setSelectedDate(dateStr); // If clicking a day in another month, also shift the displayed month @@ -70,9 +77,12 @@ const MiniCalendar = memo(function MiniCalendar({ setDisplayedMonth(startOfMonth(day)); } onDateClick(dateStr); - }; + }, [displayedMonth, onDateClick]); - const selectedDateObj = selectedDate ? new Date(selectedDate) : null; + const selectedDateObj = useMemo( + () => selectedDate ? parseDate(selectedDate) : null, + [selectedDate, parseDate] + ); return (
@@ -120,15 +130,15 @@ const MiniCalendar = memo(function MiniCalendar({ return (