Calendar (yoyaku_calendar)¶
yoyaku_calendar is the public, end-user face: a calendar Form API element
that shows a resource's slots, lets a visitor pick a day and a quantity per
tariff, and - on submit - places the holds. That is all it does;
everything after (payment, validation) belongs to other modules.
flowchart LR
FEED[Availability feed<br/>/booking/availability/{resource}] --> JS[Calendar JS<br/>month grid, colored days]
JS -->|pick day → tariffs + qty| SEL[selection: tariffs:{id:qty}]
SEL -->|submit| BK[SelectionBooker]
BK -->|hold() per tariff| ENG[BookingManager]
The element¶
$form['calendar'] = ['#type' => 'yoyaku_calendar', '#resource' => $resource_id];
It renders a month grid colored by availability (green = available, orange =
partial, red = full), navigable month to month. Clicking a day reveals that
day's slots; each slot lists its tariffs with the current availability
("12 left") and a quantity input. The element's value is
['selection' => '{"tariffs":{"<id>":<qty>}}'].
Embed it in any form, or use one of the two shipped no-code surfaces (see
Using it below). As a form step in a booking workflow it is driven
by yoyaku_webform; after submit the holds are placed by
SelectionBooker.
Using it¶
You do not need to write any code to put the calendar in front of visitors - pick one of two surfaces (full walkthrough in Getting started):
- Block - place the Yoyaku booking calendar block (Administration › Structure › Block layout) and choose the resource it shows. The block wraps the element in a form and places the holds on submit.
- On a node (with
yoyaku_node) - the module adds a Booking resource reference to content; set it on a node and display that field with the Booking calendar formatter to render the resource's calendar on the page.
A visitor navigates months, picks a day (or several, per the selection mode), enters a quantity per tariff and submits; each selection becomes a held booking and a "Held N booking(s)" message confirms it. Grant View booking availability and Place booking to the anonymous role for public, sign-in-free booking. What happens next - operator confirmation, payment, an order - is the job of other modules (see Confirm bookings).
Selection modes¶
A resource's calendar_selection sets how many days a visitor may pick:
- single (the default): exactly one day.
- multi: several separate days, not necessarily consecutive.
- range: a contiguous span of days (no gaps).
selection_min_days and selection_max_days bound the day count for multi
and range; single is always capped at one.
range enforces that the picked days are consecutive, but it does not
impose a minimum of its own: a range resource with no selection_min_days
accepts a single day. That is by design, so a contiguous-only resource can
still be booked for one day (a meeting room hired for the day, say). For an
overnight stay, set selection_min_days (for example 2) so a one-day "range"
is rejected; the Holiday Cottage in the example module does this, while the
Meeting Room leaves the minimum unset on purpose.
Availability feed¶
GET /booking/availability/{yoyaku_resource}?month=YYYY-MM returns per-day
status + per-slot/tariff remaining (computed via
BookingManager::findAvailability()). Each slot also carries its own available
and status, which is what Yoyaku Node's Upcoming slots list reads: the list is
kept in the page cache with the page, and its Availability column is filled in
the browser from this feed, so the count a booker reads is never older than the
feed's lifetime. It is cacheable on purpose - a lifetime
of yoyaku.settings.availability_max_age seconds plus the tags of everything
availability is counted from (never the volatile booking list) - so a month
opening to a thundering herd doesn't recompute per request. Display is
best-effort; the hold() re-checks capacity authoritatively, and the engine's
fail-fast pre-check spares the lock once a slot is full.
Cacheable is not open. The route carries the view booking availability
permission and the same _yoyaku_booking_channel requirement the booking pages
do, so a front is served a month only for a resource it offers, and a resource
withheld from it is refused rather than published. Nothing grants that
permission on install: a public, sign-in-free site grants it to the anonymous
role, the same deliberate step place booking needs. Whether the anonymous role
holds it also decides the cacheability: public when it does and private when
it does not, because only a document anyone may already fetch gives nothing away
in a shared cache. See Booking channels for the rest of
what a front withholding a resource narrows.
Those tags are asked for rather than listed here: AvailabilityCacheTags
collects what core counts and what every module implementing
AvailabilityCacheTagsInterface adds, so a feature that joins the arithmetic
later cannot be left out of the invalidation. Bookings are deliberately absent,
because tagging them would empty the cache several times a second and it would
be worth nothing exactly when a rush arrives. The lifetime is what absorbs
them, and it is shared with the seat map so the two surfaces cannot be tuned
against each other by accident.
The per-slot booking page¶
/booking/slot/{yoyaku_slot} offers one quantity control per tariff, grouped into
panels, with what is left beside each. Nothing on the page differs from one
visitor to the next, which is what lets a browser and a proxy keep it: it is
served as a shell of empty cards and fills itself in from three places.
| What | Where it comes from | How long it may be kept |
|---|---|---|
| What each offer is called, covers and costs, and how the page is arranged | rendered into the page | until an operator changes something (tag-backed, no lifetime) |
| What is LEFT of each offer, and the areas on offer | GET /availability/slot/{slot}/offers |
yoyaku.settings.availability_max_age |
| What THIS booker holds | the yoyaku_held cookie the engine already writes |
no request at all |
That third row is the one worth reading twice. The engine writes what a basket
holds into a cookie for every client surface to read, so the page fills its
steppers from it: a booker returning to a slot they have already booked on
costs Drupal nothing beyond the shared document. The one thing the cookie
cannot answer for is what an offer is CALLED, which only matters for a tariff
withdrawn from the front while a basket still holds a booking at it, and that
is the only case worth a request: GET /booking/slot/{slot}/basket answers
it, and the page clones an empty card from a <template> for it.
The cookie also says when the booker's order is locked, by a payment or
by a workflow's Lock order action: it is no longer the basket the page can
change, so the page would otherwise show nothing held while the places wait.
The engine writes {"c":<deadline>} from the request that locked the order,
the time its lock lapses at (0 for none). The page then says so and makes
every control inert, since the server refuses to hold into a locked order. It
adds a link back to the order only when the booker has somewhere to go: a
module that knows says so in the cookie through
hook_yoyaku_locked_order_cookie_alter(), and gives the address in the
lockedOrderUrl setting. yoyaku_orchestra_payment sets "r": 1 while the
order's workflow waits on the booker's payment, so an order locked for any
other step, an operator's review say, offers nothing to click.
Once the deadline has passed the page asks the basket whether the order is
still locked, because the cron sweep that unlocks a lapsed lock cannot rewrite
the cookie. Nothing runs for it on any other request.
Both documents the page fetches are gated as the page is.
GET /availability/slot/{slot}/offers needs place booking, or
administer yoyaku for an operator who books nothing, and
GET /booking/slot/{slot}/basket needs place booking; both carry the page's
own _yoyaku_booking_channel requirement. Neither publishes anything the page
does not.
The page is a controller, not a form, which is the same decision the place picker made and for the same two reasons. A form contributes a per-session token, and one per-session field keeps a page out of every cache however little else it holds. And a form has to be BUILT in order to be submitted, so claiming from it cost two page builds, one to process the submit and one to answer with, on a page a cache had already paid for once.
Claiming¶
POST /booking/slot/{slot}/claim takes a list, one entry per offer, each naming
its target, how many places are wanted and optionally the part of the house to put
them in. A run of stepper clicks therefore leaves as ONE request, the same way a
run of clicks on the plan does, and it answers with what is actually held
rather than what was asked for. The form it replaces echoed the booker's own
number back, so anything that wanted the truth had to fetch the page again.
Quantities are absolute, never deltas: a retried request, or one whose answer never arrived, must not hold twice.
Every hold still goes through SelectionBooker and the placement seam, under the
slot lock, exactly as it did from the form. This is the round trip around the
engine getting out of the way, not the engine changing.
Checkout is not offered here. The cart page is where a booker accepts what there is to accept about the basket, and a second way to commit it is a way around that, which is the conclusion the place picker reached first.
The card an offer is drawn as¶
Each control on the page is wrapped in the offer card, which is
yoyaku_presentation's (yoyaku_offer, yoyaku-offer.html.twig, and the
yoyaku_presentation/offer library). The card names the offer, the places it
covers, what is left, the price, whatever a module adds to the price, and what
is holding the offer back, around the quantity control as a real form child. A
site restyles an offer by overriding that one template.
It lives with the shared widgets rather than with this page because more than
one surface offers things, and because a module that adds something to an offer
has to be able to declare the module owning the hook it preprocesses: that is
how yoyaku_booking_fee mentions a fee beside the price without depending on a
calendar. What stays here is the page: what it says about the slot, which controls
are offers, and how a run of clicks on them leaves as one claim.
The card is rendered with its availability line, its limits region and its message region present and EMPTY. A live region created at the moment it has something to say is announced by nothing, and a page that printed "Full" could not be kept past the moment the house filled.
How long a sentence stays in that region once something has been said, and what
happens when there is a second thing worth saying, is the shared rule in
docs/steppers.md: the page asks the region to empty and never empties it, so a
message is never taken away by the booker's own next click.
What the page is led by¶
offer_display decides whether the page is led by the place grades or by the
tariff class, per resource through the field rule chain, with a site default
of by place grade.
- By place grade: one panel per set of grades, the prices for those places inside it, and the tariff classes keeping their own panels within a set that prices more than one. A module that has areas puts one place control at the top of each panel. One panel per SET, because a tariff pricing two grades is one offer with one quantity.
- By tariff class: one panel per class, and no place control at all.
That second point is the whole reason the arrangement is a setting rather than a preference. A control that governs a set of offers needs a heading those offers sit under; by tariff class there is none, so the only place left for it is a row above the whole page, divorced from the prices each control governs. An arrangement that cannot carry the choice legibly does not offer it.
A resource whose tariffs price no grades is left alone either way: there is nothing to lead with, so it keeps the panels it had. Nor does class mode group anything on a site that sets no tariff classes: the panels are per class, so with no classes the prices are a flat list.
offer_panels decides whether those panels start open or collapsed, on
the same chain, and applies to whichever panels the arrangement draws. Open by
default, and every one of them: opening the first alone asks a booker to guess
that the others hold prices at all, and there is nothing special about the first
except that it comes first.
The calendar owns the arrangement but knows nothing about areas. It publishes which
grades each offer covers and which panel belongs to which set of them, through
hook_yoyaku_slot_booking_alter (the page, where a module puts an empty control)
and hook_yoyaku_slot_offer_state_alter (the moving half, where the options and
the room in each area arrive). The choice then travels on a claim as data the
calendar never reads. One control per
panel rather than one per price, because two would let the full price go to one
area and the youth price to another, splitting the party across the house. See
Asking for an area.
What it does not do¶
No payment, confirmation, validation or orders - it produces held bookings
and stops. Permissions: view booking availability (the feed) and
place booking (the holds), both grantable to anonymous for public booking.