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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 00:42:12 +08:00
parent d89758fedf
commit d811890509
11 changed files with 970 additions and 335 deletions

View File

@ -1,17 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, or_
from typing import Optional, List from typing import Optional, List
import json
import urllib.request
import urllib.parse
import logging
from app.database import get_db from app.database import get_db
from app.models.location import Location 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.routers.auth import get_current_session
from app.models.settings import Settings from app.models.settings import Settings
logger = logging.getLogger(__name__)
router = APIRouter() 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]) @router.get("/", response_model=List[LocationResponse])
async def get_locations( async def get_locations(
category: Optional[str] = Query(None), category: Optional[str] = Query(None),

View File

@ -1,6 +1,13 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime 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): class LocationCreate(BaseModel):

View File

@ -11,6 +11,13 @@ import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent } from '@/types'; import type { CalendarEvent } from '@/types';
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import CalendarSidebar from './CalendarSidebar'; import CalendarSidebar from './CalendarSidebar';
import EventForm from './EventForm'; import EventForm from './EventForm';
@ -22,6 +29,8 @@ const viewLabels: Record<CalendarView, string> = {
timeGridDay: 'Day', timeGridDay: 'Day',
}; };
type ScopeAction = 'edit' | 'delete';
export default function CalendarPage() { export default function CalendarPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const calendarRef = useRef<FullCalendar>(null); const calendarRef = useRef<FullCalendar>(null);
@ -33,6 +42,11 @@ export default function CalendarPage() {
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth'); const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState(''); const [calendarTitle, setCalendarTitle] = useState('');
// Scope dialog state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
const { data: events = [] } = useQuery({ 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(() => { const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events; if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); 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))', borderColor: event.calendar_color || 'hsl(var(--accent-color))',
extendedProps: { extendedProps: {
is_virtual: event.is_virtual, 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 handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id); const event = events.find((e) => String(e.id) === info.event.id);
if (!event) return; if (!event) return;
@ -126,8 +163,27 @@ export default function CalendarPage() {
toast.info(`${event.title} — from People contacts`); toast.info(`${event.title} — from People contacts`);
return; return;
} }
if (isRecurring(event)) {
setScopeEvent(event);
setScopeAction('edit');
setScopeDialogOpen(true);
} else {
setEditingEvent(event); setEditingEvent(event);
setShowForm(true); 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) => { const handleEventDrop = (info: EventDropArg) => {
@ -270,6 +326,46 @@ export default function CalendarPage() {
onClose={handleCloseForm} 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>
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button
variant="outline"
className="w-full"
onClick={() => handleScopeChoice('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleScopeChoice('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setScopeDialogOpen(false);
setScopeEvent(null);
}}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -2,22 +2,23 @@ import { useState, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; 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 { useCalendars } from '@/hooks/useCalendars';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, SheetClose,
} from '@/components/ui/dialog'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import LocationPicker from '@/components/ui/location-picker';
interface EventFormProps { interface EventFormProps {
event: CalendarEvent | null; event: CalendarEvent | null;
@ -43,6 +44,36 @@ function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); 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) { export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars(); 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 defaultCalendar = calendars.find((c) => c.is_default);
const initialCalendarId = event?.calendar_id?.toString() || defaultCalendar?.id?.toString() || ''; 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({ const [formData, setFormData] = useState({
title: event?.title || '', title: event?.title || '',
description: event?.description || '', description: event?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'), start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(rawEnd, isAllDay, '10:00'), end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay, all_day: isAllDay,
location_id: event?.location_id?.toString() || '', location_id: event?.location_id?.toString() || '',
calendar_id: initialCalendarId, calendar_id: initialCalendarId,
recurrence_rule: event?.recurrence_rule || '',
is_starred: event?.is_starred || false, is_starred: event?.is_starred || false,
}); });
const existingRule = parseRecurrenceRule(event?.recurrence_rule);
const [recurrenceType, setRecurrenceType] = useState<string>(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({ const { data: locations = [] } = useQuery({
queryKey: ['locations'], queryKey: ['locations'],
queryFn: async () => { 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 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({ const mutation = useMutation({
mutationFn: async (data: typeof formData) => { 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 = { 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, location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
}; };
if (event) { if (event) {
const response = await api.put(`/events/${event.id}`, payload); const response = await api.put(`/events/${event.id}`, payload);
return response.data; return response.data;
@ -125,13 +199,14 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetClose onClick={onClose} />
<DialogHeader> <SheetHeader>
<DialogTitle>{event ? 'Edit Event' : 'New Event'}</DialogTitle> <SheetTitle>{event ? 'Edit Event' : 'New Event'}</SheetTitle>
</DialogHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
@ -148,7 +223,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2} rows={4}
/> />
</div> </div>
@ -192,6 +267,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="calendar">Calendar</Label> <Label htmlFor="calendar">Calendar</Label>
<Select <Select
@ -209,33 +285,124 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="location">Location</Label> <Label htmlFor="location">Location</Label>
<Select <LocationPicker
id="location" id="location"
value={formData.location_id} value={locationSearch}
onChange={(e) => setFormData({ ...formData, location_id: e.target.value })} onChange={(val) => {
setLocationSearch(val);
if (!val) setFormData({ ...formData, location_id: '' });
}}
onSelect={async (result) => {
if (result.source === 'local' && result.location_id) {
setFormData({ ...formData, 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'] });
setFormData({ ...formData, location_id: newLoc.id.toString() });
toast.success(`Location "${result.name}" created`);
} catch {
toast.error('Failed to create location');
}
}
}}
placeholder="Search for a location..."
/>
</div>
</div>
{/* Recurrence picker */}
<div className="space-y-2">
<Label htmlFor="recurrence_type">Recurrence</Label>
<Select
id="recurrence_type"
value={recurrenceType}
onChange={(e) => setRecurrenceType(e.target.value)}
> >
<option value="">None</option> <option value="">None</option>
{locations.map((loc) => ( <option value="every_n_days">Every X days</option>
<option key={loc.id} value={loc.id}> <option value="weekly">Weekly</option>
{loc.name} <option value="monthly_nth_weekday">Monthly (nth weekday)</option>
</option> <option value="monthly_date">Monthly (date)</option>
))}
</Select> </Select>
</div> </div>
{recurrenceType === 'every_n_days' && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="recurrence">Recurrence</Label> <Label htmlFor="interval">Every how many days?</Label>
<Input
id="interval"
type="number"
min={1}
max={365}
value={recurrenceInterval}
onChange={(e) => setRecurrenceInterval(parseInt(e.target.value) || 1)}
/>
</div>
)}
{recurrenceType === 'weekly' && (
<div className="space-y-2">
<Label htmlFor="weekday">Day of week</Label>
<Select <Select
id="recurrence" id="weekday"
value={formData.recurrence_rule} value={recurrenceWeekday.toString()}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })} onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
> >
<option value="">None</option> {WEEKDAYS.map((name, i) => (
<option value="daily">Daily</option> <option key={i} value={i}>{name}</option>
<option value="weekly">Weekly</option> ))}
<option value="monthly">Monthly</option>
</Select> </Select>
</div> </div>
)}
{recurrenceType === 'monthly_nth_weekday' && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="week">Week of month</Label>
<Select
id="week"
value={recurrenceWeek.toString()}
onChange={(e) => setRecurrenceWeek(parseInt(e.target.value))}
>
<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-2">
<Label htmlFor="weekday_nth">Day of week</Label>
<Select
id="weekday_nth"
value={recurrenceWeekday.toString()}
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
</div>
)}
{recurrenceType === 'monthly_date' && (
<div className="space-y-2">
<Label htmlFor="day">Day of month</Label>
<Input
id="day"
type="number"
min={1}
max={31}
value={recurrenceDay}
onChange={(e) => setRecurrenceDay(parseInt(e.target.value) || 1)}
/>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@ -245,8 +412,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
/> />
<Label htmlFor="is_starred">Star this event</Label> <Label htmlFor="is_starred">Star this event</Label>
</div> </div>
</div>
<DialogFooter> <SheetFooter>
{event && ( {event && (
<Button <Button
type="button" type="button"
@ -264,9 +432,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

View File

@ -4,18 +4,19 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types'; import type { Location } from '@/types';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, SheetClose,
} from '@/components/ui/dialog'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import LocationPicker from '@/components/ui/location-picker';
interface LocationFormProps { interface LocationFormProps {
location: Location | null; location: Location | null;
@ -57,13 +58,14 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetClose onClick={onClose} />
<DialogHeader> <SheetHeader>
<DialogTitle>{location ? 'Edit Location' : 'New Location'}</DialogTitle> <SheetTitle>{location ? 'Edit Location' : 'New Location'}</SheetTitle>
</DialogHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>
<Input <Input
@ -76,11 +78,18 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="address">Address</Label> <Label htmlFor="address">Address</Label>
<Input <LocationPicker
id="address" id="address"
value={formData.address} value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(val) => setFormData({ ...formData, address: val })}
required onSelect={(result) => {
setFormData({
...formData,
name: formData.name || result.name,
address: result.address,
});
}}
placeholder="Search or type an address..."
/> />
</div> </div>
@ -105,20 +114,21 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
id="notes" id="notes"
value={formData.notes} value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })} onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3} rows={4}
/> />
</div> </div>
</div>
<DialogFooter> <SheetFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : location ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : location ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

View File

@ -4,13 +4,13 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Reminder } from '@/types'; import type { Reminder } from '@/types';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, SheetClose,
} from '@/components/ui/dialog'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
@ -59,13 +59,14 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetClose onClick={onClose} />
<DialogHeader> <SheetHeader>
<DialogTitle>{reminder ? 'Edit Reminder' : 'New Reminder'}</DialogTitle> <SheetTitle>{reminder ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
</DialogHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
@ -82,10 +83,11 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3} rows={4}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="remind_at">Remind At</Label> <Label htmlFor="remind_at">Remind At</Label>
<Input <Input
@ -110,17 +112,19 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</Select> </Select>
</div> </div>
</div>
</div>
<DialogFooter> <SheetFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : reminder ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : reminder ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

View File

@ -4,13 +4,13 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, SheetClose,
} from '@/components/ui/dialog'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
@ -61,13 +61,14 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetClose onClick={onClose} />
<DialogHeader> <SheetHeader>
<DialogTitle>{todo ? 'Edit Todo' : 'New Todo'}</DialogTitle> <SheetTitle>{todo ? 'Edit Todo' : 'New Todo'}</SheetTitle>
</DialogHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
@ -84,7 +85,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3} rows={4}
/> />
</div> </div>
@ -113,13 +114,14 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="category">Category</Label> <Label htmlFor="category">Category</Label>
<Input <Input
id="category" id="category"
value={formData.category} value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })} onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="e.g., Work, Personal, Shopping" placeholder="e.g., Work, Personal"
/> />
</div> </div>
@ -136,17 +138,19 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</Select> </Select>
</div> </div>
</div>
</div>
<DialogFooter> <SheetFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

View File

@ -0,0 +1,139 @@
import { useState, useEffect, useRef } from 'react';
import { MapPin, Globe } from 'lucide-react';
import { Input } from '@/components/ui/input';
import api from '@/lib/api';
interface LocationSearchResult {
source: 'local' | 'nominatim';
location_id?: number | null;
name: string;
address: string;
}
interface LocationPickerProps {
value: string;
onChange: (value: string) => void;
onSelect: (result: LocationSearchResult) => void;
placeholder?: string;
id?: string;
}
export default function LocationPicker({ value, onChange, onSelect, placeholder = 'Search locations...', id }: LocationPickerProps) {
const [results, setResults] = useState<LocationSearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!value || value.length < 2) {
setResults([]);
setIsOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
setIsLoading(true);
try {
const { data } = await api.get<LocationSearchResult[]>('/locations/search', {
params: { q: value },
});
setResults(data);
setIsOpen(data.length > 0);
} catch {
setResults([]);
} finally {
setIsLoading(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [value]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (result: LocationSearchResult) => {
onChange(result.name);
onSelect(result);
setIsOpen(false);
};
const localResults = results.filter((r) => r.source === 'local');
const nominatimResults = results.filter((r) => r.source === 'nominatim');
return (
<div ref={containerRef} className="relative">
<Input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
autoComplete="off"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-3 w-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
</div>
)}
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-card shadow-lg max-h-60 overflow-y-auto">
{localResults.length > 0 && (
<>
{localResults.map((r, i) => (
<button
key={`local-${i}`}
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-card-elevated transition-colors"
onClick={() => handleSelect(r)}
>
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium">{r.name}</div>
<div className="truncate text-xs text-muted-foreground">{r.address}</div>
</div>
</button>
))}
</>
)}
{localResults.length > 0 && nominatimResults.length > 0 && (
<div className="border-t my-1" />
)}
{nominatimResults.length > 0 && (
<>
{nominatimResults.map((r, i) => (
<button
key={`osm-${i}`}
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-card-elevated transition-colors"
onClick={() => handleSelect(r)}
>
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium">{r.name}</div>
<div className="truncate text-xs text-muted-foreground">{r.address}</div>
</div>
</button>
))}
</>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,128 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
const Sheet: React.FC<SheetProps> = ({ open, onOpenChange, children }) => {
const [mounted, setMounted] = React.useState(false);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
if (open) {
setMounted(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setVisible(true));
});
document.body.style.overflow = 'hidden';
} else {
setVisible(false);
const timer = setTimeout(() => setMounted(false), 250);
document.body.style.overflow = '';
return () => clearTimeout(timer);
}
}, [open]);
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onOpenChange(false);
};
if (open) {
document.addEventListener('keydown', handleEscape);
}
return () => document.removeEventListener('keydown', handleEscape);
}, [open, onOpenChange]);
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-50">
<div
className={cn(
'fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-250',
visible ? 'opacity-100' : 'opacity-0'
)}
onClick={() => onOpenChange(false)}
/>
<div
className={cn(
'fixed right-0 top-0 h-full w-full max-w-[540px] transition-transform duration-250 ease-out',
visible ? 'translate-x-0' : 'translate-x-full'
)}
>
{children}
</div>
</div>,
document.body
);
};
const SheetContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex flex-col h-full border-l bg-card overflow-y-auto',
className
)}
{...props}
>
{children}
</div>
)
);
SheetContent.displayName = 'SheetContent';
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 px-6 py-5 border-b shrink-0', className)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight font-heading', className)}
{...props}
/>
)
);
SheetTitle.displayName = 'SheetTitle';
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t shrink-0 mt-auto',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => (
<button
ref={ref}
className={cn(
'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-10',
className
)}
{...props}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
)
);
SheetClose.displayName = 'SheetClose';
export { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose };

View File

@ -174,7 +174,8 @@
.fc .fc-daygrid-event { .fc .fc-daygrid-event {
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
padding: 1px 4px; padding: 0px 4px;
line-height: 1.4;
} }
.fc .fc-timegrid-event { .fc .fc-timegrid-event {

View File

@ -44,6 +44,14 @@ export interface Calendar {
updated_at: string; updated_at: string;
} }
export interface RecurrenceRule {
type: 'every_n_days' | 'weekly' | 'monthly_nth_weekday' | 'monthly_date';
interval?: number;
weekday?: number;
week?: number;
day?: number;
}
export interface CalendarEvent { export interface CalendarEvent {
id: number | string; id: number | string;
title: string; title: string;
@ -59,6 +67,9 @@ export interface CalendarEvent {
is_virtual?: boolean; is_virtual?: boolean;
recurrence_rule?: string; recurrence_rule?: string;
is_starred?: boolean; is_starred?: boolean;
parent_event_id?: number | null;
is_recurring?: boolean;
original_start?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }