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 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),

View File

@ -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):

View File

@ -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<CalendarView, string> = {
timeGridDay: 'Day',
};
type ScopeAction = 'edit' | 'delete';
export default function CalendarPage() {
const queryClient = useQueryClient();
const calendarRef = useRef<FullCalendar>(null);
@ -33,6 +42,11 @@ export default function CalendarPage() {
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
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: 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;
}
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 */}
<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>
);
}

View File

@ -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<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({
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,13 +199,14 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{event ? 'Edit Event' : 'New Event'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader>
<SheetTitle>{event ? 'Edit Event' : 'New Event'}</SheetTitle>
</SheetHeader>
<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">
<Label htmlFor="title">Title</Label>
<Input
@ -148,7 +223,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
rows={4}
/>
</div>
@ -192,6 +267,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="calendar">Calendar</Label>
<Select
@ -209,33 +285,124 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Select
<LocationPicker
id="location"
value={formData.location_id}
onChange={(e) => setFormData({ ...formData, location_id: e.target.value })}
value={locationSearch}
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>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>
{loc.name}
</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>
{recurrenceType === 'every_n_days' && (
<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
id="recurrence"
value={formData.recurrence_rule}
onChange={(e) => setFormData({ ...formData, recurrence_rule: e.target.value })}
id="weekday"
value={recurrenceWeekday.toString()}
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</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">
<Checkbox
@ -245,8 +412,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
/>
<Label htmlFor="is_starred">Star this event</Label>
</div>
</div>
<DialogFooter>
<SheetFooter>
{event && (
<Button
type="button"
@ -264,9 +432,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : event ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</SheetFooter>
</form>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
);
}

View File

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

View File

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

View File

@ -4,13 +4,13 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types';
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';
@ -61,13 +61,14 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{todo ? 'Edit Todo' : 'New Todo'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader>
<SheetTitle>{todo ? 'Edit Todo' : 'New Todo'}</SheetTitle>
</SheetHeader>
<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">
<Label htmlFor="title">Title</Label>
<Input
@ -84,7 +85,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
rows={4}
/>
</div>
@ -113,13 +114,14 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Input
id="category"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="e.g., Work, Personal, Shopping"
placeholder="e.g., Work, Personal"
/>
</div>
@ -136,17 +138,19 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
</div>
<DialogFooter>
<SheetFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : todo ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</SheetFooter>
</form>
</DialogContent>
</Dialog>
</SheetContent>
</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 {
border-radius: 4px;
font-size: 0.75rem;
padding: 1px 4px;
padding: 0px 4px;
line-height: 1.4;
}
.fc .fc-timegrid-event {

View File

@ -44,6 +44,14 @@ export interface Calendar {
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 {
id: number | string;
title: string;
@ -59,6 +67,9 @@ export interface CalendarEvent {
is_virtual?: boolean;
recurrence_rule?: string;
is_starred?: boolean;
parent_event_id?: number | null;
is_recurring?: boolean;
original_start?: string | null;
created_at: string;
updated_at: string;
}