Fix issues from QA review: critical bugs, warnings, and accessibility

- C1: Nominatim search already uses run_in_executor (non-blocking)
- C2: Ensure target event is deleted in "this_and_future" scope
- W3: Add Field constraints (ge/le) on RecurrenceRule fields
- W4: Add safety cleanup for body overflow on Sheet unmount
- W5: Block drag-drop/resize on recurring events (must use scope dialog)
- W6: Discard stale LocationPicker responses via request ID
- S8: Add role="dialog" and aria-modal to Sheet component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 01:22:57 +08:00
parent f826d05c60
commit 5701e067dd
6 changed files with 60 additions and 24 deletions

View File

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

View File

@ -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}")

View File

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

View File

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

View File

@ -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<ReturnType<typeof setTimeout>>();
const requestIdRef = useRef(0);
const containerRef = useRef<HTMLDivElement>(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<LocationSearchResult[]>('/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);

View File

@ -28,6 +28,13 @@ const Sheet: React.FC<SheetProps> = ({ 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<SheetProps> = ({ open, onOpenChange, children }) => {
onClick={() => onOpenChange(false)}
/>
<div
role="dialog"
aria-modal="true"
className={cn(
'fixed right-0 top-0 h-full w-full max-w-[540px] transition-transform duration-350',
visible ? 'translate-x-0' : 'translate-x-full'