diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 7b0b501..6b2b022 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -401,6 +401,13 @@ async def delete_event( CalendarEvent.original_start >= this_original_start, ) ) + # Ensure the target event itself is deleted (edge case: original_start fallback mismatch) + existing = await db.execute( + select(CalendarEvent).where(CalendarEvent.id == event_id) + ) + target = existing.scalar_one_or_none() + if target: + await db.delete(target) else: # This event IS the parent — delete it and all children (CASCADE handles children) await db.delete(event) diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index d21f641..644769b 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from typing import Optional, List +import asyncio import json import urllib.request import urllib.parse @@ -51,28 +52,29 @@ async def search_locations( ) ) - # Nominatim proxy search - try: + # Nominatim proxy search (run in thread executor to avoid blocking event loop) + def _fetch_nominatim() -> list: 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"}, - ) + 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, - ) + return json.loads(resp.read().decode()) + + try: + loop = asyncio.get_running_loop() + osm_data = await loop.run_in_executor(None, _fetch_nominatim) + 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}") diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index c219539..54e40ff 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -1,6 +1,6 @@ import json as _json -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from datetime import datetime from typing import Literal, Optional @@ -9,13 +9,13 @@ class RecurrenceRule(BaseModel): """Structured recurrence rule — serialized to/from JSON string in the DB column.""" type: Literal["every_n_days", "weekly", "monthly_nth_weekday", "monthly_date"] # every_n_days - interval: Optional[int] = None + interval: Optional[int] = Field(None, ge=1, le=365) # weekly / monthly_nth_weekday - weekday: Optional[int] = None # 0=Mon … 6=Sun + weekday: Optional[int] = Field(None, ge=0, le=6) # 0=Mon … 6=Sun # monthly_nth_weekday - week: Optional[int] = None # 1-4 + week: Optional[int] = Field(None, ge=1, le=4) # monthly_date - day: Optional[int] = None # 1-31 + day: Optional[int] = Field(None, ge=1, le=31) def _coerce_recurrence_rule(v): diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index dd0f0a4..a96371f 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -192,6 +192,12 @@ 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'); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr @@ -211,6 +217,12 @@ 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'); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr diff --git a/frontend/src/components/ui/location-picker.tsx b/frontend/src/components/ui/location-picker.tsx index 463687f..073e17a 100644 --- a/frontend/src/components/ui/location-picker.tsx +++ b/frontend/src/components/ui/location-picker.tsx @@ -23,6 +23,7 @@ export default function LocationPicker({ value, onChange, onSelect, placeholder const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const debounceRef = useRef>(); + const requestIdRef = useRef(0); const containerRef = useRef(null); useEffect(() => { @@ -35,17 +36,22 @@ export default function LocationPicker({ value, onChange, onSelect, placeholder } debounceRef.current = setTimeout(async () => { + const thisRequestId = ++requestIdRef.current; setIsLoading(true); try { const { data } = await api.get('/locations/search', { params: { q: value }, }); + if (thisRequestId !== requestIdRef.current) return; // stale response setResults(data); setIsOpen(data.length > 0); } catch { + if (thisRequestId !== requestIdRef.current) return; setResults([]); } finally { - setIsLoading(false); + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } } }, 300); diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index f6e3fa3..65cebc9 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -28,6 +28,13 @@ const Sheet: React.FC = ({ open, onOpenChange, children }) => { } }, [open]); + // Safety cleanup: restore overflow if component unmounts while open + React.useEffect(() => { + return () => { + document.body.style.overflow = ''; + }; + }, []); + React.useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false); @@ -50,6 +57,8 @@ const Sheet: React.FC = ({ open, onOpenChange, children }) => { onClick={() => onOpenChange(false)} />