18 SEPTEMBER 2026 · capExpertApp + capExpertAPI · project view, tab 3 and build-team

The screens, and what they call

The front end was already written for something close to this schema — it edits per day and per facility, then collapses to date windows only because the endpoint stores windows. Most of the work here is removing that round trip and letting each person carry their own hours.

How the current UI fitsalmost one to one

todaywith the schema
todayBuild-team edits one row per facility per day — site, hours, people — then collapses the days to a window per person per site on savewith the schemaEach row is a capture_visits row; each person on it is a capture_visit_members row with their hours. The collapse is deleted.
todayOne hours figure per facility row, shared by everyone on itwith the schemaAn hours field per person per row, defaulted to the visit’s hours. A person can do fewer hours than the facility is open. No time of day — decided.
todayThe roster table re-derives schedules by clustering overlapping windows and hashes a fake CAP-xxxx id; Edit and Delete toast “isn’t available yet”with the schemaReal capture_schedules rows with their CS- code; Edit and Delete call real endpoints.
todayThe preview hard-codes 8 hours per day, with a comment saying “until an endpoint supplies the real figure”with the schemacapture_visits.hours and each person’s hours.
todayBlackout days come from the intake’s single A7 rangewith the schemaFrom capture_project_blackout_dates, any number of rows; the DTO is already an array, so its shape does not change.

Screen 1 — the Scheduling Assistant tab?project=CP-…

The project view’s third tab, where the team roster card is today.

Header card. Project code and name, the target window, one chip per blackout row (07 Oct–08 Oct, 10 Oct), and the daily cap.

Schedules table. The same columns the roster table has now: ID (CS-…), Facility (names with a +N overflow chip), Users (names, a crown on the lead), Durations (start → end), Actions (Preview · Edit · Delete). An empty state with a “Build team” call to action.

Preview sheet. The right-side sheet as today: a window summary (N facilities · M assigned users · D days), then one card per visit day with a Facility · Hours · Assigned users table, each person shown with their hours and the crown on the lead.

Screen 2 — build team?project=1

  1. Window. Start and end pickers; blackout days are struck out and skipped in the day rail, as now.
  2. Facilities. The project’s sites, minus those already visited under another schedule — R3 shown as a disabled option reading “in CS-…”. A picked facility is stored with its first visit day; until then it is a selection on screen.
  3. Days. Per day, one row per facility with planned hours; add and remove rows — R4.
  4. People. For each person on a row, an hours field defaulted from the visit. Beside the name a “remaining today: X h” chip, computed across all projects from the availability endpoint. The row turns red and Save is blocked when the hours would exceed the cap (“would reach 9 h > 8 h”) or the person is already on that facility that day.
  5. Save. Back to screen 1 with the new CS- row.

The dummy UIdeliverable 4

A self-contained mockup of both screens, built as one page with mock data shaped exactly like the four tables and client-side validators that mirror R3–R6, so the demo cannot show a state the schema would reject. It copies the app’s tokens — #414d6a ink on #f5f6f8 surfaces, #e9f0fd borders, the light-blue accent, the right-side sheet, the column table, the outline and primary buttons — and mimics both URL forms. It is specified here and built on request; it is not part of the repositories.

The API contractcapExpertAPI, primary role

Existing routes keep their paths and change their data source. New routes treat a schedule as a record.

routechange
routeGET …/team, team-summary, team-roster, roster, roster-groups, smart-picks, team-poolchangeRe-sourced from visits and members. DTOs unchanged; the lead label derives from lead_user_id / project_lead_id.
routePUT …/teamchangeCompatibility shim during phases 2–4: still accepts today’s payload, now writes visits and member rows, expanding an undated entry into one row per schedulable day with the visit’s hours. Retired once the front end uses the schedule routes.
routeGET …/scheduleschangeNew — schedules with facilities, distinct people, window and visit-day count.
routeGET …/schedules/:scheduleIdchangeNew — visits grouped by day, each with its members and their hours. Feeds the preview.
routePOST …/scheduleschangeNew — { leadId?, startDate, endDate, visits: [{ siteId, date, hours, members: [{ userId, hours }] }] }. A facility with no visits is not persisted.
routePUT …/schedules/:scheduleIdchangeNew — full replace in one transaction: soft-delete, then insert; the deferred cap trigger evaluates at commit.
routeDELETE …/schedules/:scheduleIdchangeNew — soft-deletes the schedule and its visits and member rows together; a live visit left behind would keep the facility locked to the dead schedule.
routeGET …/availability?date=&userIds=changeNew — per person: cap, booked, remaining and the facilities booked that day across all projects. Drives the chip; the cap comes from here so the front end never hard-codes 8.
routeGET / PUT …/blackout-dateschangeNew — the scheduler’s source of closures. The wizard’s A7 answer seeds the first row on intake submit.

Errors, mappeddatabase → 400

the database saysthe API answers
databasecheck_violation from the cap trigger — at COMMITapi400 with the trigger’s message (“booked 9.00 hours on 10 Feb, over the 8-hour daily cap”); caught around transaction.commit(), not only around the insert
databaseunique violation on capture_visit_members_visit_user_uniqueapi400 — “X is already on <facility> on 10 Feb — change their hours instead”
databaseexclusion violation on capture_visits_site_one_scheduleapi400 — “<facility> already belongs to schedule CS-…”
databaseunique violation on capture_visits_site_date_uniqueapi400 — “<facility> is already scheduled on 10 Feb”
databasecheck_violation from either guard triggerapi400 naming the date — blackout, or outside the window

Never ignoreDuplicates on any of these inserts: a swallowed violation is a silent duplicate row.