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:
@@ -270,8 +270,11 @@ async function openEventSlideOver(id: number | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeEventSlideOver() {
|
function closeEventSlideOver(changed = false) {
|
||||||
eventSlideOverOpen.value = false;
|
eventSlideOverOpen.value = false;
|
||||||
|
if (changed) {
|
||||||
|
document.dispatchEvent(new Event("fable:calendar-changed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -522,9 +525,9 @@ function closeEventSlideOver() {
|
|||||||
:event="eventSlideOverEntry"
|
:event="eventSlideOverEntry"
|
||||||
initial-date=""
|
initial-date=""
|
||||||
@close="closeEventSlideOver"
|
@close="closeEventSlideOver"
|
||||||
@created="closeEventSlideOver"
|
@created="() => closeEventSlideOver(true)"
|
||||||
@updated="closeEventSlideOver"
|
@updated="() => closeEventSlideOver(true)"
|
||||||
@deleted="closeEventSlideOver"
|
@deleted="() => closeEventSlideOver(true)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ function _dateRange() {
|
|||||||
const today = new Date()
|
const today = new Date()
|
||||||
const nextWeek = new Date(today)
|
const nextWeek = new Date(today)
|
||||||
nextWeek.setDate(today.getDate() + 7)
|
nextWeek.setDate(today.getDate() + 7)
|
||||||
|
// Use full ISO strings so the server sees the correct UTC equivalent of
|
||||||
|
// local midnight / end-of-day rather than a naive UTC-midnight guess.
|
||||||
return {
|
return {
|
||||||
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
|
todayStr: today.toISOString(),
|
||||||
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
|
nextWeekStr: nextWeek.toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +110,7 @@ function _backgroundRefresh() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
|
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
|
||||||
const today = new Date();
|
const { todayStr, nextWeekStr } = _dateRange();
|
||||||
const todayStr = today.toISOString().slice(0, 10) + "T00:00:00";
|
|
||||||
const nextWeek = new Date(today);
|
|
||||||
nextWeek.setDate(today.getDate() + 7);
|
|
||||||
const nextWeekStr = nextWeek.toISOString().slice(0, 10) + "T23:59:59";
|
|
||||||
|
|
||||||
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
|
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPatch } from "@/api/client";
|
import { apiGet, apiPatch, listEvents } from "@/api/client";
|
||||||
import { fmtCompact } from "@/utils/dateFormat";
|
import { fmtCompact } from "@/utils/dateFormat";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import GraphView from "@/views/GraphView.vue";
|
import GraphView from "@/views/GraphView.vue";
|
||||||
@@ -167,15 +167,13 @@ async function fetchTodayBar() {
|
|||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const end = new Date(now.getTime() + 7 * 86_400_000);
|
const end = new Date(now.getTime() + 7 * 86_400_000);
|
||||||
const [evData, taskData] = await Promise.all([
|
const [events, taskData] = await Promise.all([
|
||||||
apiGet<{ events: UpcomingEvent[] }>(
|
listEvents(now.toISOString(), end.toISOString()).catch(() => [] as UpcomingEvent[]),
|
||||||
`/api/events?from=${now.toISOString().slice(0, 10)}&to=${end.toISOString().slice(0, 10)}`
|
|
||||||
).catch(() => ({ events: [] as UpcomingEvent[] })),
|
|
||||||
apiGet<{ total: number }>(
|
apiGet<{ total: number }>(
|
||||||
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||||
).catch(() => ({ total: 0 })),
|
).catch(() => ({ total: 0 })),
|
||||||
]);
|
]);
|
||||||
upcomingEvents.value = (evData.events ?? []).slice(0, 3);
|
upcomingEvents.value = events.slice(0, 3);
|
||||||
overdueCount.value = taskData.total ?? 0;
|
overdueCount.value = taskData.total ?? 0;
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,11 +96,15 @@ async def update_event(event_id: int):
|
|||||||
if int_field in data:
|
if int_field in data:
|
||||||
fields[int_field] = data[int_field]
|
fields[int_field] = data[int_field]
|
||||||
for dt_field in ("start_dt", "end_dt"):
|
for dt_field in ("start_dt", "end_dt"):
|
||||||
if dt_field in data and data[dt_field]:
|
if dt_field in data:
|
||||||
try:
|
if data[dt_field] is None:
|
||||||
fields[dt_field] = _parse_dt(data[dt_field])
|
# Explicit null clears the field (e.g. removing end_dt)
|
||||||
except ValueError:
|
fields[dt_field] = None
|
||||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
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(
|
event = await events_svc.update_event(
|
||||||
user_id=_get_current_user_id(),
|
user_id=_get_current_user_id(),
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
|
|||||||
@@ -75,16 +75,24 @@ async def list_events(
|
|||||||
date_from: datetime,
|
date_from: datetime,
|
||||||
date_to: datetime,
|
date_to: datetime,
|
||||||
) -> list[Event]:
|
) -> 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:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Event).where(
|
select(Event).where(
|
||||||
Event.user_id == user_id,
|
Event.user_id == user_id,
|
||||||
Event.start_dt >= date_from,
|
|
||||||
Event.start_dt <= date_to,
|
Event.start_dt <= date_to,
|
||||||
|
or_(
|
||||||
|
Event.end_dt.is_(None),
|
||||||
|
Event.end_dt >= date_from,
|
||||||
|
),
|
||||||
).order_by(Event.start_dt)
|
).order_by(Event.start_dt)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def search_events(
|
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
|
old_title = event.title # capture before mutation for CalDAV lookup
|
||||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||||
"location", "color", "recurrence", "project_id"}
|
"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():
|
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)
|
setattr(event, key, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(event)
|
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]:
|
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}%"
|
q = f"%{query}%"
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
async with async_session() as session:
|
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(
|
select(Event).where(
|
||||||
Event.user_id == user_id,
|
Event.user_id == user_id,
|
||||||
Event.title.ilike(q),
|
Event.title.ilike(q),
|
||||||
|
Event.start_dt >= now,
|
||||||
).order_by(Event.start_dt)
|
).order_by(Event.start_dt)
|
||||||
)
|
)).scalars().all()
|
||||||
return result.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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1506,7 +1506,10 @@ async def execute_tool(
|
|||||||
val = arguments.get(key)
|
val = arguments.get(key)
|
||||||
if val:
|
if val:
|
||||||
try:
|
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):
|
except (ValueError, TypeError):
|
||||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||||
updated = await events_update_event(
|
updated = await events_update_event(
|
||||||
|
|||||||
Reference in New Issue
Block a user