Troubleshooting Feature Revamp — Phased Engineering Plan

Context

The troubleshooting feature lets admins/hosts understand why slots are unavailable on the public booking page. A working but incomplete version exists on main. The prototype branch (troubleshoot-revamp-functional) demonstrates all the desired behaviors but is not production-ready code.

This plan breaks the revamp into 5 phases, each a separate PR. Each phase builds on the previous and is independently testable.

What Exists on Main Today

  • Basic troubleshoot mode toggle (Start/Done troubleshooting button)

  • fillFullDaySlots() fills the full day with slots in troubleshoot mode

  • Slot-level HelpPopover showing one generalUnavailable reason per slot

  • Per-member breakdown for round-robin/multihost (available vs unavailable members)

  • Backend generates: lead_time, holiday, host_availability, host_availability_override, booking, booking_before/after_buffer, google/outlook/icloud_event, icloud_event_travel_time, calendar_event_before/after_buffer, slot_before/after_buffer, outside_range, booking_limit_per_slot_reached, overlapping_group_slot_booking

  • Day-level: fullyUnavailable flag set for lead_time, holiday, and all-members-unavailable

  • Translations for slot-level reasons exist

What's Missing (the gaps this revamp fills)

  • Multiple reasons per slot — backend only stores one general_unavailable per slot; needs general_unavailables array to accumulate all reasons

  • date_range_exceeded reason — backend doesn't generate this; missing dates have no reason

  • available_due_to_override indicator — backend doesn't mark slots that are available only because of an override

  • Booking limit day-level reasons — day marked unavailable but no reason exposed (booking_limit_reached_day/week/month at day level)

  • Host-level booking limit reasons_host variants for per-host limits

  • "Whole day unavailable" summary card — doesn't exist; user must click each slot

  • Past time as day-level reason — when the entire day is in the past, no day-level banner is shown

  • Per-host day-level breakdown — for round-robin/multihost, no per-host reasons at day level

  • Change/View action links — no links to admin pages where the issue can be resolved

  • Timezone warning overlay — doesn't exist

  • Calendar day grey styling — unavailable days not visually distinct in troubleshoot mode

  • Day-level reason translationsday.* i18n keys don't exist

  • duration_mismatch reason — not generated by backend

  • members_unavailable reason — not generated by backend

  • host_availability_no_hours reason — not distinguished from host_availability

  • too_soon reason — not generated by backend or frontend

  • Reason display ordering — reasons shown in arbitrary order, spec defines a precedence

  • Booking limit numbers — reason payloads don't include the actual limit value (limit_no), only "has been reached"

  • Round-robin host ordering — available/unavailable host sections don't flip order based on slot availability status


Phase 1: Backend — Accumulate All Reasons + New Reason Keys

Goal: Make the backend the single source of truth for all unavailability reasons. After this phase, the API response in troubleshoot mode contains everything the frontend needs.

Scope: Backend only (time_frames_to_slots_service.rb + related services). No frontend changes.

Tasks

Accumulate multiple reasons per slot

  • Add general_unavailables array alongside existing general_unavailable (keep the old field for backward compat)

  • When a slot is marked unavailable by multiple reasons (e.g., holiday + lead_time + booking conflict), all reasons appear in the array

  • For per-member reasons: add unavailable_reasons array to each member entry (alongside existing unavailable_reason)

Add date_range_exceeded reason

  • When a date falls outside the meeting's configured date range (start_date/end_date or days-into-future), generate this reason

  • Applies to missing dates that currently have fullyUnavailable: true but no reason

  • Add outside_schedulable_range? helper method

Add available_due_to_override flag

  • After all slots are generated, check each available slot: if it falls on an override date AND outside the member's original working hours, mark available_due_to_override: true with availability_name

  • Requires tracking original_periods_by_wday and override_dates from member time frames

Enhance day-level reason collection

  • Add collect_day_level_reasons method that checks if all slots share common blockers (booking limits, holiday, date_range_exceeded, past time)

  • Expose general_unavailables array at the date level (not just general_unavailable)

  • For round-robin/multihost: add extract_day_level_members that provides per-host day reasons

  • Add past_time as a day-level reason when the entire date is in the past

Add booking limit host-level reasons

  • When a host's personal booking limit is reached, generate booking_limit_reached_day / _week / _month with limit_scope: :member and host_name in the payload

  • The frontend maps limit_scope: "member" to _host translation keys (e.g., booking_limit_reached_day_host) — the backend does NOT generate _host suffixed reason keys directly

Add duration_mismatch detection

  • When a slot's meeting duration extends beyond the availability window end, mark it with duration_mismatch reason

  • The prototype computes this on the frontend — production implementation must move it to the backend

Add too_soon reason

  • When a slot falls within the lead time window, generate too_soon reason

  • Translation exists but reason is not yet generated by backend or frontend

Add members_unavailable reason

  • For round-robin/multihost, when specific members are unavailable, generate members_unavailable with member names

  • Translation exists but reason is not yet generated by backend or frontend

Include booking limit numbers in reason payloads

  • When generating booking_limit_reached_* reasons, include limit_no (the actual limit value) in the reason payload

  • Applies to both meeting-level and host-level limits, at both slot and day level

Testing

Unit tests for time_frames_to_slots_service covering:

  • Slot with multiple reasons returns all in general_unavailables

  • Missing date outside range returns date_range_exceeded

  • Override-available slot is flagged correctly

  • Day-level reasons collected for booking limits, holidays

  • Past dates generate past_time day-level reason

  • Round-robin per-member day extraction

Reference Files

  • app/services/slots/time_frames_to_slots_service.rb (primary)

  • test/services/slots/time_frames_to_slots_service_test.rb

  • Prototype branch: see mark_available_due_to_override, collect_day_level_reasons, extract_day_level_members, outside_schedulable_range? methods


Phase 2: Frontend Foundation — Calendar Styling + Timezone Warning + Translations

Goal: Visual foundation for the revamped troubleshoot mode. After this phase, the calendar looks correct (grey unavailable days), timezone warning works, and all translation keys are in place.

Scope: CalendarWidget, TimezoneWarningOverlay (new), CalendarWrapper, translations, CSS. No slot-detail changes yet.

Tasks

Calendar day grey styling for unavailable days

  • Add CSS class fc-day-troubleshoot-unavailable with: Background: gray-200, text: gray-600; Hover: gray-500 + white text (0.15s ease transition); Selected + unavailable: gray-500 + white

  • In CalendarWidget.jsx, detect unavailable days via: isDateFullyUnavailable(slots, date) — backend flag; hasNoAvailableSlots(slots, date) — all slots unavailable; Past dates

  • Unavailable days remain clickable in troubleshoot mode (unlike regular mode)

Timezone warning overlay

  • Create TimezoneWarningOverlay.jsx component

  • Shows when troubleshoot starts and page timezone != admin's profile timezone

  • Blur backdrop, centered white card, single "Continue anyway" button (no auto-switch)

  • Auto-dismisses if user manually changes timezone to match profile

  • Wire into CalendarWrapper.jsx state management

Add all missing translations to en.json

  • Day-level reason keys: live.troubleshoot.reasons.day.past_time, day.holiday, day.lead_time, day.date_range_exceeded, day.booking_limit_reached_day/week/month, day.booking_limit_reached_day/week/month_host, day.host_availability

  • New slot-level keys: host_availability_no_hours, members_unavailable, too_soon

  • UI keys: dayUnavailableTitle, seeIndividualSlots, hideIndividualSlots, slotAvailableButHasPast, slotAvailableDueToOverride

  • Timezone warning keys: timezoneWarning.title, timezoneWarning.description, timezoneWarning.continueAnyway

  • troubleshootToSeeWhy for the empty-state link

Slot hover styling

  • Add theme primary color hover transition (0.15s ease) to slot buttons in troubleshoot mode

Testing

  • Visual QA: navigate troubleshoot mode across months, verify grey days match spec

  • Timezone warning: change page timezone to differ from profile, start troubleshoot, verify overlay

  • Verify all new translation keys render (no missing key warnings in console)

Reference Files

  • app/javascript/src/components/Live/Calendar/CalendarWidget.jsx

  • app/javascript/src/components/Live/Calendar/CalendarWrapper.jsx

  • app/javascript/stylesheets/components/calendar.css

  • app/javascript/src/translations/en.json

  • Prototype: TimezoneWarningOverlay.jsx (new file)


Goal: Each slot shows ALL its unavailability reasons with actionable links. This is the core of the troubleshoot experience for single-host meetings (one-on-one + group).

Scope: TroubleshootSlotDetails.jsx, utils.js helpers, PopoverInfo.jsx.

Tasks

Display all reasons per slot (not just one)

  • Read from general_unavailables array (Phase 1 backend)

  • Show reasons in the spec's display precedence order: 1. past_time -> 2. date_range_exceeded -> 3. host unavailable (outside availability) -> 4. holiday -> 5. availability override -> 6. lead_time -> 7. meeting/host limits, duration_mismatch, buffer time, booking conflicts, calendar conflicts -> 8. group event full

  • Each reason: bold title (text-xs font-bold) + grey description (text-xs gray-600)

Change/View links on reasons

  • Add fixLink(reason, meetingSid) utility that maps reason -> admin page URL:

    • lead_time, date_range_exceeded -> scheduling link settings

    • booking_limit_reached_* (meeting) -> scheduling link limit settings

    • booking_limit_reached_*_host -> host booking limits page

    • host_availability*, duration_mismatch, slot_*_buffer, calendar_event_*_buffer -> host availability page (with specific availability SID when available)

    • holiday -> holidays settings

    • booking, booking_*_buffer -> booking detail page (View link)

  • Add viewLink(reason) for internal booking reasons

  • Links render inline after description text: "...description. [Change]" or "...description. [View]"

Host availability special rendering

  • Single range: "{hostName} is only available from {start} to {end} as per {availabilityName}."

  • Multiple ranges: Show centered list of time ranges, then "As per {availabilityName}."

  • No hours: "{hostName} is not available on this day as per {availabilityName}."

[CHANGE] Override variant: same patterns but "due to an availability override in {availabilityName}" (previously referenced {overrideDate} — updated to use {availabilityName} per the walkthrough video)

Available slot info icons

  • Past but was available: info icon with "Slot was available, but the time has now passed"

  • Available due to override: info icon with "This slot is available because of an availability override in {availabilityName}"

Add utility functions to utils.js

  • reasonI18nKey(reason) — maps reason key to translation key

  • dayReasonI18nKey(reason) — maps day-level reason to translation key

  • reasonTitle(reason) — returns display title

  • fixLink(reason, meetingSid) — builds admin page URL

  • viewLink(reason) — builds booking detail URL

  • isInternalBookingReason(reason) — checks if reason is an internal NeetoCal booking

  • REASON_TITLES constant map

Testing

  • Create a scheduling link with various constraints (lead time, date range, buffer times)

  • Create conflicting bookings and external calendar events

  • Verify each reason type shows correct title, description, and link

  • Click change/view links and confirm they navigate to the right admin page

  • Test host_availability with 1 range, 3 ranges, and 0 ranges

Reference Files

  • app/javascript/src/components/Live/Calendar/Slots/TroubleshootSlotDetails.jsx

  • app/javascript/src/components/Live/Calendar/Slots/PopoverInfo.jsx

  • app/javascript/src/components/Live/Calendar/utils.js

  • Prototype: see REASON_TITLES, fixLink, viewLink, reasonI18nKey in utils.js


Phase 4: Day-Level "Whole Day Unavailable" Summary

Goal: When all slots on a day are unavailable, show a summary card explaining why — instead of forcing the admin to click individual slots.

Scope: New DayUnavailableSummary.jsx component, updates to TroubleshootSlots.jsx.

Tasks

Create DayUnavailableSummary.jsx

  • Card with header: "Whole day unavailable because"

  • Bordered content area listing day-level reasons

  • Each reason: title + description + change link (same rendering as slot reasons)

  • Day-level reason keys: day.past_time, day.holiday, day.lead_time, day.date_range_exceeded, day.booking_limit_reached_*, day.booking_limit_reached_*_host, day.host_availability

[CHANGE] Added day.past_time as the highest-priority day-level reason. When the entire day is in the past, show "This day has already passed." with no change link. This prevents users from having to click each slot only to see "past time" everywhere.

"See individual slots" toggle

  • Below the summary card, show a toggle: "See individual slots" / "Hide individual slots"

  • When expanded, shows the full slot list below the card

  • Default state: collapsed (just the summary card)

Detection logic in TroubleshootSlots.jsx

  • If selected date is in the past -> render DayUnavailableSummary with past_time reason

  • If selected date has fullyUnavailable: true -> render DayUnavailableSummary

  • If all slots on date are unavailable (no fullyUnavailable flag but computed) -> also render summary

  • Otherwise -> render individual slots directly

Day-level reason rendering

  • Read from general_unavailables at the date level (Phase 1 backend)

  • Use dayReasonI18nKey() for translations

  • Same change link logic as slot-level

Testing

  • Navigate to a past date and verify "This day has already passed" banner appears

  • Set up a holiday and verify "Whole day unavailable" shows with holiday reason + Change link

  • Set lead time to block an entire day, verify summary card

  • Set booking limits to block a day, verify card shows the limit type

  • Set date range to exclude dates, verify card on excluded dates

  • Toggle "See individual slots" and verify slot list appears/hides

Reference Files

  • Prototype: DayUnavailableSummary.jsx (new file)

  • app/javascript/src/components/Live/Calendar/Slots/TroubleshootSlots.jsx


Phase 5: Multi-Member Meetings (Round Robin + Multihost)

Goal: For round-robin and multihost meetings, show per-host breakdown at both day and slot level with availability status, reasons, and avatars.

Scope: DayUnavailableSummary.jsx (multi-member path), PopoverInfo.jsx, HostDayEntry.jsx (new), TroubleshootSlotDetails.jsx (multi-member path).

Tasks

Create HostDayEntry.jsx

  • Renders a single host's day-level unavailability

  • Layout: 24px circular avatar + host name (text-xs font-medium gray-800) + reasons below

  • Reasons always expanded (no accordion)

  • Each reason: title + description + change link

  • hostName and availabilityName are per-host (from backend member data)

Multi-member day-level summary (in DayUnavailableSummary.jsx)

  • Shared reasons shown once at the top (holiday, lead_time, booking limits meeting-level, date_range_exceeded) — NOT repeated per host

  • "Unavailable hosts" section: Heading with red pill badge: {count}/{total} (error-100 bg, error-600 text); List of HostDayEntry components for each unavailable host

  • "Available hosts" section: Heading with green pill badge: {count}/{total} (success-100 bg, success-600 text); Just avatar + name, no reasons

Multi-member slot-level popover (in PopoverInfo.jsx)

  • General reasons (meeting-level blockers) at top with links

  • Host section ordering depends on slot availability status (see below)

  • Popover: min-width 400px, max-height 320px with vertical scroll

[CHANGE] Host section ordering for round-robin meetings. Per the walkthrough video, when a slot is available in a round-robin meeting, show "Available hosts" first (on top) and "Unavailable hosts" second (on bottom). When a slot is unavailable, show "Unavailable hosts" first and "Available hosts" second. For multihost meetings, always show "Unavailable hosts" first since the slot is unavailable if any host is blocked.

Hook changes in useSlots.js

  • For multi-member meetings: extract per-member info from slot data

  • extractMembersFromSlots() — collects unique members and their availability per slot

  • Enrich outside_range reasons with availability range info per member

Meeting type behavior differences

  • Round-robin: slot available when ANY host is free; day unavailable when ALL hosts blocked for all slots; available slot still shows per-host info icon with reasons for unavailable hosts

  • Multihost: slot available when ALL hosts are free; day unavailable when ANY host is blocked for all slots

Testing

  • Create a round-robin meeting with 3+ hosts

  • Set different availabilities per host, verify per-host breakdown

  • Set a holiday that blocks all hosts, verify shared reason shown once (not per host)

  • Set host-level booking limits, verify _host variant reasons per member

  • Create a multihost meeting, verify different availability logic

  • Verify available round-robin slots show available hosts first, unavailable hosts second

  • Verify unavailable slots show unavailable hosts first, available hosts second

  • Test slot-level popover scrolling with many hosts

Reference Files

  • Prototype: HostDayEntry.jsx (new file), PopoverInfo.jsx (enhanced), DayUnavailableSummary.jsx (multi-member path)

  • app/javascript/src/hooks/slots/useSlots.js

  • app/javascript/src/components/Live/Calendar/utils.js (extractMembersFromSlots, groupedMembers)


Phase Dependencies

  • Phase 1 is a prerequisite for all others (backend must expose the data)

  • Phase 2 can run in parallel with Phase 3 (independent UI concerns) — these are quick wins that should be shipped fast

  • Phase 3 must complete before Phase 4 (day summary reuses slot reason rendering)

  • Phase 4 must complete before Phase 5 (multi-member extends the day summary)

Phase 1 (Backend) ──> Phase 2 (Calendar + TZ + Translations) ──> Phase 3 (Slot Reasons + Links)
                                                                        |
                                                                  Phase 4 (Day Summary)
                                                                        |
                                                                  Phase 5 (Multi-Member)

Prototype Reference

The troubleshoot-revamp-functional branch contains a working prototype of all 5 phases. Engineers should use it as a visual reference and for understanding data flow, but should not merge it directly — it needs to be rewritten cleanly in each phase's PR.

Key prototype files to reference:

  • Backend: app/services/slots/time_frames_to_slots_service.rb (see new methods)

  • Frontend utilities: app/javascript/src/components/Live/Calendar/utils.js (see new helpers)

  • New components: DayUnavailableSummary.jsx, HostDayEntry.jsx, TimezoneWarningOverlay.jsx

  • Enhanced: TroubleshootSlotDetails.jsx, PopoverInfo.jsx, CalendarWidget.jsx