2026-07-06 ADR-018 Step 3.5 Calendar PRD Section 5-7 For Matt and Zareef
Merged to main — PR #106

The calendar backend is CRUD-ready

Four slices, one branch, one table modified. Everything the Step 3.5 minimum-viable calendar UI needs to read, create, edit, cancel, and conflict-check events already exists in the API. This page is a map of what's there so the frontend build can reach for it directly.

The short version

All four slices of the Step 3.5 Calendar Backend plan landed on main via PR #106 (merge commit dade462) — including the optional Slice 4 stretch work. It will go out with the next deploy to Railway's shared mvp environment.

That means: full read/list with filtering, manual create, manual edit, soft-cancel, and a pre-flight conflict check are all backend-complete. What's missing is entirely on the frontend side — there is no calendar grid, no event form, no conflict-warning UI. That's issue #92, and it's the actual remaining work for the July demo.

One row, every kind of event

Case-scoped bookings, non-case-scoped blocks (maintenance, training), and workflow-created events are all the same scheduled_events row. Category is now an explicit, database-enforced column — not something the frontend has to infer from whether case_id is null.

FieldTypeWhat it's for
categoryenum newcase_scoped / non_case_scoped. A DB check constraint enforces the pairing with case_id — a case-scoped row can't have a null case, a non-case-scoped row can't have one set. This can't drift.
event_typeenum11 existing values plus new graveside_service and reception. setup / cleanup are still there deliberately — their removal is deferred pending the Megan and Jeanette walkthroughs.
statusenumtentative, confirmed, plus new in_progress and completed, and cancelled.
starts_at / ends_attimestampValidated: end must be strictly after start.
buffer_before_mins / buffer_after_minsinteger (default 30/30)Turnaround time around the event. Metadata on the row, not a separate event — conflict detection reads these directly.
workflow_module_instance_iduuid, FKSet when a workflow module created the event; null for manual events. This is how the frontend can tell "the engine booked this" from "a director booked this by hand." (ADR-028.)
created_by_id / updated_by_id / cancelled_by_iduuid, FK newOn-row forensic columns for query-friendly UI display ("cancelled by Sophie at 3:40pm") without joining into the audit trail. Null for seed-created rows.
cancelled_at / cancellation_reasontimestamp / text newPopulated on soft-cancel. The row is never deleted.
title, notes, procedure, expected_attendees, tasksmixedDisplay and prep-list content — unchanged from before this plan.

Everything a calendar UI can call today

One controller, six actions. All under /api/v1/scheduled_events.

EndpointDoes
GET /scheduled_events List, filterable by case_id, event_type, status, starts_after / starts_before, resource_id, and new branch_id (joins through resource assignments to resources.branch_id — this is what the branch switcher filters on). Resources and director are now included on this default response, not just on show — a week view can render without a second round-trip per event.
GET /scheduled_events/:id Single event, detail view.
POST /scheduled_events Create. Takes case_id (optional), event_type, starts_at/ends_at, title/notes/director/buffers, and resource_ids[]. Category is derived automatically from whether case_id is present — the frontend never sends it.
PATCH /scheduled_events/:id new Edit any field. Pass resource_ids[] to wholesale-replace resource assignments, or omit it to leave them untouched. case_id can't be changed through this endpoint — an event can't be moved between cases.
DELETE /scheduled_events/:id new Soft-cancel. Sets status to cancelled, stamps who/when, accepts an optional cancellation_reason. The row stays — nothing is destroyed, resource claims are simply freed.
GET /scheduled_events/conflicts Pre-flight check. Pass a proposed starts_at/ends_at and a set of resource_ids[]; get back the events that would conflict. See below.

Buffer-aware, not just a time overlap

A resource isn't just busy during the event — it's busy during the event plus its turnaround buffers. The conflicts endpoint checks the effective window, not the raw one.

Existing booking
buffer
Chapel A — confirmed
buffer
Proposed booking
would conflict

Even though the two events' raw start/end times don't overlap, the proposed booking starts inside the existing event's after buffer — the crew hasn't turned the room over yet. Scheduling::DetectConflicts catches this by construction; it's not new logic, but the /conflicts endpoint (already routed) is what makes it callable from a create/edit form before the frontend commits a booking.

What this is not

This is a yes/no check against resources you name. It is not the Automatic Scheduling engine — there's no slot suggestion, no "next available time," no path-to-yes explanation, no priority-aware ordering across competing bookings. That's a separate PRD and a separate future build. What ships here is exactly enough for a form to say "that room's not free" before the director hits save.

Every write is already accounted for

Nothing extra for the frontend to call. Create, edit, and cancel all write their own trail automatically, at two levels.

Layer 2 — forensic (the audited gem)

  • Already active on every scheduled_events write — unchanged by this plan.
  • Field-level, who/when/what-changed, on every create/update/cancel.
  • This is the durable record for "what did this row look like before."

Layer 3 — case narrative new

  • scheduled_event_created, scheduled_event_modified, scheduled_event_cancelled — case-scoped events only.
  • The "modified" entry names which fields changed, not the values — that stays in Layer 2.
  • The "cancelled" entry records whether a reason was given (yes/no), not the reason text itself — free text stays out of the human-readable narrative by design.
  • Non-case-scoped events (a maintenance block) rely on Layer 2 alone — there's no case activity feed for them to appear in.

The demo data tells a real story

The seed narrative was rewritten to route every event through the same Scheduling::CreateEvent call the API uses — so what you see after rails db:seed is exactly what a director would produce by hand. It's worth walking through before the demo, because two of these are deliberate.

Case 1

Eleanor Whitfield — Kearney, closed

A full three-event arc: viewing, funeral service, cremation disposition, all in the past. Good for showing what a completed case's calendar history looks like.

Case 2 + 3

Thornton memorial vs. Liu viewing — deliberate conflict

Thornton's tentative memorial is seeded to overlap Liu's family viewing in Chapel A, on purpose (it's flagged in the event's own notes). This is the one to demo against the /conflicts endpoint — it's a live, unresolved double-booking sitting in the data right now.

Case 7

Harper — exercises both new event types

A three-event chain: chapel service, then a reception (Chapel B, repurposed) immediately after, then a graveside committal the following week. This is the one to show if you want graveside_service and reception on screen.

Case 6

Chen — Earth's Option branch

Arrangement, viewing, chapel service, all in Victoria Chapel. Good for demoing the branch filter — switch branches and the calendar should show only this arc.

Non-case

Chapel A — quarterly HVAC maintenance

Category non_case_scoped, type blocked, no director. This is the one to show if you want to demonstrate that not everything on the calendar belongs to a case.

Note

Affordable Cremation and Burial has zero seeded events

That branch doesn't have seeded resources or staff yet, so no events were authored on it — surfaced deliberately rather than faked. If the demo needs that branch populated, it's a seed-data ask, not a backend one.

What's actually left to build

Short version: nothing on the backend blocks the demo. Everything below the line is frontend — issue #92, Matt and Zareef's call on how much of it makes the cut.

Ready to call today

  • List/filter events by branch, case, type, status, date range
  • Create a manual event with resources + buffers
  • Edit any field, replace resource assignments
  • Soft-cancel with an optional reason
  • Pre-flight conflict check on proposed times
  • Audit trail on every write, automatically

Needs frontend work (#92)

  • Actual week/day/month calendar rendering
  • Create / edit event forms
  • Resource + director pickers
  • Surfacing conflict warnings in the UI
  • Cancel confirmation + reason capture

Explicitly out of scope

  • Automatic scheduling engine (slot-finding, path-to-yes)
  • Drag-to-reschedule
  • Recurring events
  • Print / run-sheet view
  • Per-resource default buffers (Resources PRD Section 5.4)
  • setup/cleanup type removal