feat(events): replace end_dt column with duration_minutes (#160)
Structural fix for the "end before start" bug class observed on prod
2026-04-29. Bad data became inexpressible at the schema level instead
of getting trapped in defensive read-path filters.
The hotfix that landed earlier today (94b169f) is reverted by the
preceding revert commit; this commit supersedes it cleanly with a
proper data-model change.
## Schema (migration 0043)
- Add `duration_minutes INTEGER NULLABLE` column on `events`.
- CHECK constraint: ``duration_minutes IS NULL OR duration_minutes >= 0``.
- Backfill from existing `end_dt`:
- end_dt valid (end > start) → duration_minutes = total minutes
- end_dt == start → duration_minutes = 0 (zero-duration point)
- end_dt NULL or end_dt < start → duration_minutes = NULL
(the corrupt prod row collapses cleanly to a point event)
- Drop the `end_dt` column. The wire format is preserved — `to_dict()`
emits `end_dt` as a derived `start_dt + duration_minutes`. Existing
API consumers (Flutter app, web frontend, CalDAV sync) keep
receiving the same response shape; they just no longer have a way
to PUT a stored `end_dt` that disagrees with `start_dt`.
## Service layer
- `Event.end_dt` becomes a `@property`. Setting it would require a
setter we deliberately don't define — writes always go through
`duration_minutes`.
- `_normalize_duration` is the single source-of-truth for input
reduction. Accepts (start, end_dt, duration_minutes), returns the
canonical `duration_minutes`, raises `ValueError` for negative
durations, end-before-start, or end/duration disagreement.
- `create_event` and `update_event` accept either `end_dt` or
`duration_minutes` for ergonomic compat; both convert via
`_normalize_duration`. Update validates the post-update state when
the patch includes either.
- `list_events` filter is simpler now: a coarse SQL prefilter
(`start_dt <= date_to`) plus Python-side refinement using the
derived `end_dt`. Avoids Postgres-specific interval arithmetic in
the WHERE clause; refinement runs over a per-user result set so
there's no scan-cost concern at personal scale.
- Recurring-event expansion uses `event.duration_minutes` directly
instead of computing `end - start`. No more negative-timedelta
hazard.
## CalDAV sync (incoming + outgoing)
- `caldav_sync.py` (pull) and `calendar_sync.py` (Radicale upsert)
both convert iCal `DTEND` → `duration_minutes` on the way in.
Outbound iCal still emits `DTEND` as `start_dt + duration_minutes`
via the model's derived property. iCal interop is unchanged.
## Behavioral upgrade for `update_event`
Pure end_dt model: moving start past the existing end_dt would either
silently corrupt or hard-reject. Duration model: the duration is
preserved by default, so moving start slides the effective end
forward — which is what users mean when they "move" an event.
Explicit clear is still possible via `end_dt=None`.
## Tests
`tests/test_events_service.py`:
- 6 new `_normalize_duration` unit tests (sugar conversion, zero
duration valid as point event, end-before-start rejected, negative
duration rejected, inconsistent end+duration rejected, none → None)
- New behavioral test: `update_event` preserves duration when only
start_dt changes (sliding semantics)
- New: clearing `end_dt=None` on update collapses to point event
- New: list_events surfaces a point event in the upcoming window
- New: list_events excludes a timed event whose effective end has
already passed
- Existing mock-event helper updated to use `duration_minutes`
instead of stored `end_dt`.
44 event-related tests pass; ruff clean.
## Out of scope (separate task)
Fable #161 — `find_events_by_query` returning multiple matches and
silently picking matches[0]. The exact root cause of how event id=2
got mutated in the first place; orthogonal to the storage model.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user