Problem/Motivation
Appointment scheduling is a named axis in Use cases (surface: appointment / next available, bookable shape: a point in time plus a duration) but nothing implements it. A site that wants what Calendly offers, a person publishing their open times and a visitor picking one, cannot express it today.
The only tool available is the admin slot generator (GenerateSlotsForm in yoyaku_ui). It creates exactly one slot per selected weekday spanning the whole daily window, so a 09:00 to 17:00 day becomes a single eight hour slot rather than a grid of bookable appointment times. It has no duration, it stores no standing rule, and it is capped at a maximum span, so an operator has to come back and run it again by hand forever.
Materializing a rolling horizon of appointment slots is the obvious answer and it is the wrong one. A single host with open weekday hours and a 30 minute duration is roughly 16 slots a day, about 960 rows for a 60 day horizon, of which nearly all will never be booked, all needing a cron to advance, prune and reconcile. yoyaku_placement already faced the same choice and rejected it: a Configuration does not clone the venue's places per event, it keeps one set of places and subtracts what it closes.
Proposed resolution
Add an appointment layer (submodule) where availability is computed from a rule and the slot row is created on the first booking of that time. The engine is untouched: holdGroup() still receives a real slot row to lock.
- An availability rule carrying weekly open hours, the appointment duration, a scheduling horizon (how far ahead a visitor may book), the timezone the hours are expressed in, and the capacity or tier template a materialized slot is born with.
- Closures as a positive record of absence, following the placement precedent exactly: the entity stores only what it closes and the availability read subtracts it (see
Configuration::getClosedSectionIds()and the NOT IN condition inPlaceAvailability). Sections are a finite enumerated set so placement can store references and derive the complement; time is unbounded, so a closure stores the closed span directly (a date, or a date plus a from and to time). Keep theclosed_naming so this reads as the same pattern. - A virtual availability read: rule occurrences, minus closures, minus what is already taken. Nothing is stored to display an offer.
- Existing slot rows override the rule for their time. Once 10:00 has a row, availability for 10:00 comes from the row and is never recomputed from the rule, otherwise capacity is double counted.
- The slot is materialized inside the hold transaction on first booking, seeded from the rule (capacity, or a slot category per tier, the way the generator seeds them from the resource catalog today).
- Make
(resource, start)unique. It is a plain index today inBookingSlotStorageSchema. Without uniqueness two visitors booking the same time concurrently each insert their own slot row, each locks their own, and both succeed: the row lock protects a slot, nothing protects the identity of a slot. Worth doing on its own merits, since duplicate slots for one resource and start are already a data error nothing prevents. - An appointment surface: pick a day, then a column of start times, rendered in the visitor's timezone. The shipped calendar element is a month grid with a quantity per category, which is the wrong shape, but the feed and the selection to hold path are reusable underneath.
- Two constraint plugins: a buffer before and after an appointment, and a maximum number of appointments per day. Both have to consider neighboring times on the resource, which the per slot availability read does not do today.
- Group appointments work by construction. Capacity lives on the slot, so a rule with capacity 8 gives the first visitor a row with capacity 8 and everyone after takes the engine's ordinary path. Several separate bookings on one time (each with its own booker, confirmation, cancellation and ticket) and one booking for several people (quantity on the line) are both already supported.
- Rescheduling: cancel and rebook behind a signed link, the same shape as the existing reseat link. Self cancellation with a deadline already exists on the resource.
- Timezone and DST: each occurrence must be resolved in the rule's timezone at read time, never offset from a stored timestamp, so a change of offset does not drift the local start time. The existing generator gets this right by calling setTime() on a per day cursor, which is the precedent to follow.
Closing a day when nothing is materialized
Closing a span is a closure record, not a deletion, which is what makes it work in a model where most times have no row. It also does the right thing where rows do exist: any row already inside the closed span is deactivated (its status flag), which the engine already honors, since SlotBookable refuses a hold on an inactive slot. Bookings that were already taken in that span survive, so the UI can list them and ask whether to cancel and notify or to offer a reschedule, instead of a delete silently orphaning them.
The same reasoning applies to a materializing generator, which is worth recording here because it is not obvious: absence of a row cannot mean closed in a system whose generator exists to create rows. So closure is a flag, and regeneration is additive only (create the times that have no row, never touch or delete one that exists). With those two rules an explicit regeneration over an already generated window needs no policy decision and no confirmation step, because closed times already have rows and are skipped.
Relationship to a recurrence layer
Independent, and neither blocks the other. A recurrence layer stays what Recurrence describes: materialized inventory for published recurring offers, where a slot genuinely is an artifact with its own price, tiers and per occurrence overrides, and where a rule edit deliberately does not reach dates already on sale. The appointment layer needs the opposite: a host editing their working hours expects tomorrow to reflect it. That asymmetry, volatile rules versus stable published inventory, is the reason these are two layers and not one.
The appointment layer needs no cron: no horizon to advance, no watermark, no pruning.
Child issues
In dependency order:
- #3613578: Make the slot resource and start pair unique (the prerequisite: a latent data integrity fix on its own merits, and what makes materializing on booking safe)
- #3613617: Add appointment reasons and move the appointment duration onto them (what the visitor is booking, and the length that follows from it, so the availability read is born taking it)
- #3613579: Add the appointment availability rule and its virtual availability read (the core of the layer)
- #3613580: Add closure records so a day or a span can be closed (follows the venue configuration precedent)
- #3613581: Materialize the appointment slot inside the hold transaction (depends on the two above)
- #3613582: Add the appointment picker surface (the public surface)
- #3613583: Add buffer and daily maximum appointment constraints (touches the availability read as well as the constraint plugins)
- #3613584: Add rescheduling of a booking through a signed link (cancel and rebook in one transaction)
The three follow-ups listed under Out of scope are deliberately not filed yet: their shape depends on decisions taken in the children above.
Remaining tasks and open questions
- Where the rule lives: a field set on the resource, on the resource type, or its own entity. Bearing on the workflow anchor question already open in the resource type design.
- Whether a closure belongs to the rule or stands alone. Standalone lets one person close an afternoon across every appointment type they offer at once, which is the common case.
- Confirm the capacity semantics: raising a rule's capacity does not retroactively change rows already materialized. Consistent with the additive only regeneration rule above.
- What the UI offers for bookings caught inside a newly closed span: cancel and notify, or a reschedule invitation.
- Whether tiered group appointments are worth supporting in the first pass or whether the rule seeds a plain capacity only.
- Whether the appointment surface can share the availability feed controller or needs its own, given it is a day and time query rather than a month grid.
Out of scope, as follow-ups
- Two way external calendar sync (the real differentiator, and the largest piece): reading busy times from a connected calendar so an appointment cannot be offered over an existing commitment, and writing the booked appointment back. Busy times fit the closure seam, so imported busy spans and manual closures share one computation path. Writing the event belongs in the workflow.
- Calendar invitations attached to the confirmation, which fits the existing notification attachment seam.
- Round robin and collective scheduling across several hosts. This needs the criteria based availability read that Use cases already flags as the engine's one missing generalization: a per resource month feed cannot answer "who is free on Tuesday".
Data model changes
A unique key on slot resource and start, replacing the current plain index. Everything else is new storage in the new submodule.
API changes
None to the engine. Slot creation moves earlier in the hold for this surface, inside the same transaction, which is why the uniqueness key is a prerequisite rather than a cleanup.
User interface changes
A rule editing screen (weekly hours, duration, horizon, timezone, capacity), a closure action on a day or a span with the caught bookings listed, and the public appointment picker rendered in the visitor's timezone.
Issue fork yoyaku-3613576
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #3
mably commentedComment #4
mably commentedComment #5
mably commented