Replace Sheet overlays with inline detail panels across all pages

- Calendar: move view selector left, inline EventDetailPanel with view/edit/create
  modes, fix resize on panel close, remove all Sheet/Dialog usage
- Todos: add TodoDetailPanel with inline view/edit/create, replace CategoryFilterBar
  with shared component (drag-and-drop categories), 55/45 split layout
- Reminders: add ReminderDetailPanel with inline view/edit/create, 55/45 split layout
- Dashboard: all widget items now deep-link to destination page AND open the
  relevant item's detail panel (events, todos, reminders)
- Fix TS errors: unused imports, undefined→null coalescing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 22:43:06 +08:00
parent 898ecc407a
commit 8945295e2a
11 changed files with 2277 additions and 574 deletions

View File

@ -14,15 +14,9 @@ import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import CalendarSidebar from './CalendarSidebar';
import EventForm from './EventForm';
import EventDetailPanel from './EventDetailPanel';
import type { CreateDefaults } from './EventDetailPanel';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
@ -32,32 +26,20 @@ const viewLabels: Record<CalendarView, string> = {
timeGridDay: 'Day',
};
type ScopeAction = 'edit' | 'delete';
export default function CalendarPage() {
const queryClient = useQueryClient();
const location = useLocation();
const calendarRef = useRef<FullCalendar>(null);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
const [selectedStart, setSelectedStart] = useState<string | null>(null);
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
const [selectedAllDay, setSelectedAllDay] = useState(false);
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState('');
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
const [templateName, setTemplateName] = useState<string | null>(null);
const [eventSearch, setEventSearch] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
// Scope dialog state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
// Panel state
const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings();
const { data: calendars = [] } = useCalendars();
@ -81,13 +63,17 @@ export default function CalendarPage() {
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { date?: string; view?: string } | null;
if (!state?.date) return;
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
if (!state) return;
const calApi = calendarRef.current?.getApi();
if (!calApi) return;
if (state.date && calApi) {
calApi.gotoDate(state.date);
if (state.view) calApi.changeView(state.view);
// Clear state to prevent re-triggering
}
if (state.eventId) {
setSelectedEventId(state.eventId);
setPanelMode('view');
}
window.history.replaceState({}, '');
}, [location.state]);
@ -102,6 +88,16 @@ export default function CalendarPage() {
return () => observer.disconnect();
}, []);
const panelOpen = panelMode !== 'closed';
// Resize calendar when panel opens/closes (CSS transition won't trigger ResizeObserver on inner div)
useEffect(() => {
const timeout = setTimeout(() => {
calendarRef.current?.getApi().updateSize();
}, 320);
return () => clearTimeout(timeout);
}, [panelOpen]);
// Scroll wheel navigation in month view
useEffect(() => {
const el = calendarContainerRef.current;
@ -130,7 +126,6 @@ export default function CalendarPage() {
},
});
const panelOpen = selectedEventId !== null;
const selectedEvent = useMemo(
() => events.find((e) => e.id === selectedEventId) ?? null,
[selectedEventId, events],
@ -140,7 +135,7 @@ export default function CalendarPage() {
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSelectedEventId(null);
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
@ -204,24 +199,6 @@ export default function CalendarPage() {
},
});
const scopeDeleteMutation = useMutation({
mutationFn: async ({ id, scope }: { id: number; scope: string }) => {
await api.delete(`/events/${id}?scope=${scope}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event(s) deleted');
setScopeDialogOpen(false);
setScopeEvent(null);
setSelectedEventId(null);
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
@ -236,17 +213,22 @@ export default function CalendarPage() {
}, [filteredEvents, eventSearch]);
const handleSearchSelect = (event: CalendarEvent) => {
const api = calendarRef.current?.getApi();
if (!api) return;
const calApi = calendarRef.current?.getApi();
if (!calApi) return;
const startDate = new Date(event.start_datetime);
api.gotoDate(startDate);
calApi.gotoDate(startDate);
if (event.all_day) {
api.changeView('dayGridMonth');
calApi.changeView('dayGridMonth');
} else {
api.changeView('timeGridDay');
calApi.changeView('timeGridDay');
}
setEventSearch('');
setSearchFocused(false);
// Also open the event in the panel
if (!event.is_virtual) {
setSelectedEventId(event.id);
setPanelMode('view');
}
};
const calendarEvents = filteredEvents.map((event) => ({
@ -264,10 +246,6 @@ export default function CalendarPage() {
},
}));
const isRecurring = (event: CalendarEvent): boolean => {
return !!(event.is_recurring || event.parent_event_id);
};
const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id);
if (!event) return;
@ -276,18 +254,7 @@ export default function CalendarPage() {
return;
}
setSelectedEventId(event.id);
};
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
if (!scopeEvent) return;
if (scopeAction === 'edit') {
setEditingEvent(scopeEvent);
setActiveEditScope(scope);
setShowForm(true);
setScopeDialogOpen(false);
} else if (scopeAction === 'delete') {
scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope });
}
setPanelMode('view');
};
const handleEventDrop = (info: EventDropArg) => {
@ -295,7 +262,6 @@ export default function CalendarPage() {
info.revert();
return;
}
// Prevent drag-drop on recurring events — user must use scope dialog via click
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert();
toast.info('Click the event to edit recurring events');
@ -320,7 +286,6 @@ export default function CalendarPage() {
info.revert();
return;
}
// Prevent resize on recurring events — user must use scope dialog via click
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert();
toast.info('Click the event to edit recurring events');
@ -341,26 +306,33 @@ export default function CalendarPage() {
};
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedStart(selectInfo.startStr);
setSelectedEnd(selectInfo.endStr);
setSelectedAllDay(selectInfo.allDay);
setShowForm(true);
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults({
start: selectInfo.startStr,
end: selectInfo.endStr,
allDay: selectInfo.allDay,
});
};
const handleCloseForm = () => {
const handleCreateNew = () => {
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults(null);
};
const handlePanelClose = () => {
calendarRef.current?.getApi().unselect();
setShowForm(false);
setEditingEvent(null);
setTemplateEvent(null);
setTemplateName(null);
setActiveEditScope(null);
setSelectedStart(null);
setSelectedEnd(null);
setSelectedAllDay(false);
setPanelMode('closed');
setSelectedEventId(null);
setCreateDefaults(null);
};
const handleUseTemplate = (template: EventTemplate) => {
setTemplateEvent({
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults({
templateData: {
title: template.title,
description: template.description || '',
all_day: template.all_day,
@ -368,10 +340,9 @@ export default function CalendarPage() {
location_id: template.location_id || undefined,
is_starred: template.is_starred,
recurrence_rule: template.recurrence_rule || undefined,
} as Partial<CalendarEvent>);
setTemplateName(template.name);
setEditingEvent(null);
setShowForm(true);
} as Partial<CalendarEvent>,
templateName: template.name,
});
};
const handleDatesSet = (arg: DatesSetArg) => {
@ -379,46 +350,6 @@ export default function CalendarPage() {
setCurrentView(arg.view.type as CalendarView);
};
// Panel actions
const handlePanelEdit = () => {
if (!selectedEvent) return;
if (isRecurring(selectedEvent)) {
setScopeEvent(selectedEvent);
setScopeAction('edit');
setScopeDialogOpen(true);
} else {
setEditingEvent(selectedEvent);
setShowForm(true);
}
};
const handlePanelDelete = () => {
if (!selectedEvent) return;
if (isRecurring(selectedEvent)) {
setScopeEvent(selectedEvent);
setScopeAction('delete');
setScopeDialogOpen(true);
} else {
panelDeleteMutation.mutate(selectedEvent.id as number);
}
};
const panelDeleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/events/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event deleted');
setSelectedEventId(null);
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const navigatePrev = () => calendarRef.current?.getApi().prev();
const navigateNext = () => calendarRef.current?.getApi().next();
const navigateToday = () => calendarRef.current?.getApi().today();
@ -429,7 +360,7 @@ export default function CalendarPage() {
<CalendarSidebar onUseTemplate={handleUseTemplate} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar — h-16 matches sidebar header */}
{/* Custom toolbar */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
@ -442,6 +373,27 @@ export default function CalendarPage() {
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
Today
</Button>
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button
key={view}
onClick={() => changeView(view)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
currentView === view
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
}}
>
{label}
</button>
))}
</div>
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
<div className="flex-1" />
@ -483,30 +435,10 @@ export default function CalendarPage() {
)}
</div>
<Button size="sm" onClick={() => setShowForm(true)}>
<Button size="sm" onClick={handleCreateNew}>
<Plus className="mr-2 h-4 w-4" />
Create Event
</Button>
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button
key={view}
onClick={() => changeView(view)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
currentView === view
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: currentView === view ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: currentView === view ? 'hsl(var(--accent-color))' : undefined,
}}
>
{label}
</button>
))}
</div>
</div>
{/* Calendar grid + event detail panel */}
@ -549,11 +481,11 @@ export default function CalendarPage() {
}`}
>
<EventDetailPanel
event={selectedEvent}
onEdit={handlePanelEdit}
onDelete={handlePanelDelete}
deleteLoading={panelDeleteMutation.isPending}
onClose={() => setSelectedEventId(null)}
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/>
</div>
@ -561,79 +493,26 @@ export default function CalendarPage() {
</div>
{/* Mobile detail panel overlay */}
{panelOpen && selectedEvent && (
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedEventId(null)}
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<EventDetailPanel
event={selectedEvent}
onEdit={handlePanelEdit}
onDelete={handlePanelDelete}
deleteLoading={panelDeleteMutation.isPending}
onClose={() => setSelectedEventId(null)}
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/>
</div>
</div>
)}
{showForm && (
<EventForm
event={editingEvent}
templateData={templateEvent}
templateName={templateName}
initialStart={selectedStart}
initialEnd={selectedEnd}
initialAllDay={selectedAllDay}
editScope={activeEditScope}
onClose={handleCloseForm}
/>
)}
{/* Recurring event scope dialog */}
<Dialog open={scopeDialogOpen} onOpenChange={setScopeDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2 mt-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeChoice('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeChoice('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => {
setScopeDialogOpen(false);
setScopeEvent(null);
}}
>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,18 +1,69 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO } from 'date-fns';
import { X, Pencil, Trash2, Clock, MapPin, AlignLeft, Repeat } from 'lucide-react';
import type { CalendarEvent } from '@/types';
import { Button } from '@/components/ui/button';
import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import LocationPicker from '@/components/ui/location-picker';
interface EventDetailPanelProps {
event: CalendarEvent | null;
onEdit: () => void;
onDelete: () => void;
deleteLoading?: boolean;
onClose: () => void;
locationName?: string;
// --- Helpers ---
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16);
return `${dt}T${fallbackTime}`;
}
function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
}
function adjustAllDayEndForDisplay(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
d.setDate(d.getDate() - 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function adjustAllDayEndForSave(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function nowLocal(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function plusOneHour(dt: string): string {
const d = new Date(dt);
d.setHours(d.getHours() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatRecurrenceRule(rule: string): string {
@ -44,68 +95,727 @@ function ordinal(n: number): string {
return s[(v - 20) % 10] || s[v] || s[0];
}
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// Python weekday: 0=Monday, 6=Sunday
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// --- Types ---
export interface CreateDefaults {
start?: string;
end?: string;
allDay?: boolean;
templateData?: Partial<CalendarEvent>;
templateName?: string;
}
interface EventDetailPanelProps {
event: CalendarEvent | null;
isCreating?: boolean;
createDefaults?: CreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
locationName?: string;
}
interface EditState {
title: string;
description: string;
start_datetime: string;
end_datetime: string;
all_day: boolean;
location_id: string;
calendar_id: string;
is_starred: boolean;
recurrence_type: string;
recurrence_interval: number;
recurrence_weekday: number;
recurrence_week: number;
recurrence_day: number;
}
function buildEditStateFromEvent(event: CalendarEvent): EditState {
const rule = parseRecurrenceRule(event.recurrence_rule);
const isAllDay = event.all_day;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime;
return {
title: event.title,
description: event.description || '',
start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: event.location_id?.toString() || '',
calendar_id: event.calendar_id?.toString() || '',
is_starred: event.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState {
const source = defaults?.templateData;
const isAllDay = source?.all_day ?? defaults?.allDay ?? false;
const defaultStart = nowLocal();
const defaultEnd = plusOneHour(defaultStart);
const rawStart = defaults?.start || defaultStart;
const rawEnd = defaults?.end || defaultEnd;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
const rule = parseRecurrenceRule(source?.recurrence_rule);
return {
title: source?.title || '',
description: source?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: source?.location_id?.toString() || '',
calendar_id: source?.calendar_id?.toString() || defaultCalendarId,
is_starred: source?.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildRecurrencePayload(state: EditState): RecurrenceRule | null {
if (!state.recurrence_type) return null;
switch (state.recurrence_type) {
case 'every_n_days':
return { type: 'every_n_days', interval: state.recurrence_interval };
case 'weekly':
return { type: 'weekly' };
case 'monthly_nth_weekday':
return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday };
case 'monthly_date':
return { type: 'monthly_date', day: state.recurrence_day };
default:
return null;
}
}
// --- Component ---
export default function EventDetailPanel({
event,
onEdit,
onDelete,
deleteLoading = false,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
locationName,
}: EventDetailPanelProps) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const selectableCalendars = calendars.filter((c) => !c.is_system);
const defaultCalendar = calendars.find((c) => c.is_default);
if (!event) return null;
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const startDate = parseISO(event.start_datetime);
const endDate = event.end_datetime ? parseISO(event.end_datetime) : null;
const isRecurring = !!(event.is_recurring || event.parent_event_id);
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
: event
? buildEditStateFromEvent(event)
: buildCreateState(null, defaultCalendar?.id?.toString() || '')
);
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState('');
const startStr = event.all_day
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Reset state when event changes
useEffect(() => {
setIsEditing(false);
setScopeStep(null);
setEditScope(null);
setLocationSearch('');
if (event) setEditState(buildEditStateFromEvent(event));
}, [event?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || ''));
setLocationSearch('');
}
}, [isCreating, createDefaults]);
// Initialize location search text from existing location
useEffect(() => {
if (isEditing && !isCreating && event?.location_id) {
const loc = locations.find((l) => l.id === event.location_id);
if (loc) setLocationSearch(loc.name);
}
}, [isEditing, isCreating, event?.location_id, locations]);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const rule = buildRecurrencePayload(data);
let endDt = data.end_datetime;
if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt);
const payload: Record<string, unknown> = {
title: data.title,
description: data.description || null,
start_datetime: data.start_datetime,
end_datetime: endDt,
all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
};
if (event && !isCreating) {
if (editScope) payload.edit_scope = editScope;
return api.put(`/events/${event.id}`, payload);
} else {
return api.post('/events', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Event created' : 'Event updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
setEditScope(null);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
const scope = editScope ? `?scope=${editScope}` : '';
await api.delete(`/events/${event!.id}${scope}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Event deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (isRecurring) {
setScopeStep('edit');
} else {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
}
};
const handleScopeSelect = (scope: 'this' | 'this_and_future') => {
setEditScope(scope);
if (scopeStep === 'edit') {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
setScopeStep(null);
} else if (scopeStep === 'delete') {
// Delete with scope — execute immediately
setScopeStep(null);
// The deleteMutation will read editScope, but we need to set it first
// Since setState is async, use the mutation directly with the scope
const scopeParam = `?scope=${scope}`;
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
invalidateAll();
toast.success('Event(s) deleted');
onClose();
onDeleted?.();
}).catch((error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
});
}
};
const handleEditCancel = () => {
setIsEditing(false);
setEditScope(null);
setLocationSearch('');
if (isCreating) {
onClose();
} else if (event) {
setEditState(buildEditStateFromEvent(event));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const handleDeleteStart = () => {
if (isRecurring) {
setScopeStep('delete');
} else {
handleDeleteClick();
}
};
// --- Render helpers ---
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!event && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Calendar className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select an event to view details</p>
</div>
);
}
// View mode data
const startDate = event ? parseISO(event.start_datetime) : null;
const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null;
const startStr = startDate
? event!.all_day
? format(startDate, 'EEEE, MMMM d, yyyy')
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a');
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a')
: '';
const endStr = endDate
? event.all_day
? event!.all_day
? format(endDate, 'EEEE, MMMM d, yyyy')
: format(endDate, 'h:mm a')
: null;
const panelTitle = isCreating
? createDefaults?.templateName
? `New Event from ${createDefaults.templateName}`
: 'New Event'
: event?.title || '';
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border flex items-start justify-between">
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing ? (
<div className="flex-1 min-w-0">
{isCreating ? (
<h3 className="font-heading text-lg font-semibold">{panelTitle}</h3>
) : (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold"
placeholder="Event title"
autoFocus
/>
)}
</div>
) : scopeStep ? (
<h3 className="font-heading text-sm font-semibold">
{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{event.title}</h3>
<span className="text-xs text-muted-foreground">{event.calendar_name}</span>
<h3 className="font-heading text-lg font-semibold truncate">{event?.title}</h3>
<span className="text-xs text-muted-foreground">{event?.calendar_name}</span>
</div>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{scopeStep ? (
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 ml-2"
className="h-7 w-7"
onClick={() => setScopeStep(null)}
title="Cancel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
) : isEditing ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
{!event?.is_virtual && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit event"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
title="Delete event"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{scopeStep ? (
/* Scope selection step */
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => setScopeStep(null)}
>
Cancel
</Button>
</div>
</div>
) : isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{/* Title (only shown in body for create mode; edit mode has it in header) */}
{isCreating && (
<div className="space-y-1">
<Label htmlFor="panel-title" required>Title</Label>
<Input
id="panel-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Event title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="panel-desc">Description</Label>
<Textarea
id="panel-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="panel-allday"
checked={editState.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
updateField('all_day', checked);
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
}}
/>
<Label htmlFor="panel-allday">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label>
<Input
id="panel-start"
type={editState.all_day ? 'date' : 'datetime-local'}
value={editState.start_datetime}
onChange={(e) => updateField('start_datetime', e.target.value)}
className="text-xs"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="panel-end">End</Label>
<Input
id="panel-end"
type={editState.all_day ? 'date' : 'datetime-local'}
value={editState.end_datetime}
onChange={(e) => updateField('end_datetime', e.target.value)}
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
id="panel-calendar"
value={editState.calendar_id}
onChange={(e) => updateField('calendar_id', e.target.value)}
className="text-xs"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-location">Location</Label>
<LocationPicker
id="panel-location"
value={locationSearch}
onChange={(val) => {
setLocationSearch(val);
if (!val) updateField('location_id', '');
}}
onSelect={async (result) => {
if (result.source === 'local' && result.location_id) {
updateField('location_id', result.location_id.toString());
} else if (result.source === 'nominatim') {
try {
const { data: newLoc } = await api.post('/locations', {
name: result.name,
address: result.address,
category: 'other',
});
queryClient.invalidateQueries({ queryKey: ['locations'] });
updateField('location_id', newLoc.id.toString());
toast.success(`Location "${result.name}" created`);
} catch {
toast.error('Failed to create location');
}
}
}}
placeholder="Search location..."
/>
</div>
</div>
{/* Recurrence */}
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
id="panel-recurrence"
value={editState.recurrence_type}
onChange={(e) => updateField('recurrence_type', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
{editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1">
<Label htmlFor="panel-interval">Every how many days?</Label>
<Input
id="panel-interval"
type="number"
min={1}
max={365}
value={editState.recurrence_interval}
onChange={(e) => updateField('recurrence_interval', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
{editState.recurrence_type === 'weekly' && (
<p className="text-xs text-muted-foreground">
Repeats every week on the same day as the start date.
</p>
)}
{editState.recurrence_type === 'monthly_nth_weekday' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-week">Week of month</Label>
<Select
id="panel-week"
value={editState.recurrence_week.toString()}
onChange={(e) => updateField('recurrence_week', parseInt(e.target.value))}
className="text-xs"
>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-weekday">Day of week</Label>
<Select
id="panel-weekday"
value={editState.recurrence_weekday.toString()}
onChange={(e) => updateField('recurrence_weekday', parseInt(e.target.value))}
className="text-xs"
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
</div>
)}
{editState.recurrence_type === 'monthly_date' && (
<div className="space-y-1">
<Label htmlFor="panel-day">Day of month</Label>
<Input
id="panel-day"
type="number"
min={1}
max={31}
value={editState.recurrence_day}
onChange={(e) => updateField('recurrence_day', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="panel-starred"
checked={editState.is_starred}
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
/>
<Label htmlFor="panel-starred">Star this event</Label>
</div>
{/* Save / Cancel buttons at bottom of form */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* Calendar */}
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Calendar</p>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event.calendar_name}</span>
<span className="text-sm">{event?.calendar_name}</span>
</div>
</div>
@ -132,7 +842,7 @@ export default function EventDetailPanel({
)}
{/* Description */}
{event.description && (
{event?.description && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Description</p>
<div className="flex items-start gap-2">
@ -142,8 +852,18 @@ export default function EventDetailPanel({
</div>
)}
{/* Starred */}
{event?.is_starred && (
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90">Starred event</span>
</div>
</div>
)}
{/* Recurrence */}
{isRecurring && event.recurrence_rule && (
{isRecurring && event?.recurrence_rule && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
<div className="flex items-center gap-2">
@ -153,7 +873,7 @@ export default function EventDetailPanel({
</div>
)}
{isRecurring && !event.recurrence_rule && (
{isRecurring && !event?.recurrence_rule && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
<div className="flex items-center gap-2">
@ -162,44 +882,18 @@ export default function EventDetailPanel({
</div>
</div>
)}
</div>
{/* Footer */}
{!event.is_virtual && (
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
{/* Updated at */}
{event && !event.is_virtual && (
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(event.updated_at)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} aria-label="Edit">
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
{confirming ? (
<Button
variant="ghost"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Confirm delete"
className="h-8 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteLoading}
aria-label="Delete"
className="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -41,7 +41,7 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
{events.map((event) => (
<div
key={event.id}
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay' } })}
onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<div

View File

@ -24,7 +24,7 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
return (
<div
key={event.id}
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } })}
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
>
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />

View File

@ -236,7 +236,7 @@ export default function DashboardPage() {
{futureReminders.map((reminder) => (
<div
key={reminder.id}
onClick={() => navigate('/reminders')}
onClick={() => navigate('/reminders', { state: { reminderId: reminder.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />

View File

@ -54,7 +54,7 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
return (
<div
key={todo.id}
onClick={() => navigate('/todos')}
onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
>
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />

View File

@ -26,14 +26,14 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
const dateStr = item.datetime
? format(new Date(item.datetime), 'yyyy-MM-dd')
: item.date;
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay' } });
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: item.id } });
break;
}
case 'todo':
navigate('/todos');
navigate('/todos', { state: { todoId: item.id } });
break;
case 'reminder':
navigate('/reminders');
navigate('/reminders', { state: { reminderId: item.id } });
break;
}
};

View File

@ -0,0 +1,467 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isPast, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Bell, BellOff, Clock, Repeat, AlertCircle,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Reminder } from '@/types';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
// --- Types ---
interface ReminderDetailPanelProps {
reminder: Reminder | null;
isCreating?: boolean;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
remind_at: string;
recurrence_rule: string;
}
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
function buildEditState(reminder: Reminder): EditState {
return {
title: reminder.title,
description: reminder.description || '',
remind_at: reminder.remind_at ? reminder.remind_at.slice(0, 16) : '',
recurrence_rule: reminder.recurrence_rule || '',
};
}
function buildCreateState(): EditState {
return {
title: '',
description: '',
remind_at: '',
recurrence_rule: '',
};
}
// --- Component ---
export default function ReminderDetailPanel({
reminder,
isCreating = false,
onClose,
onSaved,
onDeleted,
}: ReminderDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
);
// Reset state when reminder changes
useEffect(() => {
setIsEditing(false);
if (reminder) setEditState(buildEditState(reminder));
}, [reminder?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState());
}
}, [isCreating]);
const invalidateAll = useCallback(() => {
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const payload = {
title: data.title,
description: data.description || null,
remind_at: data.remind_at || null,
recurrence_rule: data.recurrence_rule || null,
};
if (reminder && !isCreating) {
return api.put(`/reminders/${reminder.id}`, payload);
} else {
return api.post('/reminders', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Reminder created' : 'Reminder updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create reminder' : 'Failed to update reminder'));
},
});
const dismissMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/reminders/${reminder!.id}/dismiss`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success('Reminder dismissed');
},
onError: () => {
toast.error('Failed to dismiss reminder');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/reminders/${reminder!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Reminder deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete reminder'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (reminder) setEditState(buildEditState(reminder));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (reminder) {
setEditState(buildEditState(reminder));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!reminder && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Bell className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a reminder to view details</p>
</div>
);
}
// View data
const remindDate = reminder?.remind_at ? parseISO(reminder.remind_at) : null;
const isOverdue = !reminder?.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate);
const isDueToday = remindDate ? isToday(remindDate) : false;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing && !isCreating ? (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold flex-1"
placeholder="Reminder title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Reminder</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<Bell
className={`h-4 w-4 shrink-0 ${
isOverdue ? 'text-red-400' : reminder!.is_dismissed ? 'text-muted-foreground' : 'text-orange-400'
}`}
/>
<h3 className={`font-heading text-lg font-semibold truncate ${reminder!.is_dismissed ? 'line-through text-muted-foreground' : ''}`}>
{reminder!.title}
</h3>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
{!reminder!.is_dismissed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending}
title="Dismiss reminder"
>
<BellOff className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit reminder"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete reminder"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (
<div className="space-y-1">
<Label htmlFor="reminder-title" required>Title</Label>
<Input
id="reminder-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Reminder title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="reminder-desc">Description</Label>
<Textarea
id="reminder-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="reminder-at">Remind At</Label>
<Input
id="reminder-at"
type="datetime-local"
value={editState.remind_at}
onChange={(e) => updateField('remind_at', e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="reminder-recurrence">Recurrence</Label>
<Select
id="reminder-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* Status */}
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Status</p>
<div className="flex items-center gap-2">
{reminder!.is_dismissed ? (
<>
<BellOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground">Dismissed</span>
</>
) : isOverdue ? (
<>
<AlertCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />
<span className="text-sm text-red-400">Overdue</span>
</>
) : isDueToday ? (
<>
<Bell className="h-3.5 w-3.5 text-yellow-400 shrink-0" />
<span className="text-sm text-yellow-400">Due today</span>
</>
) : (
<>
<Bell className="h-3.5 w-3.5 text-orange-400 shrink-0" />
<span className="text-sm text-orange-400">Active</span>
</>
)}
</div>
</div>
{/* Remind At */}
{remindDate && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Remind At</p>
<CopyableField
value={format(remindDate, 'EEEE, MMMM d, yyyy · h:mm a')}
icon={Clock}
label="Remind at"
/>
</div>
)}
{/* Snoozed */}
{reminder!.snoozed_until && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Snoozed Until</p>
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm">
{format(parseISO(reminder!.snoozed_until), 'MMM d, h:mm a')}
</span>
</div>
</div>
)}
{/* Recurrence */}
{reminder!.recurrence_rule && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
<div className="flex items-center gap-2">
<Repeat className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm">
{recurrenceLabels[reminder!.recurrence_rule] || reminder!.recurrence_rule}
</span>
</div>
</div>
)}
{/* Description */}
{reminder!.description && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Description</p>
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
{reminder!.description}
</p>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(reminder!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { isPast, isToday, parseISO } from 'date-fns';
@ -9,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
import ReminderList from './ReminderList';
import ReminderForm from './ReminderForm';
import ReminderDetailPanel from './ReminderDetailPanel';
const statusFilters = [
{ value: 'active', label: 'Active' },
@ -20,11 +21,25 @@ const statusFilters = [
type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() {
const [showForm, setShowForm] = useState(false);
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const location = useLocation();
// Panel state
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [filter, setFilter] = useState<StatusFilter>('active');
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { reminderId?: number } | null;
if (state?.reminderId) {
setSelectedReminderId(state.reminderId);
setPanelMode('view');
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: reminders = [], isLoading } = useQuery({
queryKey: ['reminders'],
queryFn: async () => {
@ -50,16 +65,37 @@ export default function RemindersPage() {
).length;
const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
const handleEdit = (reminder: Reminder) => {
setEditingReminder(reminder);
setShowForm(true);
const panelOpen = panelMode !== 'closed';
const selectedReminder = useMemo(
() => reminders.find((r) => r.id === selectedReminderId) ?? null,
[selectedReminderId, reminders],
);
const handleSelect = (reminder: Reminder) => {
setSelectedReminderId(reminder.id);
setPanelMode('view');
};
const handleCloseForm = () => {
setShowForm(false);
setEditingReminder(null);
const handleCreateNew = () => {
setSelectedReminderId(null);
setPanelMode('create');
};
const handlePanelClose = () => {
setPanelMode('closed');
setSelectedReminderId(null);
};
// Escape key closes panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
@ -99,13 +135,20 @@ export default function RemindersPage() {
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm">
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Reminder
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Main content — list + detail panel */}
<div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 py-5">
{/* Summary stats */}
{!isLoading && reminders.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">
@ -156,13 +199,47 @@ export default function RemindersPage() {
) : (
<ReminderList
reminders={filteredReminders}
onEdit={handleEdit}
onAdd={() => setShowForm(true)}
onEdit={handleSelect}
onAdd={handleCreateNew}
/>
)}
</div>
</div>
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />}
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,539 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, Calendar, Flag, Tag, Repeat, CheckSquare, AlertCircle,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
// --- Types ---
export interface TodoCreateDefaults {
category?: string;
}
interface TodoDetailPanelProps {
todo: Todo | null;
isCreating?: boolean;
createDefaults?: TodoCreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
priority: string;
due_date: string;
due_time: string;
category: string;
recurrence_rule: string;
}
const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
function buildEditState(todo: Todo): EditState {
return {
title: todo.title,
description: todo.description || '',
priority: todo.priority,
due_date: todo.due_date ? todo.due_date.slice(0, 10) : '',
due_time: todo.due_time ? todo.due_time.slice(0, 5) : '',
category: todo.category || '',
recurrence_rule: todo.recurrence_rule || '',
};
}
function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
return {
title: '',
description: '',
priority: 'medium',
due_date: '',
due_time: '',
category: defaults?.category || '',
recurrence_rule: '',
};
}
// --- Component ---
export default function TodoDetailPanel({
todo,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
}: TodoDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
);
// Reset state when todo changes
useEffect(() => {
setIsEditing(false);
if (todo) setEditState(buildEditState(todo));
}, [todo?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults));
}
}, [isCreating, createDefaults]);
const invalidateAll = useCallback(() => {
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const payload = {
title: data.title,
description: data.description || null,
priority: data.priority,
due_date: data.due_date || null,
due_time: data.due_time || null,
category: data.category || null,
recurrence_rule: data.recurrence_rule || null,
};
if (todo && !isCreating) {
return api.put(`/todos/${todo.id}`, payload);
} else {
return api.post('/todos', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Todo created' : 'Todo updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create todo' : 'Failed to update todo'));
},
});
const toggleMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/todos/${todo!.id}/toggle`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success(todo!.completed ? 'Todo marked incomplete' : 'Todo completed!');
},
onError: () => {
toast.error('Failed to update todo');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/todos/${todo!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Todo deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete todo'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (todo) setEditState(buildEditState(todo));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (todo) {
setEditState(buildEditState(todo));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!todo && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<CheckSquare className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a todo to view details</p>
</div>
);
}
// View data
const dueDate = todo?.due_date ? parseISO(todo.due_date) : null;
const isDueToday = dueDate ? isToday(dueDate) : false;
const isOverdue = todo ? isTodoOverdue(todo.due_date, todo.completed) : false;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing && !isCreating ? (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold flex-1"
placeholder="Todo title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Todo</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={todo!.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
</span>
<h3 className={`font-heading text-lg font-semibold truncate ${todo!.completed ? 'line-through text-muted-foreground' : ''}`}>
{todo!.title}
</h3>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit todo"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete todo"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (
<div className="space-y-1">
<Label htmlFor="todo-title" required>Title</Label>
<Input
id="todo-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Todo title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="todo-desc">Description</Label>
<Textarea
id="todo-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-priority">Priority</Label>
<Select
id="todo-priority"
value={editState.priority}
onChange={(e) => updateField('priority', e.target.value)}
className="text-xs"
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="todo-category">Category</Label>
<Input
id="todo-category"
value={editState.category}
onChange={(e) => updateField('category', e.target.value)}
placeholder="e.g., Work"
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label>
<Input
id="todo-due-date"
type="date"
value={editState.due_date}
onChange={(e) => updateField('due_date', e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="todo-due-time">Due Time</Label>
<Input
id="todo-due-time"
type="time"
value={editState.due_time}
onChange={(e) => updateField('due_time', e.target.value)}
className="text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
id="todo-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* Priority */}
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Priority</p>
<div className="flex items-center gap-2">
<Flag className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
{todo!.priority}
</Badge>
</div>
</div>
{/* Category */}
{todo!.category && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Category</p>
<div className="flex items-center gap-2">
<Tag className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
{todo!.category}
</Badge>
</div>
</div>
)}
{/* Due Date */}
{dueDate && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Due Date</p>
<CopyableField
value={`${isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}${format(dueDate, 'EEEE, MMMM d, yyyy')}${todo!.due_time ? ` at ${todo!.due_time.slice(0, 5)}` : ''}`}
icon={isOverdue ? AlertCircle : Calendar}
label="Due date"
/>
</div>
)}
{/* Due Time (if no date but has time) */}
{!dueDate && todo!.due_time && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Due Time</p>
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
</div>
)}
{/* Recurrence */}
{todo!.recurrence_rule && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Recurrence</p>
<div className="flex items-center gap-2">
<Repeat className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</span>
</div>
</div>
)}
{/* Description */}
{todo!.description && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Description</p>
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
{todo!.description}
</p>
</div>
)}
{/* Completion status */}
{todo!.completed && todo!.completed_at && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Completed</p>
<div className="flex items-center gap-2">
<CheckSquare className="h-3.5 w-3.5 text-green-400 shrink-0" />
<span className="text-sm text-green-400">
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
</span>
</div>
</div>
)}
{/* Reset info for recurring */}
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
<div className="space-y-0.5">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Resets</p>
<div className="flex items-center gap-2">
<Repeat className="h-3.5 w-3.5 text-purple-400 shrink-0" />
<span className="text-sm text-purple-400">
{format(parseISO(todo!.reset_at), 'EEE, MMM d')}
{todo!.next_due_date && ` · Next due ${format(parseISO(todo!.next_due_date), 'MMM d')}`}
</span>
</div>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(todo!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,15 +1,17 @@
import { useState, useMemo } from 'react';
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search } from 'lucide-react';
import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import TodoList from './TodoList';
import TodoForm from './TodoForm';
import TodoDetailPanel from './TodoDetailPanel';
const priorityFilters = [
{ value: '', label: 'All' },
@ -20,14 +22,27 @@ const priorityFilters = [
] as const;
export default function TodosPage() {
const [showForm, setShowForm] = useState(false);
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [filters, setFilters] = useState({
priority: '',
category: '',
showCompleted: true,
search: '',
});
const location = useLocation();
// Panel state
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
// Filters
const [priorityFilter, setPriorityFilter] = useState('');
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showCompleted, setShowCompleted] = useState(true);
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { todoId?: number } | null;
if (state?.todoId) {
setSelectedTodoId(state.todoId);
setPanelMode('view');
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: todos = [], isLoading } = useQuery({
queryKey: ['todos'],
@ -37,7 +52,7 @@ export default function TodosPage() {
},
});
const categories = useMemo(() => {
const allCategories = useMemo(() => {
const cats = new Set<string>();
todos.forEach((t) => {
if (t.category) cats.add(t.category);
@ -45,54 +60,92 @@ export default function TodosPage() {
return Array.from(cats).sort();
}, [todos]);
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('todos', allCategories);
const filteredTodos = useMemo(
() =>
todos.filter((todo) => {
if (filters.priority && todo.priority !== filters.priority) return false;
if (filters.category && todo.category?.toLowerCase() !== filters.category.toLowerCase())
return false;
if (!filters.showCompleted && todo.completed) return false;
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase()))
if (priorityFilter && todo.priority !== priorityFilter) return false;
if (activeFilters.length > 0) {
if (!todo.category || !activeFilters.includes(todo.category)) return false;
}
if (!showCompleted && todo.completed) return false;
if (search && !todo.title.toLowerCase().includes(search.toLowerCase()))
return false;
return true;
}),
[todos, filters]
[todos, priorityFilter, activeFilters, showCompleted, search]
);
const totalCount = filteredTodos.filter((t) => !t.completed).length;
const completedCount = filteredTodos.filter((t) => t.completed).length;
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
const handleEdit = (todo: Todo) => {
setEditingTodo(todo);
setShowForm(true);
const panelOpen = panelMode !== 'closed';
const selectedTodo = useMemo(
() => todos.find((t) => t.id === selectedTodoId) ?? null,
[selectedTodoId, todos],
);
const handleSelect = (todo: Todo) => {
setSelectedTodoId(todo.id);
setPanelMode('view');
};
const handleCloseForm = () => {
setShowForm(false);
setEditingTodo(null);
const handleCreateNew = () => {
setSelectedTodoId(null);
setPanelMode('create');
};
const handlePanelClose = () => {
setPanelMode('closed');
setSelectedTodoId(null);
};
// CategoryFilterBar handlers
const toggleAll = () => setActiveFilters([]);
const toggleCompleted = () => setShowCompleted((p) => !p);
const toggleCategory = (cat: string) => {
setActiveFilters((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
const selectAllCategories = () => {
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
setActiveFilters(allSelected ? [] : [...orderedCategories]);
};
// Escape key closes panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0 flex-wrap">
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
{/* Priority filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
{priorityFilters.map((pf) => (
<button
key={pf.value}
onClick={() => setFilters({ ...filters, priority: pf.value })}
onClick={() => setPriorityFilter(pf.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filters.priority === pf.value
priorityFilter === pf.value
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor:
filters.priority === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.priority === pf.value ? 'hsl(var(--accent-color))' : undefined,
priorityFilter === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: priorityFilter === pf.value ? 'hsl(var(--accent-color))' : undefined,
}}
>
{pf.label}
@ -100,77 +153,37 @@ export default function TodosPage() {
))}
</div>
{/* Category pills */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button
onClick={() => setFilters({ ...filters, category: '' })}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
!filters.category
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: !filters.category ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: !filters.category ? 'hsl(var(--accent-color))' : undefined,
}}
>
All
</button>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setFilters({ ...filters, category: cat })}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filters.category === cat
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: filters.category === cat ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.category === cat ? 'hsl(var(--accent-color))' : undefined,
}}
>
{cat}
</button>
))}
</div>
{/* Completed toggle */}
<button
onClick={() => setFilters({ ...filters, showCompleted: !filters.showCompleted })}
className={`px-3 py-1.5 text-sm font-medium rounded-md border transition-colors duration-150 ${
filters.showCompleted
? 'border-accent/30 bg-accent/15 text-accent'
: 'border-border text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor: filters.showCompleted ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.showCompleted ? 'hsl(var(--accent-color))' : undefined,
borderColor: filters.showCompleted ? 'hsl(var(--accent-color) / 0.3)' : undefined,
}}
>
Completed
</button>
{/* Category filter bar (All + Completed + Categories with drag) */}
<CategoryFilterBar
activeFilters={activeFilters}
pinnedLabel="Completed"
showPinned={showCompleted}
categories={orderedCategories}
onToggleAll={toggleAll}
onTogglePinned={toggleCompleted}
onToggleCategory={toggleCategory}
onSelectAllCategories={selectAllCategories}
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
/>
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm">
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Todo
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Main content — list + detail panel */}
<div className="flex-1 overflow-hidden flex">
<div
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-6 py-5">
{/* Summary stats */}
{!isLoading && todos.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">
@ -221,13 +234,47 @@ export default function TodosPage() {
) : (
<TodoList
todos={filteredTodos}
onEdit={handleEdit}
onAdd={() => setShowForm(true)}
onEdit={handleSelect}
onAdd={handleCreateNew}
/>
)}
</div>
</div>
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />}
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
)}
</div>
);
}