fix(events): audit pass — 7 correctness fixes across the events system

Backend:
- tools.py: apply UTC normalization to update_event datetime fields
  (matched create_event which already did this)
- events.py service: allow end_dt/recurrence/project_id to be cleared
  via update_event by permitting None for nullable fields
- events.py service: find_events_by_query now returns upcoming events
  first, falling back to past — prevents AI tools from mutating stale
  past events when a future match exists
- events.py service: list_events now uses overlap logic (start <= to
  AND end >= from) so multi-day events spanning the query boundary
  are included; previously only start_dt was checked

Frontend:
- ToolCallCard: fire fable:calendar-changed on created/updated/deleted
  so CalendarView refetches without requiring a manual page refresh
- KnowledgeView: replace raw apiGet('/api/events') with listEvents()
  client function; also fix today bar which was reading .events off a
  flat array (always empty) — now correctly receives EventEntry[]
- HomeView: use full ISO strings for event date range instead of naive
  UTC-midnight strings; deduplicate inline date math via _dateRange()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:02:10 -04:00
parent 7677ab4028
commit 358534efbf
6 changed files with 63 additions and 31 deletions
+9 -5
View File
@@ -96,11 +96,15 @@ async def update_event(event_id: int):
if int_field in data:
fields[int_field] = data[int_field]
for dt_field in ("start_dt", "end_dt"):
if dt_field in data and data[dt_field]:
try:
fields[dt_field] = _parse_dt(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
if dt_field in data:
if data[dt_field] is None:
# Explicit null clears the field (e.g. removing end_dt)
fields[dt_field] = None
elif data[dt_field]:
try:
fields[dt_field] = _parse_dt(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
event = await events_svc.update_event(
user_id=_get_current_user_id(),
event_id=event_id,
+34 -8
View File
@@ -75,16 +75,24 @@ async def list_events(
date_from: datetime,
date_to: datetime,
) -> list[Event]:
"""List events for user_id within [date_from, date_to]."""
"""List events for user_id that overlap [date_from, date_to].
An event overlaps the range if it starts before date_to AND either
has no end_dt (point event) or its end_dt is after date_from.
This ensures multi-day events that span the query boundary are included.
"""
async with async_session() as session:
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.start_dt >= date_from,
Event.start_dt <= date_to,
or_(
Event.end_dt.is_(None),
Event.end_dt >= date_from,
),
).order_by(Event.start_dt)
)
return result.scalars().all()
return list(result.scalars().all())
async def search_events(
@@ -124,8 +132,10 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
old_title = event.title # capture before mutation for CalDAV lookup
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
"location", "color", "recurrence", "project_id"}
# Nullable fields that callers can explicitly set to None to clear
nullable = {"end_dt", "recurrence", "project_id"}
for key, value in fields.items():
if key in allowed and value is not None:
if key in allowed and (value is not None or key in nullable):
setattr(event, key, value)
await session.commit()
await session.refresh(event)
@@ -153,16 +163,32 @@ async def delete_event(user_id: int, event_id: int) -> None:
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
"""ILIKE search on title — used by AI update/delete tools."""
"""ILIKE search on title — used by AI update/delete tools.
Returns upcoming events first (start_dt >= now), falling back to
past events so the AI operates on the most relevant match.
"""
q = f"%{query}%"
now = datetime.now(timezone.utc)
async with async_session() as session:
result = await session.execute(
# Prefer events at or after now; fall back to past events
upcoming = (await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.title.ilike(q),
Event.start_dt >= now,
).order_by(Event.start_dt)
)
return result.scalars().all()
)).scalars().all()
if upcoming:
return list(upcoming)
past = (await session.execute(
select(Event).where(
Event.user_id == user_id,
Event.title.ilike(q),
Event.start_dt < now,
).order_by(Event.start_dt.desc())
)).scalars().all()
return list(past)
# ---------------------------------------------------------------------------
+4 -1
View File
@@ -1506,7 +1506,10 @@ async def execute_tool(
val = arguments.get(key)
if val:
try:
fields[dt_field] = datetime.fromisoformat(val)
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
fields[dt_field] = dt
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
updated = await events_update_event(