From d8118905094fd70f3f807cdffa0f4d1ac4e3c8c1 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Sun, 22 Feb 2026 00:42:12 +0800 Subject: [PATCH] Add Sheet forms, recurrence UI, all-day fix, LocationPicker - Sheet component: slide-in panel replacing Dialog for all forms - EventForm: structured recurrence picker, all-day end-date offset fix, LocationPicker with OSM search integration - CalendarPage: scope dialog for editing/deleting recurring events - TodoForm/ReminderForm/LocationForm: migrated to Sheet with 2-col layouts - LocationPicker: debounced search combining local DB + Nominatim results - Backend: /locations/search endpoint with OSM proxy - CSS: slimmer all-day event bars in calendar grid - Types: RecurrenceRule interface, extended CalendarEvent fields Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/locations.py | 71 ++- backend/app/schemas/location.py | 9 +- .../src/components/calendar/CalendarPage.tsx | 100 ++++- .../src/components/calendar/EventForm.tsx | 420 ++++++++++++------ .../src/components/locations/LocationForm.tsx | 132 +++--- .../src/components/reminders/ReminderForm.tsx | 126 +++--- frontend/src/components/todos/TodoForm.tsx | 166 +++---- .../src/components/ui/location-picker.tsx | 139 ++++++ frontend/src/components/ui/sheet.tsx | 128 ++++++ frontend/src/index.css | 3 +- frontend/src/types/index.ts | 11 + 11 files changed, 970 insertions(+), 335 deletions(-) create mode 100644 frontend/src/components/ui/location-picker.tsx create mode 100644 frontend/src/components/ui/sheet.tsx diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index 308d29d..d21f641 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -1,17 +1,84 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, or_ from typing import Optional, List +import json +import urllib.request +import urllib.parse +import logging from app.database import get_db from app.models.location import Location -from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse +from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse, LocationSearchResult from app.routers.auth import get_current_session from app.models.settings import Settings +logger = logging.getLogger(__name__) + router = APIRouter() +@router.get("/search", response_model=List[LocationSearchResult]) +async def search_locations( + q: str = Query(..., min_length=1), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session), +): + """Search locations from local DB and Nominatim OSM.""" + results: List[LocationSearchResult] = [] + + # Local DB search + local_query = ( + select(Location) + .where( + or_( + Location.name.ilike(f"%{q}%"), + Location.address.ilike(f"%{q}%"), + ) + ) + .limit(5) + ) + local_result = await db.execute(local_query) + local_locations = local_result.scalars().all() + + for loc in local_locations: + results.append( + LocationSearchResult( + source="local", + location_id=loc.id, + name=loc.name, + address=loc.address, + ) + ) + + # Nominatim proxy search + try: + encoded_q = urllib.parse.quote(q) + url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&limit=5" + req = urllib.request.Request( + url, + headers={"User-Agent": "UMBRA-LifeManager/1.0"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + osm_data = json.loads(resp.read().decode()) + for item in osm_data: + display_name = item.get("display_name", "") + name_parts = display_name.split(",", 1) + name = name_parts[0].strip() + address = name_parts[1].strip() if len(name_parts) > 1 else display_name + results.append( + LocationSearchResult( + source="nominatim", + name=name, + address=address, + ) + ) + except Exception as e: + logger.warning(f"Nominatim search failed: {e}") + + return results + + @router.get("/", response_model=List[LocationResponse]) async def get_locations( category: Optional[str] = Query(None), diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py index d9e3e09..35c0a89 100644 --- a/backend/app/schemas/location.py +++ b/backend/app/schemas/location.py @@ -1,6 +1,13 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime -from typing import Optional +from typing import Optional, Literal + + +class LocationSearchResult(BaseModel): + source: Literal["local", "nominatim"] + location_id: Optional[int] = None + name: str + address: str class LocationCreate(BaseModel): diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index b2f26ca..244ede9 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -11,6 +11,13 @@ import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; import CalendarSidebar from './CalendarSidebar'; import EventForm from './EventForm'; @@ -22,6 +29,8 @@ const viewLabels: Record = { timeGridDay: 'Day', }; +type ScopeAction = 'edit' | 'delete'; + export default function CalendarPage() { const queryClient = useQueryClient(); const calendarRef = useRef(null); @@ -33,6 +42,11 @@ export default function CalendarPage() { const [currentView, setCurrentView] = useState('dayGridMonth'); const [calendarTitle, setCalendarTitle] = useState(''); + // Scope dialog state + const [scopeDialogOpen, setScopeDialogOpen] = useState(false); + const [scopeAction, setScopeAction] = useState('edit'); + const [scopeEvent, setScopeEvent] = useState(null); + const { data: calendars = [] } = useCalendars(); const { data: events = [] } = useQuery({ @@ -101,6 +115,23 @@ 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); + }, + 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)); @@ -116,9 +147,15 @@ export default function CalendarPage() { borderColor: event.calendar_color || 'hsl(var(--accent-color))', extendedProps: { is_virtual: event.is_virtual, + is_recurring: event.is_recurring, + parent_event_id: event.parent_event_id, }, })); + 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; @@ -126,8 +163,27 @@ export default function CalendarPage() { toast.info(`${event.title} — from People contacts`); return; } - setEditingEvent(event); - setShowForm(true); + + if (isRecurring(event)) { + setScopeEvent(event); + setScopeAction('edit'); + setScopeDialogOpen(true); + } else { + setEditingEvent(event); + setShowForm(true); + } + }; + + const handleScopeChoice = (scope: 'this' | 'this_and_future') => { + if (!scopeEvent) return; + if (scopeAction === 'edit') { + // For edits, open form — the form will send scope on save + setEditingEvent({ ...scopeEvent, _editScope: scope } as any); + setShowForm(true); + setScopeDialogOpen(false); + } else if (scopeAction === 'delete') { + scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope }); + } }; const handleEventDrop = (info: EventDropArg) => { @@ -270,6 +326,46 @@ export default function CalendarPage() { onClose={handleCloseForm} /> )} + + {/* Recurring event scope dialog */} + + + + + {scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'} + + +

+ This is a recurring event. How would you like to proceed? +

+ + + + + +
+
); } diff --git a/frontend/src/components/calendar/EventForm.tsx b/frontend/src/components/calendar/EventForm.tsx index 0d16b43..26f5466 100644 --- a/frontend/src/components/calendar/EventForm.tsx +++ b/frontend/src/components/calendar/EventForm.tsx @@ -2,22 +2,23 @@ import { useState, FormEvent } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent, Location } from '@/types'; +import type { CalendarEvent, Location, RecurrenceRule } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogClose, -} from '@/components/ui/dialog'; + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, + SheetClose, +} from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import LocationPicker from '@/components/ui/location-picker'; interface EventFormProps { event: CalendarEvent | null; @@ -43,6 +44,36 @@ function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09: return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); } +/** FullCalendar uses exclusive end dates for all-day events. Subtract 1 day for display. */ +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())}`; +} + +/** Add 1 day to form end date before sending to API for all-day events. */ +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())}`; +} + +// Python weekday: 0=Monday, 6=Sunday +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + +function parseRecurrenceRule(raw?: string): RecurrenceRule | null { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) { const queryClient = useQueryClient(); const { data: calendars = [] } = useCalendars(); @@ -53,18 +84,27 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD const defaultCalendar = calendars.find((c) => c.is_default); const initialCalendarId = event?.calendar_id?.toString() || defaultCalendar?.id?.toString() || ''; + // For all-day events, adjust end date for display (FullCalendar exclusive end) + const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd; + const [formData, setFormData] = useState({ title: event?.title || '', description: event?.description || '', start_datetime: formatForInput(rawStart, isAllDay, '09:00'), - end_datetime: formatForInput(rawEnd, isAllDay, '10:00'), + end_datetime: formatForInput(displayEnd, isAllDay, '10:00'), all_day: isAllDay, location_id: event?.location_id?.toString() || '', calendar_id: initialCalendarId, - recurrence_rule: event?.recurrence_rule || '', is_starred: event?.is_starred || false, }); + const existingRule = parseRecurrenceRule(event?.recurrence_rule); + const [recurrenceType, setRecurrenceType] = useState(existingRule?.type || ''); + const [recurrenceInterval, setRecurrenceInterval] = useState(existingRule?.interval || 2); + const [recurrenceWeekday, setRecurrenceWeekday] = useState(existingRule?.weekday ?? 1); + const [recurrenceWeek, setRecurrenceWeek] = useState(existingRule?.week || 1); + const [recurrenceDay, setRecurrenceDay] = useState(existingRule?.day || 1); + const { data: locations = [] } = useQuery({ queryKey: ['locations'], queryFn: async () => { @@ -73,16 +113,50 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD }, }); - // Filter out system calendars (Birthdays) from the dropdown + // Location picker state + const existingLocation = locations.find((l) => l.id === event?.location_id); + const [locationSearch, setLocationSearch] = useState(existingLocation?.name || ''); + const selectableCalendars = calendars.filter((c) => !c.is_system); + const buildRecurrenceRule = (): RecurrenceRule | null => { + if (!recurrenceType) return null; + switch (recurrenceType) { + case 'every_n_days': + return { type: 'every_n_days', interval: recurrenceInterval }; + case 'weekly': + return { type: 'weekly', weekday: recurrenceWeekday }; + case 'monthly_nth_weekday': + return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday }; + case 'monthly_date': + return { type: 'monthly_date', day: recurrenceDay }; + default: + return null; + } + }; + const mutation = useMutation({ mutationFn: async (data: typeof formData) => { + const rule = buildRecurrenceRule(); + + // Adjust end date for all-day events before save + let endDt = data.end_datetime; + if (data.all_day && endDt) { + endDt = adjustAllDayEndForSave(endDt); + } + const payload = { - ...data, + title: data.title, + description: data.description, + 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) { const response = await api.put(`/events/${event.id}`, payload); return response.data; @@ -125,128 +199,222 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD }; return ( - - - - - {event ? 'Edit Event' : 'New Event'} - -
-
- - setFormData({ ...formData, title: e.target.value })} - required - /> -
- -
- -