Compare commits

...

7 Commits

Author SHA1 Message Date
c5a309f4a1 Merge feature/mini-calendar: compact date navigator in sidebar
Adds MiniCalendar component with independent month browsing, click-to-navigate,
today/selected highlights, firstDayOfWeek support, navKey selection clearing,
aria-labels, and mobile sheet auto-close. QA reviewed — 0 critical findings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:12:50 +08:00
0ba920f8e1 Fix issues from QA review: stale closure, aria-labels, mobile sheet close
W-01: Use functional updater in handleDayClick to remove displayedMonth
      from dependency array, eliminating stale closure risk
S-02: Add aria-label with full date string to day buttons for screen readers
S-04: Close mobile sidebar sheet when clicking a date in mini calendar,
      matching existing onUseTemplate behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:12:16 +08:00
68337b12a0 Fix: clear mini-cal selection on Today click even when month unchanged
datesSet fires but currentDate stays the same value when already on
the current month, so the useEffect didn't re-run. Added navKey counter
that increments on every datesSet call — MiniCalendar watches it in a
separate useEffect to reliably clear selectedDate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:14:00 +08:00
bda02039a6 Clear mini calendar selection on main calendar navigation
Clicking Today/prev/next on the toolbar now clears the selected day
in the mini calendar, so only the today highlight remains visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:08:56 +08:00
2d76ecf869 Fix 1st-of-month highlight bug and restore Calendars header
selectedDate now only set by user clicks in mini calendar, not by
external currentDate prop (which is always 1st of displayed month
from FullCalendar's view.currentStart). Restore h-16 "Calendars"
header above mini calendar for consistent top-of-page alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:03:22 +08:00
b939843249 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) <noreply@anthropic.com>
2026-03-17 19:48:32 +08:00
a5ac047b0b 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) <noreply@anthropic.com>
2026-03-17 19:42:00 +08:00
3 changed files with 214 additions and 15 deletions

View File

@ -51,7 +51,10 @@ export default function CalendarPage() {
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings();
const firstDayOfWeek = settings?.first_day_of_week ?? 0;
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
const [currentDate, setCurrentDate] = useState<string>(() => format(new Date(), 'yyyy-MM-dd'));
const [navKey, setNavKey] = useState(0);
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(null);
@ -107,6 +110,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 +520,10 @@ 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'));
// Increment nav key so mini calendar clears selection even when month doesn't change
setNavKey((k) => k + 1);
};
const navigatePrev = () => calendarRef.current?.getApi().prev();
@ -591,7 +602,7 @@ export default function CalendarPage() {
return (
<div className="flex h-full overflow-hidden animate-fade-in">
<div className="hidden lg:flex lg:flex-row shrink-0">
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} onDateClick={handleMiniCalClick} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} navKey={navKey} />
<div
onMouseDown={handleSidebarMouseDown}
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
@ -602,7 +613,7 @@ export default function CalendarPage() {
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent className="w-72 p-0">
<SheetClose onClick={() => setMobileSidebarOpen(false)} />
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} onDateClick={(dateStr) => { setMobileSidebarOpen(false); handleMiniCalClick(dateStr); }} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} navKey={navKey} />
</SheetContent>
</Sheet>
)}
@ -712,12 +723,12 @@ export default function CalendarPage() {
>
<div className="h-full">
<FullCalendar
key={`fc-${settings?.first_day_of_week ?? 0}`}
key={`fc-${firstDayOfWeek}`}
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={false}
firstDay={settings?.first_day_of_week ?? 0}
firstDay={firstDayOfWeek}
events={calendarEvents}
editable={true}
selectable={true}

View File

@ -10,14 +10,19 @@ import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm';
import TemplateForm from './TemplateForm';
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
import MiniCalendar from './MiniCalendar';
interface CalendarSidebarProps {
onUseTemplate?: (template: EventTemplate) => void;
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
width: number;
onDateClick?: (dateStr: string) => void;
currentDate?: string;
firstDayOfWeek?: number;
navKey?: number;
}
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) {
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width, onDateClick, currentDate, firstDayOfWeek, navKey }, ref) {
const queryClient = useQueryClient();
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false);
@ -95,20 +100,36 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
return (
<div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}>
<div className="h-16 px-4 border-b flex items-center justify-between shrink-0">
<div className="h-16 px-4 border-b flex items-center shrink-0">
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{onDateClick && (
<div className="border-b shrink-0">
<MiniCalendar
onDateClick={onDateClick}
currentDate={currentDate}
firstDayOfWeek={firstDayOfWeek}
navKey={navKey}
/>
</div>
)}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Owned calendars list (non-shared only) */}
<div className="space-y-0.5">
<div className="space-y-1.5">
<div className="flex items-center justify-between px-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
My Calendars
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-0.5">
{calendars.filter((c) => !c.is_shared).map((cal) => (
<div
key={cal.id}
@ -138,6 +159,7 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
</div>
))}
</div>
</div>
{/* Shared calendars section -- owned + member */}
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (

View File

@ -0,0 +1,166 @@
import { useState, useEffect, useMemo, useCallback, memo } from 'react';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, format, isSameDay, isSameMonth, isToday,
addMonths, subMonths, parse,
} 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;
navKey?: 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 = 0,
navKey,
}: 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(parseDate(currentDate)) : startOfMonth(new Date())
);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// Sync displayed month when main calendar navigates across months
useEffect(() => {
if (!currentDate) return;
const incoming = startOfMonth(parseDate(currentDate));
setDisplayedMonth((prev) =>
prev.getTime() === incoming.getTime() ? prev : incoming
);
}, [currentDate, parseDate]);
// Clear selection on any toolbar navigation (today/prev/next)
// navKey increments on every datesSet, even when the month doesn't change
useEffect(() => {
setSelectedDate(null);
}, [navKey]);
const days = useMemo(
() => buildGrid(displayedMonth, firstDayOfWeek),
[displayedMonth, firstDayOfWeek]
);
const orderedLabels = useMemo(
() => getOrderedLabels(firstDayOfWeek),
[firstDayOfWeek]
);
const handlePrev = useCallback(() => setDisplayedMonth((m) => subMonths(m, 1)), []);
const handleNext = useCallback(() => setDisplayedMonth((m) => addMonths(m, 1)), []);
const handleDayClick = useCallback((day: Date) => {
const dateStr = format(day, 'yyyy-MM-dd');
setSelectedDate(dateStr);
setDisplayedMonth((prev) => isSameMonth(day, prev) ? prev : startOfMonth(day));
onDateClick(dateStr);
}, [onDateClick]);
const selectedDateObj = useMemo(
() => selectedDate ? parseDate(selectedDate) : null,
[selectedDate, parseDate]
);
return (
<div className="px-3 pt-3 pb-2 max-w-[280px] mx-auto">
{/* Month header with navigation */}
<div className="flex items-center justify-between mb-1.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handlePrev}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-sm font-medium font-heading select-none">
{format(displayedMonth, 'MMMM yyyy')}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNext}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
{/* Day-of-week headers */}
<div className="grid grid-cols-7 mb-0.5">
{orderedLabels.map((label) => (
<span
key={label}
className="text-[10px] text-muted-foreground text-center select-none"
>
{label}
</span>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{days.map((day) => {
const isCurrentMonth = isSameMonth(day, displayedMonth);
const today = isToday(day);
const isSelected = selectedDateObj ? isSameDay(day, selectedDateObj) : false;
return (
<button
key={format(day, 'yyyy-MM-dd')}
type="button"
aria-label={format(day, 'EEEE, MMMM d, yyyy')}
onClick={() => handleDayClick(day)}
className={[
'h-7 text-xs flex items-center justify-center rounded-md transition-colors duration-100',
isSelected
? 'font-medium'
: today
? 'font-semibold'
: isCurrentMonth
? 'text-foreground hover:bg-accent/10'
: 'text-muted-foreground/40 hover:bg-accent/10',
].join(' ')}
style={
isSelected
? { backgroundColor: 'hsl(var(--accent-color))', color: 'hsl(var(--accent-foreground))' }
: today && !isSelected
? { backgroundColor: 'hsl(var(--accent-color) / 0.2)', color: 'hsl(var(--accent-color))' }
: undefined
}
>
{format(day, 'd')}
</button>
);
})}
</div>
</div>
);
});
export default MiniCalendar;