CU-06: Availability engine: weekly rules, overrides, holidays, slot-debug

Priority: P0 Accounts/sessions: P1 host (ravikantguptaofficial@gmail.com) signed into Calendo at https://calendo.dev/dashboard/ via persisted session. No invitee account needed — bookings/L3 not in scope for this suite. Parallel-safe: No. Exclusive (rewrites global host availability?): YES. This suite rewrites P1's GLOBAL default weekly schedule ("Working Hours") plus its overrides/holidays/meeting-limits, which changes slot availability for every host-writing suite. It MUST run alone, and MUST restore the documented baseline at the end (see Cleanup). Estimated time: 35 minutes L3 reality checks: None. All assertions are [L1] UI / [L2] Calendo persistence + slot-debug API. No Google/Outlook calendar or Gmail verification is required for this suite. (Slot-debug reasons 8 google_calendar and 9 outlook_calendar are NOT exercised here — see Manual residue.)

Goal

This suite proves Calendo's availability engine correctly governs which times a public invitee can book. A host sets a known weekly recurring schedule (Mon–Fri 09:00–17:00) and the public booking page must offer ONLY those windows; a date override that blocks a weekday must zero out that day; an override that opens a normally-off day (Saturday) must surface slots; an enabled holiday preset must auto-block the holiday date; per-event-type buffers and min-notice must shrink the offered slots; multiple named schedules must drive different event types independently; and the /api/slot-debug endpoint must explain WHY any specific time is unavailable across its nine reason codes. This is the core scheduling correctness guarantee — if it's wrong, the product double-books or hides valid times. Because it rewrites the host's global schedule, it is the canonical "exclusive" suite that all other host-writing suites depend on for a clean baseline.

Preconditions

Test data

RUNID convention: pick one fresh UTC token at execution start, format YYYYMMDD-HHMM (example 20260601-1530). Embed RUNID in every created artifact so reruns never collide.

Steps

Order matters throughout: each phase mutates the schedule, so you must capture the original baseline FIRST (Step 1), then run phases sequentially, then restore in Cleanup. Several saves auto-persist (overrides) while others require an explicit Save button (weekly rules, holidays, meeting limits) — follow exactly.

Phase A — Establish and verify the known weekly schedule

  1. Action: Go to https://calendo.dev/dashboard/, click the sidebar "Availability" link (.sidebar-nav a[data-tab="availability"]). Confirm panel #panel-availability gets class active. Ensure the "Schedules" sub-tab is active (.avail-sub-tab with text "Schedules"; its panel #avail-panel-schedules has class active). In #scheduleSelect, confirm "Working Hours" is selected. Read and RECORD the current state for cleanup: the day circles that are active (#weeklyScheduleEditor [data-day-circle].active), each day's time-select values (.avail-time-select data-name="start-N-0"/end-N-0), the value of #totalWeeklyHours, the timezone shown in #scheduleTimezoneDisplay, plus any items in #overridesList, #meetingLimitsList (Advanced settings sub-tab), and #holidaysList. Expect: You can see and record a complete baseline snapshot. Capture screenshot: cu06-01-baseline. [L2]
  1. Action: Set the schedule to exactly Mon–Fri 09:00–17:00 and weekends off. In #weeklyScheduleEditor, for each weekday Mon(1)–Fri(5): ensure its circle ([data-day-circle="1"][data-day-circle="5"]) is active; if a day shows "Unavailable", click its circle to enable it (defaults to 09:00–17:00). For each weekday confirm the first window's start select (.avail-time-select[data-name="start-N-0"]) = 09:00 and end (end-N-0) = 17:00; if a day has extra windows, remove them via the window's × ([data-remove-window="N-i"]). Ensure Saturday(6) and Sunday(0) circles ([data-day-circle="6"], [data-day-circle="0"]) are NOT active (click to disable if they show times). Expect: Mon–Fri each show one 09:00–17:00 window; Sat/Sun show "Unavailable"; #totalWeeklyHours reads 40.0 hrs/week. Capture screenshot: cu06-02-weekly-set. [L1]
  1. Action: Click "Save Schedule" (#saveScheduleBtn). Wait for the toast "Schedule saved". Expect: A success toast appears; button returns to label "Save Schedule". [L1]
  1. Action: Reload the page (F5), navigate back to Availability, confirm "Working Hours" selected. Expect: Mon–Fri still 09:00–17:00, Sat/Sun off, #totalWeeklyHours = 40.0 hrs/week — i.e. the save persisted across reload. [L2]
  1. Action: Open the public booking page in a new tab: https://calendo.dev/booking/?user=<HOST_SLUG>&event=30-minute-meeting. Wait for #booking-view to be visible (give the calendar ~1.5s to render). Expect: The month calendar shows weekday cells with class .calendar-day.available (count > 0) and weekend cells with class .calendar-day.unavailable (count > 0). [L2]
  1. Action: Click the first available weekday cell (.calendar-day.available). Expect: Time slots (.time-slot) appear; the earliest visible slot corresponds to 09:00 host time (rendered in the invitee's timezone) and the latest fits before 17:00 (a 30-min event => last start 16:30 host time). Hover/read a couple of slots to confirm they fall inside 09:00–16:30 host-equivalent. Capture screenshot: cu06-03-booking-weekday-slots. [L2]
  1. Action: Navigate the booking calendar to land on a Saturday or Sunday cell. Expect: Weekend cells carry .calendar-day.unavailable and NOT .available; clicking does not surface .time-slot elements. [L2]

Phase B — Date override that BLOCKS a weekday

  1. Action: Back in the dashboard Availability "Schedules" sub-panel, in the date-override mini calendar (#overrideCalendar), use the nav arrows ([data-cal-nav="1"]) to reach the month containing BLOCK_DATE, then click that day cell ([data-cal-date="<BLOCK_DATE>"]). The override dialog #overrideDialog opens. Expect: Dialog title "Add Override"; #overrideDialogDate shows the long-form date of BLOCK_DATE; "Block entire day" (#overrideBlockDay) is checked. [L1]
  1. Action: Leave "Block entire day" checked and click "Save Override" (#saveOverride). Expect: Toast "Override saved" (overrides auto-save via PUT). The BLOCK_DATE cell in #overrideCalendar now shows the blocked styling (class has-override), and #overridesList lists the date with a "Blocked" badge (.avail-override-blocked). Capture screenshot: cu06-04-block-override-saved. [L1]
  1. Action: Reload the booking page tab at ...&event=30-minute-meeting, navigate to the month of BLOCK_DATE, and locate that date's cell. Expect: BLOCK_DATE is .calendar-day.unavailable (not .available); clicking it surfaces no .time-slot. The surrounding weekdays remain available. Capture screenshot: cu06-05-booking-block-date. [L2]

Phase C — Date override that OPENS a normally-off Saturday

  1. Action: In #overrideCalendar, reach the month of OPEN_DATE (a Saturday) and click its cell ([data-cal-date="<OPEN_DATE>"]). In the dialog, UNCHECK "Block entire day" (#overrideBlockDay) — the custom-hours block (#overrideCustomHours) appears. Set #overrideStart = 10:00 and #overrideEnd = 12:00. Click "Save Override" (#saveOverride). Expect: Toast "Override saved"; OPEN_DATE cell shows custom-hours styling (class has-override-custom); #overridesList shows OPEN_DATE with "10:00 AM - 12:00 PM". [L1]
  1. Action: Reload the booking page, navigate to the month of OPEN_DATE. Expect: OPEN_DATE (a Saturday, normally closed) is now .calendar-day.available. Click it; .time-slot elements appear, bounded by 10:00–12:00 host time (for a 30-min event, exactly four starts: 10:00, 10:30, 11:00, 11:30 host time). Capture screenshot: cu06-06-booking-open-saturday. [L2]

Phase D — Holiday preset auto-blocks a holiday

  1. Action: In the Availability tab, click the "Advanced settings" sub-tab (.avail-sub-tab text "Advanced settings"; panel #avail-panel-advanced-settings becomes active). In the Holidays section set #holidayCountry = "United States" (value US) and #holidayYear = 2026, then click "Load Presets" (#loadHolidayPresetsBtn). Expect: Toast "Loaded N holidays for US 2026"; #holidaysList populates with .holiday-toggle-row entries (e.g. "Independence Day", "Thanksgiving Day", "Christmas Day"), each with an enabled toggle ([data-toggle-holiday]). [L1]
  1. Action: Identify an UPCOMING US holiday at least a few days out (e.g. Independence Day 2026 = 2026-07-04, or the next holiday after today). Confirm its toggle is ON (checked). Click "Save Holidays" (#saveHolidaysBtn). Expect: Toast "Holidays saved". RECORD which holiday date you will assert as HOLIDAY_DATE. Capture screenshot: cu06-07-holidays-saved. [L1]
  1. Action: Switch back to the "Schedules" sub-tab; in #overrideCalendar navigate to the month of HOLIDAY_DATE. Expect: The HOLIDAY_DATE cell shows blocked styling (class has-override) with a tooltip "Holiday: <name>" (holidays render red even without an explicit override). [L1]
  1. Action: Reload the booking page, navigate to the month of HOLIDAY_DATE. Expect: HOLIDAY_DATE is .calendar-day.unavailable; no .time-slot on click — the holiday auto-blocked the day. Capture screenshot: cu06-08-booking-holiday-blocked. [L2]

Phase E — Buffer + min-notice interaction (per-event-type)

  1. Action: Create a dedicated event type so buffers/min-notice don't pollute the default. Go to the dashboard Overview (sidebar "Overview"), click "+ New" (#overviewCreateEtBtn); the edit modal #editEtModal opens. Set #editEtName = Buf-<RUNID>, #editEtDuration = 30. Open the "Limits & Buffers" section and set #editEtBufferBefore = 30, #editEtBufferAfter = 30, #editEtMinNotice = 48. Leave its availability schedule = default ("Working Hours") via #etSchedule. Click the save button (#editEtSaveBtn, labeled "Create"). Expect: Modal closes; Buf-<RUNID> appears in #overviewEventTypes. Record slug buf-<runid>. [L1]
  1. Action: Open https://calendo.dev/booking/?user=<HOST_SLUG>&event=buf-<runid>. Expect: Because min-notice is 48h, all slots within the next 48 hours are suppressed; the earliest bookable day/time is ≥48h from now. Compare against the 30-minute-meeting page (default 4h notice) opened side by side: the Buf-<RUNID> page offers strictly FEWER near-term slots. [L2]
  1. Action: On the Buf-<RUNID> page, pick any open weekday and inspect its .time-slot list. Expect: With 30-min buffers before/after, adjacent slots are spaced so a booked slot would block its neighbors; the slot grid is sparser than the buffer-free default event. (Exact count depends on existing bookings; the key assertion is fewer slots than the no-buffer default for the same day.) Capture screenshot: cu06-09-buffer-minnotice-slots. [L2]

Phase F — Multiple schedules drive different event types

  1. Action: Go to Availability → "Schedules" sub-tab, click "+ New Schedule" (#createScheduleBtn); form #newScheduleForm appears. Set #newScheduleName = Eve-<RUNID>, #newScheduleTimezone = America/New_York. Click the form's "Create" submit (#scheduleCreateForm button[type="submit"]). Expect: Form hides; #scheduleSelect now contains and has selected Eve-<RUNID>; the new schedule starts empty (all days "Unavailable", #totalWeeklyHours = 0.0 hrs/week). The "Delete Schedule" button (#deleteScheduleBtn) is now visible (non-default). [L1]
  1. Action: With Eve-<RUNID> selected, enable a DISTINCT window: click the Wednesday circle ([data-day-circle="3"]) to enable, set its window to start 18:00 / end 21:00 (.avail-time-select[data-name="start-3-0"]/end-3-0). Click "Save Schedule" (#saveScheduleBtn). Expect: Toast "Schedule saved"; #totalWeeklyHours reflects 3.0 hrs/week. [L1]
  1. Action: Create an event type bound to this schedule. Overview → "+ New" (#overviewCreateEtBtn); set #editEtName = EvtSched-<RUNID>, #editEtDuration = 30, and set #etSchedule to Eve-<RUNID>. Click "Create" (#editEtSaveBtn). Expect: Modal closes; EvtSched-<RUNID> appears in overview. Record slug evtsched-<runid>. [L1]
  1. Action: Open https://calendo.dev/booking/?user=<HOST_SLUG>&event=evtsched-<runid> and navigate to an upcoming Wednesday. Expect: Only Wednesdays offer slots, and those slots fall in the 18:00–21:00 New York window (rendered in invitee tz), NOT the default 09:00–17:00 LA window. Cross-check: the 30-minute-meeting page still shows Mon–Fri 09:00–17:00 LA — proving the two event types use independent schedules. Capture screenshot: cu06-10-multi-schedule-booking. [L2]

Phase G — Meeting limits (daily/weekly)

  1. Action: Re-select "Working Hours" in #scheduleSelect. Go to "Advanced settings" sub-tab. In Meeting limits, click "+ Add a meeting limit" (#addMeetingLimitBtn). A .meeting-limit-row appears with type select ([data-limit-type="0"]) defaulting to "Daily" and a max input ([data-limit-max="0"]) defaulting to 5. Leave type "daily", set max = 1. Click "Save Limits" (#saveMeetingLimitsBtn). Expect: Toast "Meeting limits saved". [L1]
  1. Action: Reload the page, return to Availability → Advanced settings → Meeting limits. Expect: The daily-max-1 limit persisted (row present, type "Daily", max 1) — confirming [L2] persistence of the meeting-limits write. (Full behavioral enforcement — a day going empty after 1 booking — is covered by unit tests tests/unit/worker/availability-meeting-limits.test.js; in-browser we verify persistence and that slot-debug reports daily_cap when applicable in Phase H.) Capture screenshot: cu06-11-meeting-limit-saved. [L2]

Phase H — Slot-debug "why is this time unavailable?" (all 9 reasons)

The endpoint is GET https://calendo.dev/api/slot-debug/<HOST_SLUG>/<EVENT_SLUG>?time=<ISO>&timezone=<TZ>. It returns JSON { slot, date, time, timezone, available, reasons: [{reason, detail}] }. Drive it by typing the URL into the browser address bar (you are authenticated, but this is a public endpoint) and reading the rendered JSON. The nine reason codes the engine can emit are: holiday, date_override, no_schedule, outside_hours, min_notice, max_future, booking_conflict, daily_cap, google_calendar, outlook_calendar.

  1. Action: Probe an OPEN time. Open …/api/slot-debug/<HOST_SLUG>/30-minute-meeting?time=<DEBUG_OK_TIME>&timezone=America/Los_Angeles. Expect: "available": true and "reasons": []. [L2]
  1. Action: Probe a weekend (no-schedule) time, e.g. an upcoming Sunday 14:00: …/30-minute-meeting?time=<SUNDAY>T14:00:00&timezone=America/Los_Angeles. Expect: available:false; a reason with "reason":"no_schedule" (detail mentions no availability rules for that weekday). [L2]
  1. Action: Probe an out-of-hours weekday time, e.g. a normal Tuesday at 20:00: …/30-minute-meeting?time=<TUESDAY>T20:00:00&timezone=America/Los_Angeles. Expect: available:false; reason "outside_hours" (detail cites scheduled hours 09:00-17:00). [L2]
  1. Action: Probe the BLOCK override date at 14:00: …/30-minute-meeting?time=<BLOCK_DATE>T14:00:00&timezone=America/Los_Angeles. Expect: available:false; reason "date_override" (detail: date blocked by a date override). [L2]
  1. Action: Probe a HOLIDAY date at 14:00: …/30-minute-meeting?time=<HOLIDAY_DATE>T14:00:00&timezone=America/Los_Angeles. Expect: available:false; reason "holiday" (detail names the holiday). [L2]
  1. Action: Probe a too-soon time using the high-min-notice event Buf-<RUNID>: a valid open weekday/time only ~2 hours from now: …/buf-<runid>?time=<SOON_ISO>&timezone=America/Los_Angeles. Expect: available:false; reason "min_notice" (detail: requires 48h notice). [L2]
  1. Action: Probe a far-future time beyond max_future_days (default 60) on the default event, e.g. ~120 days out at 14:00: …/30-minute-meeting?time=<FAR_ISO>&timezone=America/Los_Angeles. Expect: available:false; reason "max_future" (detail: more than 60 days in the future). [L2]
  1. Action: (booking_conflict) If a confirmed booking exists at a known time for 30-minute-meeting, probe that exact slot. Expect: available:false; reason "booking_conflict" with the conflicting booking's times. If no booking exists in this clean run, RECORD that booking_conflict was not exercised in-browser (it is covered by unit tests tests/unit/worker/availability.test.js); do not fabricate. [L2]
  1. Action: (daily_cap) The daily-max-1 limit set in Phase G is a SCHEDULE-level meeting limit (it empties days in slot computation), distinct from the event-type max_bookings_per_day cap that slot-debug's daily_cap reason reads. If P1's 30-minute-meeting has a max_bookings_per_day set AND that many confirmed bookings exist on a day, probe a time on that day to elicit "daily_cap". Otherwise RECORD that daily_cap was not reproducible in-browser this run (covered by unit tests). [L2]
  1. Action: RECORD the slot-debug coverage matrix: which of the nine reasons were observed live ({no_schedule, outside_hours, date_override, holiday, min_notice, max_future} are reliably reproducible; {booking_conflict, daily_cap} depend on existing data; {google_calendar, outlook_calendar} are out of scope for this suite). Capture screenshot of the slot-debug JSON for at least four distinct reasons: cu06-12-slotdebug-reasons. [L2]

L3 reality checks

None — see Pass/Fail. This suite makes no external calendar or email assertions. (Slot-debug reasons google_calendar and outlook_calendar would require connected external calendars with conflicting events; they are deliberately deferred to the calendar-integration suites and listed in Manual residue.)

Cleanup

Restore P1 to the documented baseline so other host-writing suites see Mon–Fri 09:00–17:00 with no test residue. Do these in order:

  1. Remove test overrides. Availability → Schedules sub-tab → #overridesList: click the × ([data-remove-override="<BLOCK_DATE>"]) for BLOCK_DATE and for OPEN_DATE; each auto-saves ("Override removed" toast). Confirm #overridesList shows "No date overrides" (unless the recorded baseline had overrides — in that case restore exactly those).
  2. Clear holidays (only if the baseline had none). Advanced settings → Holidays → click "Clear All" (#clearHolidaysBtn), then "Save Holidays" (#saveHolidaysBtn). If the baseline had holidays, re-load and re-save that exact set instead.
  3. Remove the meeting limit. Advanced settings → Meeting limits → click the × ([data-remove-limit="0"]) on the daily-max-1 row, then "Save Limits" (#saveMeetingLimitsBtn). Confirm "No meeting limits configured" (unless baseline had limits — restore those).
  4. Delete the test event types. Overview → for Buf-<RUNID> and EvtSched-<RUNID>, open each and delete it (event-type delete control / confirm dialog #dialogConfirm). Confirm both are gone from #overviewEventTypes.
  5. Delete the test schedule. Availability → select Eve-<RUNID> in #scheduleSelect → click "Delete Schedule" (#deleteScheduleBtn) → confirm in #confirmDialog via #dialogConfirm. Confirm #scheduleSelect no longer lists Eve-<RUNID> and falls back to "Working Hours".
  6. Restore the default weekly schedule. Select "Working Hours"; set days/windows back to the Step 1 baseline snapshot (default = Mon–Fri 09:00–17:00, weekends off, America/Los_Angeles); click "Save Schedule" (#saveScheduleBtn). Confirm #totalWeeklyHours = 40.0 hrs/week (or the recorded baseline value).
  7. Final verification. Reload, open …/api/slot-debug/<HOST_SLUG>/30-minute-meeting?time=<DEBUG_OK_TIME>&timezone=America/Los_Angeles and confirm available:true, reasons:[]. Capture screenshot: cu06-13-baseline-restored.

Pass/Fail criteria

The run PASSES only if ALL of the following hold:

FAILS if any availability mutation does not reflect on the public booking page, any slot-debug probe returns the wrong reason or a 4xx/5xx, persistence does not survive reload, or cleanup leaves residue / a non-baseline default schedule.

Evidence to capture

Manual residue / cannot-verify