Skip to content

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.