@specfork/scheduling
Booking pages with availability, team round robin, calendar sync, payments and webhooks.
Scheduling
People and teams publish booking pages; others book time without an account. Behaviour derived from Cal.com's open-source code (Cal.diy, MIT), written fresh and stack-free. Not affiliated with or endorsed by Cal.com, Inc. The name is used only to credit the source.
| Area | Prefix | Covers |
|---|---|---|
| event-types.md | EVT | Bookable templates: durations, notice, window, limits, seats, confirmation |
| schedules.md | AVL | Weekly hours, overrides, time zones, time off, holidays |
| slots.md | SLT | Which start times are offered, buffers, limits, holds |
| bookings.md | BKG | Create, confirm, reschedule, cancel, seats, recurring, no-show |
| team-events.md | RR | Collective and round-robin team events, fairness, team permissions |
| calendars.md | CAL | Calendar and video connections, busy times, writing events |
| payments.md | PAY | Paid events, timeout, refunds, no-show fees |
| notifications.md | NTF | Emails and webhook events |
| public-pages.md | PUB | Profile, booking flow, manage booking, embeds |
All times are stored in UTC; all durations are whole minutes.
Bookings
Status: Draft · Prefix: BKG
Purpose
Create, confirm, reschedule and cancel a booking, and keep calendars and people in sync.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| BKG-1 | A booking has uid (opaque, unique, URL-safe), event_type, organizer (a user), attendees [{name, email, time_zone, phone?, locale, no_show}], start, end (UTC), location, responses, status, paid, from_reschedule? (uid), rescheduled (bool), cancel_reason?, cancelled_by?, reject_reason?, host_no_show, sequence (starts 0), ical_uid (kept across reschedules, CAL-11). |
create → uid of ≥ 16 URL-safe chars |
| BKG-2 | Statuses: awaiting_payment, pending, accepted, rejected, cancelled. Only these transitions: awaiting_payment → pending|accepted|cancelled; pending → accepted|rejected|cancelled; accepted → cancelled. |
accepted → rejected → refused |
| BKG-3 | Booking form: system fields name (required), email (required), phone (hidden), location, notes (optional), guests (list of emails, hideable), reschedule_reason (reschedule only); owner-added fields of type text, textarea, number, email, phone, address, url, select, multiselect, checkbox, radio, boolean, each with name (key for responses and prefill, [a-z][a-z0-9_]{0,39}, unique), label, required, hidden, options?, min_length?, max_length?. System fields other than name and email can be hidden, none deleted; hidden fields are never required. |
required custom field empty → rejected |
| BKG-4 | Field validation: emails are an addr-spec with a dotted domain; phones E.164-valid; email fields may have allow_domains/block_domains; guests de-duplicated and lower-cased; guests who are hosts of the event or the booker are dropped; each guest becomes an attendee named by their email. |
guest list with a duplicate → stored once |
| BKG-5 | Location: the booker's pick from EVT-14, required when several are configured (location_required); one configured → it; none configured → organizer's default video app (CAL-5), else a plain meeting link on the instance. |
event without locations → video link created |
| BKG-6 | Create re-checks, in order: event exists; duration (EVT-3); start in the future, notice and window; limits; booker limit (EVT-12); at least one host free per SLT, at a start on the slot grid. Codes: event_not_found, invalid_duration, in_past, too_soon, outside_window, limit_reached, booker_limit, slot_taken (busy or held), unavailable (no hours, time off, off-grid). |
two bookers race for 10:00 → one succeeds, other gets slot_taken |
| BKG-7 | At most one accepted booking per host per exact [start, end); enforced atomically. Cancelling or rejecting frees it. |
concurrent identical requests → one booking |
| BKG-8 | Same booker email, same event type, same start, while an earlier one is pending or awaiting payment → the earlier booking is returned, not a new one. | submit twice → same uid |
| BKG-9 | New status: paid event → awaiting_payment (PAY); else confirmation required (EVT-10) → pending; else accepted. An organizer rescheduling their own booking → accepted. |
free event, no confirmation → accepted |
| BKG-10 | On becoming accepted: create the event in the destination calendar and the video meeting (CAL), store their external ids, notify (NTF). Integration failures are logged and shown to the organizer; the booking stands. |
calendar API down → booking accepted, organizer sees warning |
| BKG-11 | Confirm/reject a pending booking: by the organizer or a team admin, or via single-use links in the organizer's request email (opening shows the booking, acting needs a POST; using either link voids both). Reject takes an optional reason. Confirming a booking that isn't pending → error; confirming into a slot taken per BKG-7 → slot_taken; confirming past a reached limit (SLT-13) → limit_reached. |
reuse a confirm link → refused |
| BKG-12 | Reschedule by booker (via uid link) or organizer: creates a new booking with from_reschedule, sets the old one cancelled with rescheduled = true, updates the same external calendar event (sequence + 1), carries over paid, attendees, responses, location and duration. |
reschedule → old cancelled, new links back |
| BKG-13 | Reschedule refused when allow_reschedule is false, the booking is cancelled or rejected, it has started, or a non-organizer is inside min_reschedule_notice. |
reschedule 1 h before start, notice 2 h → refused |
| BKG-14 | Organizer may "request reschedule": the booking becomes cancelled with a reason and the booker gets a link to pick a new time. |
manual: booker email has rebook link |
| BKG-15 | Cancel by booker (via uid link, no login) or organizer, until the booking ends; refused when allow_cancel is false; reason required per EVT-16. Deletes external calendar and video events. |
cancel after end → refused |
| BKG-16 | Seats (EVT-9): one booking per slot holds up to seats attendees, each with a seat uid to cancel or reschedule their own seat; a full slot → seats_full; the same email twice → already_booked. Removing the last seat cancels the booking. |
seats 2; third booker → seats_full |
| BKG-17 | Seated bookings show attendees to each other only if show_attendees (default false). |
manual: attendee email lists only themself |
| BKG-18 | No-show: after the start of an accepted booking, the organizer can mark or unmark any attendee or the host as no-show; marking before start → refused. | mark 1 min before start → refused |
| BKG-19 | Recurring events ({freq: weekly|monthly, interval, count ≤ 24}): one booking per occurrence sharing a series_id; every occurrence must be free; cancel one or all future. Seats and recurrence can't be combined. |
weekly × 4 with week 3 busy → whole series refused |
Out of scope
- Instant meetings, built-in video, recording, CRM routing, SMS, workflow automations.
Decisions
awaiting_paymentis its own status (the source used pending + unpaid, and let a host confirm an unpaid booking).- A recurring series is all-or-nothing at creation.
Calendars and video
Status: Draft · Prefix: CAL
Purpose
Read busy times from people's calendars, write bookings into them, and create meeting links.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| CAL-1 | A connection {provider, owner (user|team), secret (encrypted at rest), status: ok|invalid} per connected account. Calendar adapters: list_calendars, busy(from, to, calendars) → [{start, end}], create_event, update_event, delete_event. Video adapters: create_meeting → {url, id, password?}, update_meeting, delete_meeting. |
adapter contract tests per provider |
| CAL-2 | Ship at least: Google Calendar, Microsoft 365/Outlook, CalDAV; video: Google Meet, Microsoft Teams, Zoom, plain link. | manual: each connects |
| CAL-3 | Conflict calendars: a user picks which of their calendars count as busy; per event type a different set may be chosen. A connection with none picked contributes no busy time. | unpicked calendar busy → slot still offered |
| CAL-4 | Destination calendar: one per user, optionally one per event type (overrides); none set → first connected calendar. | event-type destination set → event written there |
| CAL-5 | Each user may set a default video app; Google Meet requires Google as destination, else the default video app (or plain link) is used instead. | Meet + Outlook destination → fallback used |
| CAL-6 | OAuth tokens refresh before expiry; a refused refresh marks the connection invalid, notifies the owner, and stops using it. |
revoked token → status invalid, owner emailed |
| CAL-7 | Busy query failure fails closed for that host (SLT-11); other hosts unaffected. | one host's provider down → other hosts still offered |
| CAL-8 | Busy queries pad the range by 24 h each side, then clip, so time zone edges aren't missed. | event at 23:30 UTC−10 day before → seen |
| CAL-9 | Every external event or meeting created for a booking is recorded {provider, external_id, calendar_id?, meeting_url?} and used to update or delete it later. |
cancel → external event deleted |
| CAL-10 | Video creation failure: organizer is told, the booking falls back to the plain link location if one exists, else keeps no link. | Zoom error → organizer email |
| CAL-11 | Events carry an iCalendar UID stable across reschedules, with SEQUENCE = booking sequence. |
reschedule → same UID, sequence 1 |
Out of scope
- Push sync channels, calendar caching, domain-wide delegation, CRM sync.
Decisions
- One failing calendar blocks only its own host; the source blocked the whole query.
Event types
Status: Draft · Prefix: EVT
Purpose
A bookable meeting template: how long it is, when it can be booked, and what the booker must give. All durations are whole minutes.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| EVT-1 | An event type is owned by exactly one user or one team (@specfork/teams) and has title (1–200 chars), slug ([a-z0-9-], 1–64), description?, length (1–1440), hidden (default false), position (display order, ascending, ties by creation). |
create with length 0 → rejected |
| EVT-2 | slug is unique per owner. |
second event /intro for same user → rejected; same slug for another user → ok |
| EVT-3 | durations?: list of allowed lengths that includes length; the booker picks one. A slot query or booking with a duration not in the list (or ≠ length when no list) is rejected. |
durations [15,30]; book 45 min → rejected |
| EVT-4 | slot_interval? (≥ 1): step between slot starts; default = the chosen duration. |
length 60, interval 30 → overlapping slots 09:00, 09:30 (SLT-6) |
| EVT-5 | min_notice (≥ 0, default 120): no slot starts before now + min_notice. |
now 10:00, notice 120 → first slot ≥ 12:00 |
| EVT-6 | buffer_before, buffer_after (≥ 0, default 0): protected time around each booking of this type (SLT-9). |
manual: see SLT-9 |
| EVT-7 | window: unlimited (default); rolling {days, calendar_days: bool} (last bookable date = today + days calendar days, or + days Mon–Fri days); rolling_available {days} (last bookable date = the days-th date after today with ≥ 1 free slot; fewer than days such dates within 61 days → today + 61); range {start_date, end_date} inclusive in the owner's zone. |
rolling 5 business days from Fri → last bookable day is next Fri |
| EVT-8 | limits.bookings {day?, week?, month?, year?} and limits.minutes {…} (total booked minutes); each set value must be ≤ every set value of a longer period. |
save day 5, week 3 → rejected |
| EVT-9 | seats? (≥ 1): several bookers share one slot (BKG-16). Null = one booking per slot. |
manual: see BKG-16 |
| EVT-10 | requires_confirmation (default false), with optional confirm_within {value, unit: minutes|hours}: confirmation needed only when the booking starts within that time of now; otherwise auto-accepted. |
threshold 24 h; book 3 days ahead → accepted; book in 2 h → pending |
| EVT-11 | pending_blocks_slot (default false): pending bookings of this type block their host's time, for every event type. |
pending 10:00, flag on → 10:00 not offered |
| EVT-12 | max_active_per_booker?: a booker (by email) with that many future accepted bookings of this type is refused, and shown their next booking to reschedule. |
max 1; same email books twice → second refused |
| EVT-13 | hidden events are left out of the public profile but stay bookable by direct link. |
hidden event → not listed; direct URL → slots shown |
| EVT-14 | locations: ordered list of {kind: in_person|link|phone_host|phone_booker|video, value?}; the booker picks one when there are several (BKG-5). |
two locations → booker must choose |
| EVT-15 | booking_fields: the booking form (BKG-3). |
manual: see BKG-3 |
| EVT-16 | allow_cancel, allow_reschedule (default true; restrict the booker, never the organizer); min_reschedule_notice?; cancel_reason: required_host (default), required_booker, required_both, optional. |
allow_cancel false → booker cancel link refused |
| EVT-17 | private_links: secret URLs {token, expires_at?, max_uses (default 1), uses} that open a hidden event; a link past expiry or max_uses is refused. |
link max 1; second booking via it → refused |
| EVT-18 | success_redirect_url?: after booking, the booker is sent there with booking details as query parameters. |
manual: book → redirected with uid |
| EVT-19 | schedule?: the schedule used for availability (AVL-5). |
manual: see AVL-5 |
| EVT-20 | locked_time_zone? (IANA): the booking page shows times in this zone and no zone picker (PUB-2). |
locked Europe/Berlin → times in Berlin, no picker |
Out of scope
- Offset start times, "optimised" slot alignment, auto-translation, instant meetings, managed event types pushed to members, recording, attribute-based routing.
Decisions
- Duration is validated on slot queries as well as on booking (the source validated only on booking).
- Limits use the owner's week start, not a fixed Sunday.
Notifications
Status: Draft · Prefix: NTF
Purpose
Who hears about what. Emails here; webhook delivery from @specfork/webhooks.
Host side = organizer + other hosts; attendee side = booker + guests.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| NTF-1 | Emails are sent from the instance's address with Reply-To = organizer (host-side mails: booker). |
manual: headers of a scheduled email |
| NTF-2 | accepted → host: scheduled; attendees: scheduled, with .ics attachment and manage links (reschedule, cancel). |
book → both sides emailed, .ics valid |
| NTF-3 | pending → host: request with confirm/reject links (BKG-11); booker: request received. Host reminders at 48 h, 24 h, 3 h after creation while still pending and before start. | pending 50 h → 2 reminders sent |
| NTF-4 | rejected → attendees: declined (with reason). cancelled → both: cancelled (with reason). rescheduled → both: rescheduled (old and new time). reschedule requested → booker: rebook link. | cancel → both sides emailed |
| NTF-5 | Seats: joining → organizer and the new attendee; cancelling a seat → organizer and that attendee only. | manual |
| NTF-6 | Payments: reminder (PAY-4), refund failed → organizer, no-show fee charged → booker. | manual |
| NTF-7 | Emails use the recipient's locale and time zone; the event type can turn off host-side or attendee-side emails. | attendee in Asia/Tokyo → times in JST |
| NTF-8 | Webhook events: booking.created, booking.requested, booking.rescheduled, booking.cancelled, booking.rejected, booking.payment_initiated, booking.paid, booking.no_show_updated, meeting.started, meeting.ended, time_off.created. |
each transition → one event |
| NTF-9 | Booking event payload: {uid, status, event_type {id, slug, title}, start, end, organizer {name, email, time_zone}, attendees, location, responses, from_reschedule?, cancel_reason?, reject_reason?}. |
payload matches schema |
| NTF-10 | meeting.started/meeting.ended fire at start/end of accepted bookings; cancelled or rescheduled bookings cancel their pending ones. |
cancel before start → no meeting.started |
| NTF-11 | Webhook subscriptions may be scoped to a user, a team or one event type. | event-type webhook → only that event's bookings |
Out of scope
- SMS, workflow builder, location-change and guest-added emails.
Payments
Status: Draft · Prefix: PAY
Purpose
Charge for a booking, refund fairly, and optionally hold a card for no-show fees.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PAY-1 | An event type may have price {amount (integer minor units, > 0), currency (ISO 4217)}, payment_mode: on_booking (default) or hold (save card, charge later), and a connected payment provider. A price without a provider → can't publish. |
price set, no provider → save refused |
| PAY-2 | Booking a paid event creates it awaiting_payment with a payment {uid, amount, currency, provider_ref, status: pending|succeeded|failed|refunded} and returns a checkout URL. |
book paid event → response has checkout URL |
| PAY-3 | awaiting_payment bookings block the slot until payment_timeout (default 30 min); unpaid at timeout → cancelled with reason payment_timeout, slot freed. |
no payment in 30 min → booking cancelled, slot offered again |
| PAY-4 | 15 minutes after creation, still unpaid → reminder email to the booker with the checkout link. | manual: email at +15 min |
| PAY-5 | Provider confirms payment (signed webhook, idempotent by provider_ref) → paid = true; status → pending if confirmation is required, else accepted. |
duplicate provider webhook → one transition |
| PAY-6 | refund_policy: never, always, or before {days, calendar_days: bool}. Applied when the booking is cancelled or rejected; rejection always refunds. |
policy 2 days, cancel 1 day before → no refund |
| PAY-7 | A failed refund notifies the organizer; the booking stays cancelled. | provider refund error → organizer email |
| PAY-8 | Rescheduling a paid booking needs no new payment. | reschedule paid booking → no checkout |
| PAY-9 | hold mode: the organizer can charge the no-show fee after marking the booker no-show (BKG-18); optional auto-charge when the booker cancels within {value, unit} of the start. |
auto-charge 24 h; cancel 2 h before → charged |
| PAY-10 | Seated paid events take one payment per seat; cancelling a seat refunds that seat per policy. | cancel one seat → one refund |
Out of scope
- Coupons, taxes, invoices, multiple providers per event.
Decisions
- Payment timeout (30 min) is new: the source had none, so unpaid bookings could block a slot forever.
Public pages
Status: Draft · Prefix: PUB
Purpose
What bookers see: profile, booking flow, confirmation, and managing a booking. No sign-in needed.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PUB-1 | /{username} and /{team-slug} (one namespace; route names such as app, api, booking, login are reserved and refused as usernames or slugs): name, avatar, bio, and visible event types (title, durations, description); private teams hide members. |
hidden event not listed |
| PUB-2 | /{owner}/{event-slug}: month calendar with available dates, time list for the chosen date in the booker's zone (detected, changeable unless EVT-20 locks it), 12/24 h toggle, duration picker when several. |
zone changed → times re-rendered |
| PUB-3 | /{user1}+{user2}/{duration} (dynamic group): collective booking across users who allow it; any user not allowing it → 404. |
user disallows → 404 |
| PUB-4 | Choosing a time places the hold (SLT-15) and shows the form (BKG-3); fields can be prefilled from query parameters with the field name. | ?email=a@b.c → email prefilled |
| PUB-5 | Success page /booking/{uid}: what, when (booker zone), where, who; add-to-calendar links (Google, Outlook, .ics); status shown for pending and awaiting payment. |
open uid → details shown |
| PUB-6 | The same page offers reschedule and cancel per BKG-12..BKG-15; for seats, by seat uid. | cancel from page → status cancelled |
| PUB-7 | Embeds: inline, pop-up button and floating button snippets for any event page; embedded pages post booking_successful to the parent window. |
manual: embed on a test page |
| PUB-8 | Pages are server-rendered, usable without third-party cookies, WCAG 2.2 AA, and work at 360 px width. | manual: axe scan, phone width |
| PUB-9 | Users can hide the product branding and set a brand colour and light/dark theme for their pages. | manual |
Out of scope
- Custom domains, SEO settings, routing forms.
Schedules
Status: Draft · Prefix: AVL
Purpose
When a person is available: weekly hours, date overrides, time off.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| AVL-1 | Every user has an IANA time_zone (required, chosen at signup) and week_start (monday|sunday, default monday). |
signup without zone → rejected |
| AVL-2 | A schedule has name, time_zone? (falls back to owner's), weekly rules {days: [0..6] (0 = Sunday), start, end} and date overrides {date, start, end}; times are wall-clock HH:MM; weekly rules need end > start, overrides end ≥ start (AVL-7). |
create weekly rule with end ≤ start → rejected |
| AVL-3 | A new user gets one schedule, Mon–Fri 09:00–17:00, as their default. The owner can make any of their schedules the default; a user always has exactly one. | new user → one default schedule |
| AVL-4 | Deleting the default schedule makes the oldest remaining one default; every reference to a deleted schedule is repointed to the default; deleting the last schedule is refused. | delete only schedule → refused |
| AVL-5 | Schedule used for a host on an event: event's schedule, else the host's per-event schedule (RR-2), else the host's default. | event without schedule → host default used |
| AVL-6 | Rules expand per local date in the schedule's zone; overlapping or touching ranges on a date merge; end 23:59 means midnight. |
09–12 and 12–17 → one range 09–17 |
| AVL-7 | Any override on a date replaces all weekly rules for that date; several overrides on a date give several ranges; an override with start == end makes the date unavailable. |
Mon rule 09–17, override Mon 13–14 → only 13–14 |
| AVL-8 | On DST change days, ranges keep their wall-clock times; their UTC instants shift. A time skipped by the change uses the offset before it (02:30 → 03:30); a repeated one the earlier instant. | Europe/Berlin, last Sun of March, 09–17 → 07:00–15:00 UTC |
| AVL-9 | Travel periods {time_zone, start_date, end_date?} read the default schedule's rules in the travel zone for those dates. |
default schedule 09–17 Europe/Berlin, travel to Asia/Tokyo → 09–17 Tokyo time |
| AVL-10 | Time off {start_date, end_date (inclusive, owner's zone), reason (free text), note?, note_public (default false), redirect_to_user?} makes those dates unavailable for booking. |
time off Tue → Tue has no bookable slots |
| AVL-11 | During time off with redirect_to_user, the public page shows the dates as away with a link to that user's page; note shown only if note_public. |
manual: away dates show redirect |
| AVL-12 | Optional public holidays by country, each holiday can be switched off; an enabled holiday on a working day acts as time off. | country DE, Oct 3 on a Fri → no slots |
Out of scope
- Restriction schedules (intersect with a second schedule).
Decisions
- Explicit time zone at signup; the source defaulted to
Europe/London. - Monday week start by default (ISO 8601); used by weekly limits (EVT-8).
Slots
Status: Draft · Prefix: SLT
Purpose
Which start times a booker is offered. The same rules re-check every booking (BKG-6).
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SLT-1 | Query: event, from, to (ISO 8601 instants, to > from, ≤ 62 days apart; slots starting in [from, to)), booker time_zone, duration?, reschedule_uid?. Result: map booker-local YYYY-MM-DD → list of {start (ISO 8601 UTC), seats_taken?}. |
to < from → rejected |
| SLT-2 | Per host: free = schedule ranges (AVL) − time off − busy times (SLT-8..11). | busy 10–11 in 09–17 → 10:00 not offered |
| SLT-3 | Hosts combine per RR-3; individual events have one host. | manual: see RR-3 |
| SLT-4 | Slots start at max(range start, now + min_notice) per free range (schedule ranges minus busy time widened per SLT-9), rounded up to the alignment step: the first of 60, 30, 20, 15, 10, 5 that divides the interval, else 1; alignment is to the hour in the booker's zone. |
free 09:05–12:00, 60 min → 10:00, 11:00 |
| SLT-5 | A slot is offered only if start + duration ≤ range end; next start = start + interval. |
free 09:00–10:00, 45 min, interval 30 → 09:00 only |
| SLT-6 | Interval < duration gives overlapping slots. | 60 min, interval 30, free 09–11 → 09:00, 09:30, 10:00 |
| SLT-7 | A start offered by two ranges appears once. | manual: two hosts, same start → one slot |
| SLT-8 | Accepted bookings where the host is organiser or attendee (by account email) are busy; awaiting_payment bookings are busy until their payment timeout (PAY-3); pending bookings are busy only per EVT-11, for their host; cancelled and rejected never. |
cancelled 10:00 → 10:00 offered |
| SLT-9 | Buffers: new event E (before B, after A) conflicts with booking X (before Bx, after Ax) iff [E.start − B, E.end + A] overlaps [X.start − Bx, X.end + Ax]. Buffers never shrink working hours. |
X 10:00–10:30 with A=0, E B=15 → 10:30 blocked, 10:45 ok |
| SLT-10 | Busy time from connected calendars (CAL-3) is busy like a booking with no buffers of its own. | external 14–15 → 14:00 not offered |
| SLT-11 | If a host's connected calendar can't be read, that host has no availability for the query (fail closed). | calendar API error → host offers nothing |
| SLT-12 | Overlap means a.start < b.end && a.end > b.start; touching is not overlap. |
booking 10–11, slot 11:00 → offered |
| SLT-13 | A limit reached (EVT-8) makes its whole period busy; periods are in the event owner's zone and week start; a booking counts in the period of its start; a slot is offered only while bookings < the booking limit and booked minutes + its duration ≤ the minute limit; booking limits count accepted bookings of the event type across all hosts; minute limits count per host. | day limit 2, two bookings Tue → no Tue slots |
| SLT-14 | Rolling windows (EVT-7) count dates in the booker's zone using each slot's own UTC offset; range dates are the owner's zone; notice is an absolute duration; for rolling windows all dates after the limit are dropped. |
rolling 3 calendar days from Mon → no Fri slots |
| SLT-15 | Holds: when a booker (an anonymous browser id) selects a slot, it is held for 5 minutes, replacing their earlier hold; slots overlapping another booker's unexpired hold are neither offered nor bookable (slot_taken); a booker's own hold is ignored; holds have no buffers. |
A holds 10:00; B queries → no 10:00 |
| SLT-16 | Seated events: a slot at time T with an existing booking stays offered until seats_taken = seats; other slots overlapping that booking are busy. |
seats 3, 1 taken → T offered with seats_taken 1 |
| SLT-17 | When rescheduling, the booking being moved is not busy and doesn't count towards limits. | reschedule 10:00 → 10:00 offered to that booker |
| SLT-18 | Only slots starting in [from, to) are returned; date keys without slots are omitted. |
manual: query one day → one date key |
Out of scope
- Slot caching, slot troubleshooter, "only first slot" display mode.
Decisions
- Seats: a seated booking blocks overlapping slots with other start times (the source counted bookings, not seats, and left them free).
- Pending bookings block only their own host (the source blocked the slot for every host).
- UTC offsets per slot date, not the current offset.
Team events
Status: Draft · Prefix: RR
Purpose
Event types owned by a team: everyone attends (collective) or one host is picked (round robin).
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| RR-1 | A team event type has mode: collective or round_robin, and hosts [{user, fixed (bool), priority 0–4 (default 2), weight ≥ 0 (default 100), group?}]; hosts must be accepted team members (@specfork/teams). all_members (bool) makes every member a host. |
add non-member as host → refused |
| RR-2 | A host may set a per-event schedule overriding their default (AVL-5). | manual: see AVL-5 |
| RR-3 | Availability: collective → intersection of all hosts; round robin → intersection of fixed hosts and, for each group, the union of its non-fixed hosts. Fixed hosts with no common time → no slots. | RR hosts A 9–12, B 12–17 → slots 9–17 |
| RR-4 | Collective booking: all hosts attend; the first host is organizer; every host free or hosts_unavailable. |
one collective host busy → no slot |
| RR-5 | Round-robin pick, per group: among non-fixed hosts free for the slot, keep the highest priority; then, if weights are on, the hosts furthest below their weighted share of this period's bookings; then the least recently booked. |
A booked last, B not → B picked |
| RR-6 | Weighted share: share = total_in_period × weight / sum_weights − own_in_period; period is the current month (default) or day, counted by booking creation time; no-shows excluded. |
weights 2:1, 30 bookings → 20:10 over time |
| RR-7 | A host added mid-period starts with the average bookings per host so far, so they aren't flooded. | new host on day 15 → not picked for every booking |
| RR-8 | Reschedule keeps the same host if they're free, else picks again. | host free at new time → same host |
| RR-9 | Seats on round-robin: later seats join the first seat's host. | manual: second seat same host |
| RR-10 | Recurring round-robin: the picked host must be free for all occurrences, else the next candidate is tried. | host busy in week 2 → next host |
| RR-11 | A team admin can reassign a booking to another free host; old host, new host and attendees are notified. | reassign → both hosts emailed |
| RR-12 | Team permissions (@specfork/teams:TEAM-10 rows): event types read: member+; create, update, delete: admin+; team bookings read, reassign: admin+; webhooks read: member+, manage: admin+. A host always sees bookings they host. |
member edits team event → 403 |
Out of scope
- Managed event types, routing forms, CRM owner routing, attribute segments, lead thresholds.
Decisions
- Priority before weights before recency, as the source does; simpler than combined scoring.
From Specfork/scheduling at c798296, licence CC-BY-4.0.