diff --git a/alembic/versions/0043_event_duration_replaces_end_dt.py b/alembic/versions/0043_event_duration_replaces_end_dt.py new file mode 100644 index 0000000..3fac32a --- /dev/null +++ b/alembic/versions/0043_event_duration_replaces_end_dt.py @@ -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") diff --git a/frontend/src/components/EventSlideOver.vue b/frontend/src/components/EventSlideOver.vue index 02bd149..4943a9e 100644 --- a/frontend/src/components/EventSlideOver.vue +++ b/frontend/src/components/EventSlideOver.vue @@ -1,5 +1,5 @@