Writing the query-count test that task 14 owed showed the growth was real: one
resource cost 10 queries, six cost 33 — about five per resource, because the
available-minutes figure walked each resource's calendar on its own.
Holidays, tenant overrides and branch hours are identical for every resource in
a report, so they now load once outside the loop; shifts and exceptions load for
all resources in one query each. The batched path is a new method rather than a
change to rawAvailability, which the booking engine also calls. The test pins
the shape of the growth, not an exact count.
Also landed:
- app:segment:seed-templates with beauty, dental and physio presets. Building
four segments and their requirements by hand is the first thing a new clinic
must do and the most tedious; this gives them something to edit instead of an
empty page. It refuses to touch a service that already has segments unless
--force, and it will not invent resource types the tenant never defined.
- book-all is all-or-nothing, proven rather than asserted: with a calendar open
one day a week and a 1-2 day protocol gap, session one finds a slot and
session two cannot, and every session must come back planned.
- credit_refundable: false takes the credit back with a negative adjustment and
deletes nothing — the ledger stays append-only.
- the segments editor has frontend tests, including that it sends back what the
user sees and renders read-only without the permission.
useBranches now returns [] for a non-array payload instead of throwing
"branches.map is not a function" and taking the page down with it.
BookingLocationsScanTest built a Clinic around a Doctor loaded from a different
manager, which Doctrine treats as a new entity; it flushed fine most runs and
failed on cascade in others. It now loads the doctor from the same manager.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The no-show records existed and drove the risk tag, but the patient's file
never showed the number behind it — the operator saw a tag with no evidence.
GET /patient/{uuid}/no-shows returns the count, the policy threshold and the
window, and the banner shows it only when the count is above zero: "0 no-shows"
on every healthy patient's file is an accusation nobody made.
The badge does not block anything and the docs say so. Blocking is an
eligibility policy from task 09 built on the same tag; a clinic that wants to
see the risk but still take a deposit must not have to switch the count off.
A test pins that a tagged patient still books.
Both report pages kept their range and branch in local state, so going back
from a resource lost the report and a shared link opened someone else's
default. They use useUrlState now, like every other list in the panel.
Three tests that were owed:
- the service-level cancellation policy beats the tenant one with no blending,
checked through the number that comes out rather than through the resolver
- a patient over the no-show threshold can still book
- occupied includes the waiting segment while active does not — if those two
came back equal the whole utilization report would be pointless
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:
- AppointmentCompleted fires from both status-change routes, after the row is
saved. A rejected transition or a version conflict leaves no event; otherwise
the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
confirm plus a cancel, and a consumer that only hears the cancel messages a
patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
as audible as capacity going away, or the resource reads as permanently taken.
Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.
Panel work that these paths made reachable:
- Cancelling from the appointment page now goes through the policy-aware
endpoint and shows the penalty preview before the confirm, so the operator
does not discover the patient's penalty after the fact. The cancellation
service writes the timeline entry itself and accepts a reason, which that
path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
hold steps are identical and only the final step differs. The doctor picker
is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
list is not an error, it means the appointment is slot-based, and that is
exactly what gates the resource-mode reschedule button.
AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 07 through 13 each changed something the rest of the system might want
to know about, with no contract for saying so. And task 05 shipped a powerful
segment editor with no feedback on whether a clinic defined its segments right.
Events
- A closed list of names, because a consumer branches on the string and a
one-letter typo would produce an event nobody hears and no error either
- Payloads carry uuids and scalars only; non-scalars are dropped, not
serialised, so a consumer always fetches fresh rather than reading a stale
detached entity
- record() deliberately does not flush: the event row commits with the change
it describes, so a rolled-back transaction leaves no event behind. A test
pins exactly that
- app:events:publish drains the outbox; five failed attempts park a row with
its error rather than deleting it, because a silently dropped event is a
loss with no trace. app:events:prune only ever removes published rows
Reports
- Resource utilisation separates available, occupied and active minutes.
The gap between occupied and active is what exposes a bad segment
definition, and available is multiplied by capacity so a three-chair room
does not read as permanently over 100%
- A resource with no calendar reports utilization: null, not zero — dividing
by zero means something different from being idle
- Plan accuracy compares planned against actual duration per service and
flags both directions: running short wastes capacity that could have been
sold. Its row links straight to editing that service's segments, because a
report with no route to a fix does not get read
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>