Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign

This commit was merged in pull request #47.
This commit is contained in:
2026-04-29 20:04:43 +00:00
10 changed files with 1023 additions and 260 deletions
@@ -0,0 +1,73 @@
"""replace events.end_dt with duration_minutes (Fable #160)
Revision ID: 0043
Revises: 0042
Create Date: 2026-04-29
Structural fix for the "end before start" bug class observed on prod
2026-04-29: an event landed with end_dt 32 days before start_dt due
to a tool-call mishap, then disappeared from upcoming-list filters.
Storing duration instead of end_dt makes the invalid state
inexpressible at the schema level (duration_minutes >= 0).
Backfill rules:
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
or open-ended; treated as a point event from here on)
Existing API consumers continue to receive `end_dt` in responses — the
field is now derived from `start_dt + duration_minutes` in
``Event.to_dict()`` rather than stored.
"""
from alembic import op
import sqlalchemy as sa
revision = "0043"
down_revision = "0042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"events",
sa.Column("duration_minutes", sa.Integer(), nullable=True),
)
op.create_check_constraint(
"events_duration_minutes_non_negative",
"events",
"duration_minutes IS NULL OR duration_minutes >= 0",
)
# Backfill: convert valid end_dt into a minute count; leave NULL for
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
# cleanly to point events instead of forcing a recovery guess.
op.execute(
"""
UPDATE events
SET duration_minutes = CAST(
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
)
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
"""
)
op.drop_column("events", "end_dt")
def downgrade() -> None:
op.add_column(
"events",
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
)
# Restore end_dt = start_dt + duration_minutes minutes for rows that
# had a duration. NULL duration → NULL end_dt (point event).
op.execute(
"""
UPDATE events
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
WHERE duration_minutes IS NOT NULL
"""
)
op.drop_constraint("events_duration_minutes_non_negative", "events")
op.drop_column("events", "duration_minutes")
+207 -154
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { X } from "lucide-vue-next"; import { Trash2, X } from "lucide-vue-next";
import { ref, computed, watch, onMounted, onUnmounted } from "vue"; import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client"; import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue"; import ProjectSelector from "@/components/ProjectSelector.vue";
@@ -177,26 +177,69 @@ watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm); watch(() => props.initialDate, resetForm);
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close"); if (e.key === "Escape") {
if (deleteConfirm.value) {
// Esc cancels the delete-confirm rather than closing the modal —
// gives the user a clear way out of the destructive prompt.
deleteConfirm.value = false;
return;
}
attemptClose();
}
} }
onMounted(() => document.addEventListener("keydown", handleKeydown)); onMounted(() => document.addEventListener("keydown", handleKeydown));
onUnmounted(() => document.removeEventListener("keydown", handleKeydown)); onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
async function save() { // ── Close / save flow ─────────────────────────────────────────────────────────
//
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
//
// Validity-aware behavior:
// - Form valid → save (PATCH for edit, POST for create), then close.
// - Form invalid in EDIT mode → discard the in-memory change and close.
// A toast tells the user what happened so they don't think their edit
// silently landed.
// - Form invalid in CREATE mode → close silently (nothing existed to begin
// with; no need to call this out).
function isFormValid(): { valid: boolean; reason?: string } {
if (!title.value.trim()) { if (!title.value.trim()) {
toast.show("Title is required", "error"); return { valid: false, reason: "Title required" };
return;
} }
if (!startDate.value) { if (!startDate.value) {
toast.show("Start date is required", "error"); return { valid: false, reason: "Start date required" };
return;
} }
if (!allDay.value && !startTime.value) { if (!allDay.value && !startTime.value) {
toast.show("Start time is required", "error"); return { valid: false, reason: "Start time required" };
return;
} }
return { valid: true };
}
let _closing = false;
async function attemptClose() {
if (_closing) return;
_closing = true;
try {
const validity = isFormValid();
if (!validity.valid) {
if (isEditMode.value) {
toast.show(`${validity.reason} — change discarded`, "warning");
}
// Create mode + invalid: silent close. Nothing was committed.
emit("close");
return;
}
await save();
emit("close");
} finally {
_closing = false;
}
}
async function save() {
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value); const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value const end_dt = endDate.value
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value)) ? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
@@ -216,7 +259,6 @@ async function save() {
project_id: projectId.value ?? undefined, project_id: projectId.value ?? undefined,
}; };
const updated = await updateEvent(props.event.id, payload); const updated = await updateEvent(props.event.id, payload);
toast.show("Event updated", "success");
emit("updated", updated); emit("updated", updated);
} else { } else {
const payload: EventCreatePayload = { const payload: EventCreatePayload = {
@@ -230,7 +272,6 @@ async function save() {
project_id: projectId.value ?? undefined, project_id: projectId.value ?? undefined,
}; };
const created = await createEvent(payload); const created = await createEvent(payload);
toast.show("Event created", "success");
emit("created", created); emit("created", created);
} }
} catch { } catch {
@@ -256,23 +297,57 @@ async function doDelete() {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="slide-over-backdrop" @click.self="emit('close')"> <div class="modal-backdrop" @click.self="attemptClose">
<div class="slide-over-panel" role="dialog" aria-modal="true"> <div class="modal-panel" role="dialog" aria-modal="true">
<div class="so-header"> <!-- Header: trash + close (or inline delete-confirm) -->
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2> <div class="modal-header">
<button class="so-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button> <template v-if="!deleteConfirm">
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<div class="header-actions">
<button
v-if="isEditMode"
class="header-btn header-btn-danger"
@click="deleteConfirm = true"
title="Delete event"
aria-label="Delete event"
><Trash2 :size="16" /></button>
<button
class="header-btn"
@click="attemptClose"
title="Close"
aria-label="Close"
><X :size="16" /></button>
</div>
</template>
<template v-else>
<span class="delete-confirm-prompt">Delete this event?</span>
<div class="header-actions">
<button
type="button"
class="btn-danger"
:disabled="deleting"
@click="doDelete"
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
<button
type="button"
class="btn-confirm-cancel"
@click="deleteConfirm = false"
>No</button>
</div>
</template>
</div> </div>
<form class="so-form" @submit.prevent="save"> <!-- Body: form (scrolls if it gets long) -->
<form class="modal-form" @submit.prevent="attemptClose">
<!-- Title --> <!-- Title -->
<div class="so-field"> <div class="form-field">
<label class="so-label">Title <span class="required">*</span></label> <label class="form-label">Title <span class="required">*</span></label>
<input v-model="title" class="so-input" placeholder="Event title" autofocus /> <input v-model="title" class="form-input" placeholder="Event title" autofocus />
</div> </div>
<!-- All-day toggle --> <!-- All-day toggle -->
<div class="so-field so-field-row"> <div class="form-field form-field-row">
<label class="so-label so-label-inline">All day</label> <label class="form-label form-label-inline">All day</label>
<button <button
type="button" type="button"
:class="['toggle-btn', { active: allDay }]" :class="['toggle-btn', { active: allDay }]"
@@ -281,74 +356,55 @@ async function doDelete() {
</div> </div>
<!-- Start --> <!-- Start -->
<div class="so-field"> <div class="form-field">
<label class="so-label">Start <span class="required">*</span></label> <label class="form-label">Start <span class="required">*</span></label>
<div class="dt-row"> <div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" required /> <input v-model="startDate" type="date" class="form-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required /> <input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
</div> </div>
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p> <p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
</div> </div>
<!-- End --> <!-- End -->
<div class="so-field"> <div class="form-field">
<label class="so-label">End</label> <label class="form-label">End</label>
<div class="dt-row"> <div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" /> <input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" /> <input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
</div> </div>
</div> </div>
<!-- Location --> <!-- Location -->
<div class="so-field"> <div class="form-field">
<label class="so-label">Location <span class="so-hint">(optional)</span></label> <label class="form-label">Location <span class="form-hint">(optional)</span></label>
<input v-model="location" class="so-input" placeholder="Location" /> <input v-model="location" class="form-input" placeholder="Location" />
</div> </div>
<!-- Description --> <!-- Description -->
<div class="so-field"> <div class="form-field">
<label class="so-label">Description <span class="so-hint">(optional)</span></label> <label class="form-label">Description <span class="form-hint">(optional)</span></label>
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" /> <textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
</div> </div>
<!-- Color --> <!-- Color -->
<div class="so-field so-field-row"> <div class="form-field form-field-row">
<label class="so-label so-label-inline">Color</label> <label class="form-label form-label-inline">Color</label>
<div class="color-row"> <div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" /> <input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#5B4A8A" /> <input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button> <button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
</div> </div>
</div> </div>
<!-- Project --> <!-- Project -->
<div class="so-field"> <div class="form-field">
<label class="so-label">Project <span class="so-hint">(optional)</span></label> <label class="form-label">Project <span class="form-hint">(optional)</span></label>
<ProjectSelector v-model="projectId" /> <ProjectSelector v-model="projectId" />
</div> </div>
<!-- Actions --> <!-- A hidden submit so Enter inside text inputs triggers attemptClose,
<div class="so-actions"> matching the no-explicit-Save-button intent: Enter commits. -->
<button type="submit" class="btn-primary" :disabled="saving"> <button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
</button>
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
<template v-if="isEditMode">
<button
v-if="!deleteConfirm"
type="button"
class="btn-danger-ghost"
@click="deleteConfirm = true"
>Delete</button>
<template v-else>
<span class="delete-confirm-label">Delete this event?</span>
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
{{ deleting ? "Deleting" : "Yes, delete" }}
</button>
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
</template>
</template>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -356,81 +412,110 @@ async function doDelete() {
</template> </template>
<style scoped> <style scoped>
.slide-over-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.55);
z-index: 200; z-index: 200;
display: flex; display: flex;
justify-content: flex-end; align-items: center;
justify-content: center;
padding: 1.25rem;
} }
.slide-over-panel { .modal-panel {
background: var(--color-surface, #1a1b1e); background: var(--color-surface, #1a1b1e);
border-left: 1px solid var(--color-border, #2a2b30); border: 1px solid var(--color-border, #2a2b30);
width: min(440px, 100vw); border-radius: 12px;
height: 100%; width: min(480px, 100%);
overflow-y: auto; max-height: calc(100vh - 2.5rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
} }
.so-header { .modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1.25rem 1.5rem; gap: 0.75rem;
padding: 0.85rem 1rem 0.85rem 1.5rem;
border-bottom: 1px solid var(--color-border, #2a2b30); border-bottom: 1px solid var(--color-border, #2a2b30);
position: sticky;
top: 0;
background: var(--color-surface, #1a1b1e); background: var(--color-surface, #1a1b1e);
z-index: 1; min-height: 3rem;
} }
.so-title { .modal-title {
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 500; font-weight: 500;
margin: 0; margin: 0;
color: var(--color-text, #e8e9f0); color: var(--color-text, #e8e9f0);
} }
.so-close { .header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.header-btn {
background: none; background: none;
border: none; border: none;
color: var(--color-text-muted, #888); color: var(--color-text-muted, #888);
cursor: pointer; cursor: pointer;
font-size: 1.1rem; padding: 0.4rem;
padding: 0.25rem 0.4rem; border-radius: 6px;
border-radius: 4px;
line-height: 1; line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.header-btn:hover {
background: var(--color-hover, rgba(255,255,255,0.06));
color: var(--color-text, #e8e9f0);
} }
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
.so-form { /* Trash in header: subtle until hover, then Oxblood. Lower visual weight
padding: 1.25rem 1.5rem; than Save would have been — destructive actions shouldn't loom. */
.header-btn-danger:hover {
background: var(--color-action-destructive);
color: #fff;
}
/* Inline delete-confirm prompt replaces the title row */
.delete-confirm-prompt {
font-size: 0.95rem;
color: var(--color-text, #e8e9f0);
font-weight: 500;
}
/* Form scrolls inside the panel when content overflows */
.modal-form {
padding: 1.25rem 1.5rem 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.1rem; gap: 1.1rem;
flex: 1; overflow-y: auto;
} }
.so-field { display: flex; flex-direction: column; gap: 0.35rem; } .form-field { display: flex; flex-direction: column; gap: 0.35rem; }
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; } .form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
.so-label { .form-label {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-muted, #888); color: var(--color-text-muted, #888);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.so-label-inline { flex-shrink: 0; margin: 0; } .form-label-inline { flex-shrink: 0; margin: 0; }
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; } .form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
.required { color: #f87171; } .required { color: #f87171; }
.so-input { .form-input {
background: var(--color-input-bg, #111113); background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30); border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text, #e8e9f0); color: var(--color-text, #e8e9f0);
@@ -441,14 +526,14 @@ async function doDelete() {
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.so-input:focus { outline: none; border-color: var(--color-primary); } .form-input:focus { outline: none; border-color: var(--color-primary); }
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; } .form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; } .dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; } .dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; } .dt-time { width: 7.5rem; flex-shrink: 0; }
.so-past-hint { .form-past-hint {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -482,65 +567,14 @@ async function doDelete() {
font-size: 0.85rem; font-size: 0.85rem;
} }
.so-actions { /* Confirm-delete buttons (only shown during the inline confirm flow) */
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
margin-top: auto;
}
/* Save (in slide-over): Moss action-primary */
.btn-primary {
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.2rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Cancel: Bronze action-secondary */
.btn-secondary {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-action-secondary-hover); }
/* Delete (entry-point): Oxblood action-destructive ghost */
.btn-danger-ghost {
margin-left: auto;
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-danger-ghost:hover { background: var(--color-action-destructive); color: #fff; }
/* Confirm-delete: Oxblood filled */
.btn-danger { .btn-danger {
background: var(--color-action-destructive); background: var(--color-action-destructive);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 8px; border-radius: 6px;
padding: 0.55rem 1rem; padding: 0.4rem 0.85rem;
font-size: 0.9rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@@ -548,9 +582,28 @@ async function doDelete() {
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); } .btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } .btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.delete-confirm-label { .btn-confirm-cancel {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted, #888); cursor: pointer;
align-self: center; transition: background 0.15s;
}
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
path as X / Esc / backdrop. No visible Save button needed. */
.hidden-submit {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
} }
</style> </style>
+22 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -20,7 +20,11 @@ class Event(Base):
uid: Mapped[str] = mapped_column(Text) uid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True)) start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Duration in minutes; NULL = point event with no end specified.
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
# The DB has a CHECK constraint that this is NULL or >= 0, so an
# event whose end is before its start is structurally inexpressible.
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False) all_day: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(Text, default="") location: Mapped[str] = mapped_column(Text, default="")
@@ -38,7 +42,21 @@ class Event(Base):
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),
) )
@property
def end_dt(self) -> datetime | None:
"""Derived end datetime: ``start_dt + duration_minutes``.
Returns ``None`` for point events (``duration_minutes is None``).
Computed at access time rather than stored — a stored end was
the source of the "end before start" corruption that motivated
this redesign.
"""
if self.duration_minutes is None:
return None
return self.start_dt + timedelta(minutes=self.duration_minutes)
def to_dict(self) -> dict: def to_dict(self) -> dict:
end_dt = self.end_dt
return { return {
"id": self.id, "id": self.id,
"user_id": self.user_id, "user_id": self.user_id,
@@ -47,7 +65,8 @@ class Event(Base):
"project_id": self.project_id, "project_id": self.project_id,
"title": self.title, "title": self.title,
"start_dt": self.start_dt.isoformat() if self.start_dt else None, "start_dt": self.start_dt.isoformat() if self.start_dt else None,
"end_dt": self.end_dt.isoformat() if self.end_dt else None, "end_dt": end_dt.isoformat() if end_dt else None,
"duration_minutes": self.duration_minutes,
"all_day": self.all_day, "all_day": self.all_day,
"description": self.description, "description": self.description,
"location": self.location, "location": self.location,
+26 -19
View File
@@ -54,19 +54,23 @@ async def create_event():
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
except ValueError: except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400 return jsonify({"error": "Invalid datetime format"}), 400
event = await events_svc.create_event( try:
user_id=_get_current_user_id(), event = await events_svc.create_event(
title=data["title"], user_id=_get_current_user_id(),
start_dt=start_dt, title=data["title"],
end_dt=end_dt, start_dt=start_dt,
all_day=data.get("all_day", False), end_dt=end_dt,
description=data.get("description", ""), duration_minutes=data.get("duration_minutes"),
location=data.get("location", ""), all_day=data.get("all_day", False),
color=data.get("color", ""), description=data.get("description", ""),
recurrence=data.get("recurrence"), location=data.get("location", ""),
project_id=data.get("project_id"), color=data.get("color", ""),
reminder_minutes=data.get("reminder_minutes"), recurrence=data.get("recurrence"),
) project_id=data.get("project_id"),
reminder_minutes=data.get("reminder_minutes"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(event.to_dict()), 201 return jsonify(event.to_dict()), 201
@@ -93,7 +97,7 @@ async def update_event(event_id: int):
for bool_field in ("all_day",): for bool_field in ("all_day",):
if bool_field in data: if bool_field in data:
fields[bool_field] = data[bool_field] fields[bool_field] = data[bool_field]
for int_field in ("project_id", "reminder_minutes"): for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
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"):
@@ -106,11 +110,14 @@ async def update_event(event_id: int):
fields[dt_field] = _parse_dt(data[dt_field]) fields[dt_field] = _parse_dt(data[dt_field])
except ValueError: except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400 return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
event = await events_svc.update_event( try:
user_id=_get_current_user_id(), event = await events_svc.update_event(
event_id=event_id, user_id=_get_current_user_id(),
**fields, event_id=event_id,
) **fields,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if event is None: if event is None:
return jsonify({"error": "Event not found"}), 404 return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict()) return jsonify(event.to_dict())
+14 -3
View File
@@ -130,6 +130,17 @@ async def sync_user_events(user_id: int) -> dict:
async with async_session() as session: async with async_session() as session:
for ev in remote_events: for ev in remote_events:
caldav_uid = ev["caldav_uid"] caldav_uid = ev["caldav_uid"]
# Storage uses duration, not end_dt. Convert here so the
# rest of this function can compare/upsert in one shape.
ev_start = ev["start_dt"]
ev_end = ev["end_dt"]
ev_duration = (
int((ev_end - ev_start).total_seconds() // 60)
if ev_end is not None and ev_start is not None and ev_end > ev_start
else None
)
ev["duration_minutes"] = ev_duration
result = await session.execute( result = await session.execute(
select(Event).where( select(Event).where(
Event.user_id == user_id, Event.user_id == user_id,
@@ -145,8 +156,8 @@ async def sync_user_events(user_id: int) -> dict:
uid=str(uuid.uuid4()), uid=str(uuid.uuid4()),
caldav_uid=caldav_uid, caldav_uid=caldav_uid,
title=ev["title"], title=ev["title"],
start_dt=ev["start_dt"], start_dt=ev_start,
end_dt=ev["end_dt"], duration_minutes=ev_duration,
all_day=ev["all_day"], all_day=ev["all_day"],
description=ev["description"], description=ev["description"],
location=ev["location"], location=ev["location"],
@@ -157,7 +168,7 @@ async def sync_user_events(user_id: int) -> dict:
else: else:
# Update if anything changed # Update if anything changed
changed = False changed = False
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"): for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
if getattr(existing, field) != ev[field]: if getattr(existing, field) != ev[field]:
setattr(existing, field, ev[field]) setattr(existing, field, ev[field])
changed = True changed = True
+10 -2
View File
@@ -207,6 +207,14 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
d = dtend.dt d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc) end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
# Storage uses duration, not end_dt. Convert iCal DTEND to a
# minute count anchored on DTSTART. Treat invalid (end <= start)
# incoming data as a point event rather than rejecting; we
# don't control external CalDAV writers.
duration_minutes = None
if end_dt is not None and end_dt > start_dt:
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid) select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
@@ -215,7 +223,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
if existing: if existing:
existing.title = title existing.title = title
existing.start_dt = start_dt existing.start_dt = start_dt
existing.end_dt = end_dt existing.duration_minutes = duration_minutes
existing.all_day = all_day existing.all_day = all_day
existing.description = description existing.description = description
existing.location = location existing.location = location
@@ -230,7 +238,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
uid=ical_uid, uid=ical_uid,
title=title, title=title,
start_dt=start_dt, start_dt=start_dt,
end_dt=end_dt, duration_minutes=duration_minutes,
all_day=all_day, all_day=all_day,
description=description, description=description,
location=location, location=location,
+171 -59
View File
@@ -1,4 +1,13 @@
"""Internal event store service with CalDAV push sync.""" """Internal event store service with CalDAV push sync.
Storage model: an event is anchored at ``start_dt`` and has an optional
``duration_minutes``. The end of the event is *derived* via
``Event.end_dt`` (a Python property), never stored. Callers may still
pass ``end_dt`` on writes for ergonomic compatibility — the service
converts to ``duration_minutes`` internally. This rules out the entire
"end before start" bug class structurally (Fable #160 / migration
0043). Open-ended events use ``duration_minutes = None``.
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -7,7 +16,7 @@ import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from sqlalchemy import and_, or_, select from sqlalchemy import or_, select
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.event import Event from fabledassistant.models.event import Event
@@ -15,11 +24,56 @@ from fabledassistant.models.event import Event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _normalize_duration(
*,
start_dt: datetime,
end_dt: datetime | None,
duration_minutes: int | None,
) -> int | None:
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
``duration_minutes`` value.
Resolution order:
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
If ``end_dt`` is also given, validate the two agree.
2. Otherwise, derive from ``end_dt - start_dt``.
3. Otherwise None (point event with no end).
Raises ``ValueError`` for any invalid combination — duration < 0,
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
"""
if duration_minutes is not None:
if duration_minutes < 0:
raise ValueError(
f"duration_minutes must be >= 0, got {duration_minutes}"
)
if end_dt is not None:
expected = int((end_dt - start_dt).total_seconds() // 60)
if expected != duration_minutes:
raise ValueError(
f"end_dt ({end_dt.isoformat()}) implies "
f"{expected} minutes but duration_minutes={duration_minutes} "
f"was passed; pass only one or make them agree."
)
return duration_minutes
if end_dt is not None:
delta_seconds = (end_dt - start_dt).total_seconds()
if delta_seconds < 0:
raise ValueError(
f"end_dt ({end_dt.isoformat()}) must be at or after "
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
f"or omit it for point events."
)
return int(delta_seconds // 60)
return None
async def create_event( async def create_event(
user_id: int, user_id: int,
title: str, title: str,
start_dt: datetime, start_dt: datetime,
end_dt: datetime | None = None, end_dt: datetime | None = None,
duration_minutes: int | None = None,
all_day: bool = False, all_day: bool = False,
description: str = "", description: str = "",
location: str = "", location: str = "",
@@ -27,12 +81,25 @@ async def create_event(
recurrence: str | None = None, recurrence: str | None = None,
project_id: int | None = None, project_id: int | None = None,
reminder_minutes: int | None = None, reminder_minutes: int | None = None,
# CalDAV-only fields (not stored in DB, forwarded to push) # ``duration`` is a legacy alias kept for the calendar tool layer
# and CalDAV pass-through callers; promotes to duration_minutes
# when duration_minutes isn't otherwise specified.
duration: int | None = None, duration: int | None = None,
attendees: list[str] | None = None, attendees: list[str] | None = None,
calendar_name: str | None = None, calendar_name: str | None = None,
) -> Event: ) -> Event:
"""Create an event in the DB, then fire a CalDAV push task.""" """Create an event in the DB, then fire a CalDAV push task.
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
service converts to ``duration_minutes`` internally. Raises
``ValueError`` on invalid combinations (negative duration, end
before start, end/duration disagreement).
"""
if duration is not None and duration_minutes is None:
duration_minutes = duration
duration_minutes = _normalize_duration(
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
)
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
async with async_session() as session: async with async_session() as session:
event = Event( event = Event(
@@ -40,7 +107,7 @@ async def create_event(
uid=uid, uid=uid,
title=title, title=title,
start_dt=start_dt, start_dt=start_dt,
end_dt=end_dt, duration_minutes=duration_minutes,
all_day=all_day, all_day=all_day,
description=description, description=description,
location=location, location=location,
@@ -54,7 +121,7 @@ async def create_event(
await session.refresh(event) await session.refresh(event)
extra_fields = { extra_fields = {
"duration": duration, "duration": duration_minutes,
"reminder_minutes": reminder_minutes, "reminder_minutes": reminder_minutes,
"attendees": attendees, "attendees": attendees,
"calendar_name": calendar_name, "calendar_name": calendar_name,
@@ -80,66 +147,74 @@ async def list_events(
"""List events for user_id that overlap [date_from, date_to]. """List events for user_id that overlap [date_from, date_to].
Recurring events (with an RRULE recurrence string) are expanded into Recurring events (with an RRULE recurrence string) are expanded into
individual occurrences within the range. Non-recurring events are individual occurrences within the range. Non-recurring events are
returned as-is. All results are sorted by start time and returned as returned as-is. All results are sorted by start time and returned as
dicts (same shape as Event.to_dict()). dicts (same shape as ``Event.to_dict()``).
Filtering strategy: a coarse SQL prefilter (events that start on or
before ``date_to``), then refine in Python using the event's derived
end (``start_dt + duration_minutes``). Doing the end-of-event math
in SQL would require Postgres-specific interval arithmetic; the
Python-side refinement is a few row-loops over a small per-user
result set, which is fine for personal-scale data and avoids
coupling the query to a specific dialect.
""" """
async with async_session() as session: async with async_session() as session:
# Match strategy:
# - Recurring events: fetch all, expand via rrule below.
# - Non-recurring with an end_dt: standard overlap — starts before
# date_to and ends after date_from.
# - Non-recurring with no end_dt: treat as a point event at
# start_dt, include only if start_dt falls within the window.
# (Previously this branch matched any event with a null end_dt,
# returning all past events as "happening today".)
result = await session.execute( result = await session.execute(
select(Event).where( select(Event)
.where(
Event.user_id == user_id, Event.user_id == user_id,
or_( or_(
Event.recurrence.isnot(None), Event.recurrence.isnot(None),
and_( Event.start_dt <= date_to,
Event.recurrence.is_(None),
Event.start_dt <= date_to,
or_(
Event.end_dt >= date_from,
and_(
Event.end_dt.is_(None),
Event.start_dt >= date_from,
),
),
),
), ),
).order_by(Event.start_dt) )
.order_by(Event.start_dt)
) )
events = list(result.scalars().all()) events = list(result.scalars().all())
items: list[dict] = [] items: list[dict] = []
for event in events: for event in events:
if not event.recurrence: if event.recurrence:
items.append(event.to_dict()) duration = (
timedelta(minutes=event.duration_minutes)
if event.duration_minutes is not None
else None
)
try:
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
occurrences = rule.between(date_from, date_to, inc=True)
except Exception:
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
# Fall back to canonical event row; still apply the
# window check so a far-future canonical row doesn't
# leak into today's list.
if date_from <= event.start_dt <= date_to:
items.append(event.to_dict())
continue
base = event.to_dict()
for occ in occurrences:
if occ.tzinfo is None:
occ = occ.replace(tzinfo=timezone.utc)
occurrence_dict = dict(base)
occurrence_dict["start_dt"] = occ.isoformat()
if duration is not None:
occurrence_dict["end_dt"] = (occ + duration).isoformat()
items.append(occurrence_dict)
continue continue
# Expand recurring event occurrences within [date_from, date_to] # Non-recurring: refine the coarse prefilter in Python using the
duration = (event.end_dt - event.start_dt) if event.end_dt else None # derived end_dt. A point event (duration None) is included when
try: # its start is at or after date_from. A timed event is included
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False) # when its end is at or after date_from.
occurrences = rule.between(date_from, date_to, inc=True) derived_end = event.end_dt
except Exception: if derived_end is None:
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence) if event.start_dt >= date_from:
items.append(event.to_dict()) items.append(event.to_dict())
continue else:
if derived_end >= date_from:
base = event.to_dict() items.append(event.to_dict())
for occ in occurrences:
# Ensure occurrence is UTC-aware
if occ.tzinfo is None:
occ = occ.replace(tzinfo=timezone.utc)
occurrence_dict = dict(base)
occurrence_dict["start_dt"] = occ.isoformat()
if duration is not None:
occurrence_dict["end_dt"] = (occ + duration).isoformat()
items.append(occurrence_dict)
items.sort(key=lambda x: x["start_dt"]) items.sort(key=lambda x: x["start_dt"])
return items return items
@@ -173,7 +248,13 @@ async def search_events(
async def update_event(user_id: int, event_id: int, **fields) -> Event | None: async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
"""Partial update. Returns updated event or None if not found.""" """Partial update. Returns updated event or None if not found.
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
agreement). The service converts to ``duration_minutes`` before
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
invalid combinations against the post-update state.
"""
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id) select(Event).where(Event.id == event_id, Event.user_id == user_id)
@@ -182,10 +263,39 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
if event is None: if event is None:
return None return 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",
"location", "color", "recurrence", "project_id", "reminder_minutes"} # Resolve any end_dt/duration_minutes inputs against the
# Nullable fields that callers can explicitly set to None to clear # post-update start_dt. If neither is in the patch, leave the
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"} # existing duration_minutes alone.
post_update_start = (
fields["start_dt"]
if fields.get("start_dt") is not None
else event.start_dt
)
if "end_dt" in fields or "duration_minutes" in fields:
new_end = fields.pop("end_dt", None)
new_duration = fields.pop("duration_minutes", None)
# If end_dt is in the patch but explicitly None, that's a
# clear → duration_minutes = None. Same shape duration_minutes=None.
if new_end is None and new_duration is None:
fields["duration_minutes"] = None
else:
fields["duration_minutes"] = _normalize_duration(
start_dt=post_update_start,
end_dt=new_end,
duration_minutes=new_duration,
)
allowed = {
"title", "start_dt", "duration_minutes", "all_day",
"description", "location", "color", "recurrence",
"project_id", "reminder_minutes",
}
# Nullable fields callers can explicitly clear by passing None
nullable = {
"duration_minutes", "recurrence", "project_id",
"reminder_minutes",
}
for key, value in fields.items(): for key, value in fields.items():
if key in allowed and (value is not None or key in nullable): if key in allowed and (value is not None or key in nullable):
setattr(event, key, value) setattr(event, key, value)
@@ -255,11 +365,12 @@ async def _push_create(event: Event, user_id: int, extra: dict) -> None:
) )
if not await is_caldav_configured(user_id): if not await is_caldav_configured(user_id):
return return
derived_end = event.end_dt # property: start + duration_minutes
await caldav_create( await caldav_create(
user_id=user_id, user_id=user_id,
title=event.title, title=event.title,
start=event.start_dt.isoformat(), start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None, end=derived_end.isoformat() if derived_end else None,
description=event.description or None, description=event.description or None,
location=event.location or None, location=event.location or None,
all_day=event.all_day, all_day=event.all_day,
@@ -296,12 +407,13 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
return return
# Use old_title so CalDAV can find the event even if the title was changed # Use old_title so CalDAV can find the event even if the title was changed
query_title = old_title or event.title query_title = old_title or event.title
derived_end = event.end_dt
await caldav_update( await caldav_update(
user_id=user_id, user_id=user_id,
query=query_title, query=query_title,
title=event.title, title=event.title,
start=event.start_dt.isoformat(), start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None, end=derived_end.isoformat() if derived_end else None,
description=event.description or None, description=event.description or None,
location=event.location or None, location=event.location or None,
) )
+117 -16
View File
@@ -108,6 +108,88 @@ async def _resolve_event_end(
return None return None
def _candidate_summary(event) -> dict:
"""Compact event summary used in ambiguous-match responses.
Keeps the candidate list small so the model can disambiguate from
the same turn without bloating context. Includes id (for the
follow-up call), title, start_dt, and location when present.
"""
return {
"id": event.id,
"title": event.title,
"start_dt": event.start_dt.isoformat() if event.start_dt else None,
"location": event.location or None,
}
async def _resolve_event_for_action(
*, user_id: int, arguments: dict, action: str,
):
"""Pick the single event the model intends to update or delete.
Resolution rules:
- ``event_id`` in arguments → exact lookup (skip query). Used by
the model to disambiguate after a multi-match refusal.
- else ``query`` → ``find_events_by_query``:
- 0 results → return error tuple ("not_found", ...)
- 1 result → return that event
- 2+ results → return ("ambiguous", error, candidates) so the
caller can refuse the call and show candidates to the model.
Returns either an Event (success) or a 2- or 3-tuple of
``(error_kind, error_dict)`` for the caller to translate into a
tool-call response.
"""
from fabledassistant.services.events import get_event
event_id = arguments.get("event_id")
if event_id is not None:
try:
event_id_int = int(event_id)
except (TypeError, ValueError):
return ("invalid_id", {
"success": False,
"error": f"event_id must be an integer; got {event_id!r}.",
})
ev = await get_event(user_id=user_id, event_id=event_id_int)
if ev is None:
return ("not_found", {
"success": False,
"error": f"No event found with id={event_id_int}.",
})
return ev
query = arguments.get("query", "")
if not query:
return ("invalid_query", {
"success": False,
"error": "Either query or event_id is required.",
})
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return ("not_found", {
"success": False,
"error": f"No event found matching {query!r}.",
})
if len(matches) == 1:
return matches[0]
# Multi-match: refuse and surface candidates so the model can
# disambiguate via event_id on the next call. Prevents the silent-
# picks-matches[0] failure mode that mutated the wrong event in the
# 2026-04-29 dentist-appointment incident (Fable #161).
return ("ambiguous", {
"success": False,
"error": (
f"Found {len(matches)} events matching {query!r}. "
f"Pick one by passing `event_id` instead of `query`, "
f"or refine the search term to match a single event."
),
"action": action,
"candidates": [_candidate_summary(m) for m in matches[:8]],
})
def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None: def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None:
"""Verify the resolved local date falls on the expected day of the week. """Verify the resolved local date falls on the expected day of the week.
@@ -305,10 +387,17 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
"When the user names a weekday ('move to Friday'), state the " "When the user names a weekday ('move to Friday'), state the "
"resolved calendar date in your reply BEFORE calling this tool, " "resolved calendar date in your reply BEFORE calling this tool, "
"and pass `expected_weekday` so the server can verify the date " "and pass `expected_weekday` so the server can verify the date "
"falls on the day you intended." "falls on the day you intended.\n\n"
"Identify the event with EITHER `query` (a title substring) OR "
"`event_id` (when you already have an exact id from a prior tool "
"result). If `query` matches multiple events, the tool returns "
"an ambiguity error with a candidate list — pick one by passing "
"its `event_id` on the next call, or refine the query so it "
"matches a single event."
), ),
parameters={ parameters={
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"}, "query": {"type": "string", "description": "Search term to find the event to update (matches against title). Required unless event_id is set."},
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
"title": {"type": "string", "description": "New title for the event"}, "title": {"type": "string", "description": "New title for the event"},
"start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."}, "start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
"start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."}, "start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."},
@@ -325,14 +414,15 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."}, "start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."}, "end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
}, },
required=["query"], required=[],
) )
async def update_event_tool(*, user_id, arguments, **_ctx): async def update_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "") resolved = await _resolve_event_for_action(
matches = await find_events_by_query(user_id=user_id, query=query) user_id=user_id, arguments=arguments, action="update",
if not matches: )
return {"success": False, "error": f"No event found matching '{query}'."} if isinstance(resolved, tuple):
event_to_update = matches[0] return resolved[1] # error dict from the resolver
event_to_update = resolved
fields: dict = {} fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"): for str_field in ("title", "description", "location", "color", "recurrence"):
if arguments.get(str_field) is not None: if arguments.get(str_field) is not None:
@@ -368,18 +458,29 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
@tool( @tool(
name="delete_event", name="delete_event",
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.", description=(
"Delete a calendar event. Use this when the user asks to cancel, "
"remove, or delete an event. Identify the event with EITHER "
"`query` (a title substring) OR `event_id` (when you have an "
"exact id). If `query` matches multiple events, the tool returns "
"an ambiguity error with a candidate list — pick one by passing "
"its `event_id` on the next call, or refine the query so it "
"matches a single event. Deleting the wrong event is a costly "
"user error; never guess between candidates."
),
parameters={ parameters={
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"}, "query": {"type": "string", "description": "Search term to find the event to delete (matches against title). Required unless event_id is set."},
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
}, },
required=["query"], required=[],
) )
async def delete_event_tool(*, user_id, arguments, **_ctx): async def delete_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "") resolved = await _resolve_event_for_action(
matches = await find_events_by_query(user_id=user_id, query=query) user_id=user_id, arguments=arguments, action="delete",
if not matches: )
return {"success": False, "error": f"No event found matching '{query}'."} if isinstance(resolved, tuple):
event_to_delete = matches[0] return resolved[1]
event_to_delete = resolved
await events_delete_event(user_id=user_id, event_id=event_to_delete.id) await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}} return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
+182
View File
@@ -577,6 +577,188 @@ async def test_update_event_expected_weekday_mismatch_rejects():
assert update_called is False assert update_called is False
# ── Multi-match disambiguation (Fable #161) ──────────────────────────────────
@pytest.mark.asyncio
async def test_update_event_refuses_ambiguous_query_with_candidates():
"""The reported failure: model called update_event(query='Appointment')
when two events matched. Pre-fix: silently mutated matches[0] (the
wrong event). Post-fix: returns success=False with a candidates list
so the model can pick the right one."""
from fabledassistant.services.tools.calendar import update_event_tool
# Two events both matching "Appointment"
ev_a = AsyncMock()
ev_a.id = 2
ev_a.title = "Appointment"
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
ev_a.location = ""
ev_b = AsyncMock()
ev_b.id = 15
ev_b.title = "Appointment"
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
ev_b.location = "Dentist"
update_called = False
async def fake_find(*, user_id, query):
return [ev_a, ev_b]
async def fake_update(*, user_id, event_id, **fields):
nonlocal update_called
update_called = True
return AsyncMock()
with patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.tools.calendar.events_update_event",
side_effect=fake_update,
):
result = await update_event_tool(
user_id=1,
arguments={"query": "Appointment", "title": "Dentist"},
)
assert result["success"] is False
assert "Found 2 events" in result["error"]
assert "event_id" in result["error"].lower()
assert "candidates" in result
candidate_ids = [c["id"] for c in result["candidates"]]
assert candidate_ids == [2, 15]
# Critical: nothing was mutated.
assert update_called is False
@pytest.mark.asyncio
async def test_update_event_with_event_id_skips_query_lookup():
"""Once the model picks a candidate via event_id, the call proceeds
against that exact event — no query, no ambiguity."""
from fabledassistant.services.tools.calendar import update_event_tool
# find_events_by_query should NOT be called when event_id is supplied
find_called = False
async def fake_find(*, user_id, query):
nonlocal find_called
find_called = True
return []
target = AsyncMock()
target.id = 15
target.title = "Appointment"
target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
target.location = ""
async def fake_get_event(*, user_id, event_id):
return target if event_id == 15 else None
captured = {}
async def fake_update(*, user_id, event_id, **fields):
captured["event_id"] = event_id
captured.update(fields)
ev = AsyncMock()
ev.to_dict.return_value = {"id": event_id, **fields}
return ev
with patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.events.get_event",
side_effect=fake_get_event,
), patch(
"fabledassistant.services.tools.calendar.events_update_event",
side_effect=fake_update,
):
result = await update_event_tool(
user_id=1,
arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"},
)
assert result["success"] is True
assert captured["event_id"] == 15
assert captured["title"] == "Dentist: Permanent Crown Fitting"
assert find_called is False
@pytest.mark.asyncio
async def test_update_event_with_event_id_not_found_returns_error():
"""A bad event_id (non-existent) must surface clearly, not silently
fall back to query-based lookup."""
from fabledassistant.services.tools.calendar import update_event_tool
async def fake_get_event(*, user_id, event_id):
return None
with patch(
"fabledassistant.services.events.get_event",
side_effect=fake_get_event,
):
result = await update_event_tool(
user_id=1,
arguments={"event_id": 999, "title": "anything"},
)
assert result["success"] is False
assert "999" in result["error"]
assert "No event found" in result["error"]
@pytest.mark.asyncio
async def test_update_event_neither_query_nor_event_id_returns_error():
"""Calling update_event with neither identifier is a usage error."""
from fabledassistant.services.tools.calendar import update_event_tool
result = await update_event_tool(user_id=1, arguments={"title": "x"})
assert result["success"] is False
assert "query or event_id" in result["error"]
@pytest.mark.asyncio
async def test_delete_event_refuses_ambiguous_query_with_candidates():
"""delete_event must enforce the same disambiguation. Costly to get
wrong — silent matches[0] would delete the wrong event entirely."""
from fabledassistant.services.tools.calendar import delete_event_tool
ev_a = AsyncMock()
ev_a.id = 2; ev_a.title = "Appointment"
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
ev_a.location = ""
ev_b = AsyncMock()
ev_b.id = 15; ev_b.title = "Appointment"
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
ev_b.location = "Dentist"
delete_called = False
async def fake_find(*, user_id, query):
return [ev_a, ev_b]
async def fake_delete(*, user_id, event_id):
nonlocal delete_called
delete_called = True
with patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.tools.calendar.events_delete_event",
side_effect=fake_delete,
):
result = await delete_event_tool(
user_id=1, arguments={"query": "Appointment"},
)
assert result["success"] is False
assert len(result["candidates"]) == 2
# Most important assertion: nothing was actually deleted.
assert delete_called is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_event_weekday_check_uses_local_not_utc(): async def test_create_event_weekday_check_uses_local_not_utc():
"""The weekday check must use the LOCAL date, not the UTC date. """The weekday check must use the LOCAL date, not the UTC date.
+201 -4
View File
@@ -14,7 +14,7 @@ def _make_mock_session():
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting", def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
caldav_uid="", color=""): caldav_uid="", color="", duration_minutes=60):
e = MagicMock() e = MagicMock()
e.id = id e.id = id
e.user_id = user_id e.user_id = user_id
@@ -23,7 +23,14 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
e.caldav_uid = caldav_uid e.caldav_uid = caldav_uid
e.color = color e.color = color
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc) e.duration_minutes = duration_minutes
# end_dt is derived; mirror the property's behavior on the mock so
# service code that reads `event.end_dt` gets a sensible value.
if duration_minutes is None:
e.end_dt = None
else:
from datetime import timedelta
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
e.all_day = False e.all_day = False
e.description = "" e.description = ""
e.location = "" e.location = ""
@@ -32,8 +39,9 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
e.to_dict.return_value = { e.to_dict.return_value = {
"id": id, "uid": uid, "title": title, "id": id, "uid": uid, "title": title,
"caldav_uid": caldav_uid, "color": color, "caldav_uid": caldav_uid, "color": color,
"start_dt": datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc).isoformat(), "start_dt": e.start_dt.isoformat(),
"end_dt": datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc).isoformat(), "end_dt": e.end_dt.isoformat() if e.end_dt else None,
"duration_minutes": duration_minutes,
} }
return e return e
@@ -124,6 +132,195 @@ async def test_update_event_fires_caldav_push():
assert mock_task.called assert mock_task.called
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
def test_normalize_duration_from_end_dt():
"""end_dt sugar converts to a positive minute count anchored on start."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
def test_normalize_duration_zero_is_valid_point_event():
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
is rare but legal (e.g. an instant marker); the duration model treats
it the same as duration None for display purposes."""
from fabledassistant.services.events import _normalize_duration
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
def test_normalize_duration_rejects_end_before_start():
"""The exact 2026-04-29 prod failure: end 32 days before start.
The duration model makes this inexpressible at the schema level
via a CHECK constraint, but write-path callers still get a
helpful ValueError if they construct an inconsistent (start, end)
pair via the end_dt sugar."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
_normalize_duration(
start_dt=start, end_dt=end_before, duration_minutes=None,
)
def test_normalize_duration_rejects_negative_duration():
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
constraint at the service boundary so callers get a clean error
rather than a constraint violation from psycopg."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="must be >= 0"):
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
def test_normalize_duration_rejects_inconsistent_end_and_duration():
"""If a caller passes both end_dt AND duration_minutes that disagree,
the inconsistency is surfaced rather than silently picking one."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
with pytest.raises(ValueError, match="implies 60 minutes"):
_normalize_duration(
start_dt=start, end_dt=end, duration_minutes=30,
)
def test_normalize_duration_none_for_open_ended():
"""Both inputs None → None duration (open-ended event)."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
assert _normalize_duration(
start_dt=start, end_dt=None, duration_minutes=None,
) is None
@pytest.mark.asyncio
async def test_create_event_rejects_end_before_start():
"""Service-level rejection — same scenario as the prod bug, surfaced
cleanly for tool / route callers via ValueError."""
from fabledassistant.services.events import create_event
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
await create_event(
user_id=1, title="Bad",
start_dt=start, end_dt=end_before,
)
@pytest.mark.asyncio
async def test_update_event_preserves_duration_when_only_start_changes():
"""Sliding semantics: when the user moves an event by changing only
start_dt, the existing duration_minutes is preserved as-is. The new
effective end_dt slides forward with the start. This is a behavioral
upgrade vs. the old end_dt model, where moving start past the
stored end made the event 'go backward in time'."""
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls, \
patch("fabledassistant.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
# Move start to 12:00; effective end becomes 13:00 automatically.
result = await update_event(
user_id=1, event_id=1,
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
)
assert result is not None
# duration_minutes was NOT touched; mock_event still has 60.
assert mock_event.duration_minutes == 60
@pytest.mark.asyncio
async def test_update_event_clearing_end_dt_clears_duration():
"""Passing end_dt=None on update is the documented way to clear the
end (turn a timed event into a point event). The service must
translate that into duration_minutes=None, not leave the prior
value in place."""
mock_event = _make_mock_event(duration_minutes=60)
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls, \
patch("fabledassistant.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
await update_event(user_id=1, event_id=1, end_dt=None)
assert mock_event.duration_minutes is None
@pytest.mark.asyncio
async def test_list_events_includes_point_event_in_window():
"""A point event (duration_minutes=None) surfaces when its start
is in the window. Replaces the prior 'corrupt end_dt' regression
test — the duration model can't represent that state, but the
same code path is exercised here for point events."""
mock_event = _make_mock_event(duration_minutes=None)
# Point event in the upcoming window
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = None
mock_event.to_dict.return_value = {
"id": 1, "title": "Point",
"start_dt": mock_event.start_dt.isoformat(),
"end_dt": None,
"duration_minutes": None,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert len(results) == 1
assert results[0]["id"] == 1
@pytest.mark.asyncio
async def test_list_events_excludes_timed_event_that_already_ended():
"""A timed event whose start + duration is before the window must
NOT surface. Verifies the Python-side refinement actually works
against the coarse SQL prefilter."""
mock_event = _make_mock_event(duration_minutes=60)
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
mock_event.to_dict.return_value = {
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert results == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tools_calendar_always_available(): async def test_tools_calendar_always_available():
"""Calendar tools must appear in get_tools_for_user even without CalDAV.""" """Calendar tools must appear in get_tools_for_user even without CalDAV."""