Skip to main content

Calendar v2 — Spacious, Production-Grade Day/Week/Month

Problem

  1. Data starvation on Week and Month. GET /api/v1/protected/appointments caps at 50 rows (default 50, max 100; server/src/routes/appointments.ts:68) and the client never overrides. Day view’s narrow window (±7 days) stays under the cap; Week (~21 days) and Month (~75 days) blow past it. Returned rows are the earliest 50 in the range, so the visible week or month often gets zero appointments — what looks like “broken views” is just the cap eating the visible window.
  2. Visual cramp. The current views fight for vertical pixels (64px hour rows, tiny status tiles, no breathing room). At >24 events/day Week falls back to a scroll-trapped agenda overlay that covers the column. Month shows 3 chips + heat tint, which works in moderation but doesn’t surface when the day is busy, only that it is. There is no answer to “what does the busy hour pattern look like across the month?”
  3. React fragility. Existing components do per-cell filter(state.events …) 7× (Week) or up to 42× (Month) per render, mutate Date objects in derivations, and call setOpen from inside render paths that re-enter on AnimatePresence transitions. We have a history of React #185 crashes when view/date change cascades fire on Month — already touched in AppointmentCalendar.tsx:226-230.
Clinics use this surface daily. The goal is a calendar that wins them over while doing zero harm to what already works.

Goals

  • Stop the data loss. Week and Month return all appointments in the visible range, ordered correctly, without breaking existing day-view behaviour.
  • Spacious, crisp, sharp. Day/Week/Month feel premium — generous vertical rhythm, tabular-num times, status as a ribbon not a tile, considered hover/selection states.
  • Smart density, not crammed density. When a day has 100+ appointments, the views remain glanceable — not collapsed into a scrolling list dump.
  • Crash-safe. No new React #185, no race-y setOpen from render, no quadratic per-render filtering.
  • Single PR, controlled blast radius. Old components live one more release behind ?calendar=legacy. Day view (the trusted workhorse) is preserved verbatim until the v2 is verified.

Non-Goals

  • Replacing the underlying scheduler provider (schedular-provider.tsx). Same data contract — just consume it correctly.
  • Drag-to-reschedule UX changes. v2 keeps existing handleDragEnd semantics; only visuals change.
  • Recurring appointments, multi-day events, all-day events. Out of scope for this iteration.
  • Mobile gets minimal updates (today’s mobile-day-view stays); v2 visual work targets desktop/tablet first. A follow-up plan covers mobile parity.
  • Operational metrics dashboards, capacity planning UI. Heat strip on Month is for daily orientation, not a reporting product.

Architecture

File layout

ui/src/components/schedule/_components/view/
  day/
    daily-view.tsx              (legacy, kept)
    doctor-day-view.tsx         (legacy, kept)
    day-view-v2.tsx             (new — replaces daily-view in v2 mode)
  week/
    week-view.tsx               (legacy, kept)
    week-view-v2.tsx            (new)
  month/
    month-view.tsx              (legacy, kept)
    month-view-v2.tsx           (new)
  schedular-view-filteration.tsx  (selects v2 by default, legacy via query flag)
schedular-view-filteration.tsx reads searchParams.get('calendar') === 'legacy' once on mount. v2 is the default; legacy is a deliberate opt-out for any clinic that hits a v2 edge case. Both versions consume the same useScheduler() context — no provider changes. After one clean release with no rollbacks, the legacy files and the query escape hatch are deleted.

Shared layout primitives

A small set of new components shared by all three v2 views, in ui/src/components/schedule/_components/v2/:
  • EventCard.tsx — the appointment card unit (used by Day and Week). Props: event, compact?: boolean, width?: 'narrow' | 'normal' | 'wide'. Pure render, no state.
  • OverflowPill.tsx — the “+12 at 14:00–17:00” pill used in Day and Week when ≥4 events share a band. Click opens a popover; the popover is portaled and lives outside the day/week grid so it cannot trigger parent re-renders.
  • UtilizationBar.tsx and UtilizationRing.tsx — booked-minutes ÷ operating-minutes visualization. Ring on Month cells, bar on Week day headers.
  • HourHeatStrip.tsx — 10-bar mini heatmap used in Month cells.
  • NowLine.tsx — animated current-time indicator with the live-clock pill. Single timer at the parent, broadcast via CSS variable to avoid re-rendering every minute.

Data indexing — done once per events change

The single biggest source of jank and crashes in the current code is that every render of Week/Month calls state.events.filter(...) for every day cell. With 700 events on screen, Month does ~30,000 filter passes per render. v2 introduces an useDayIndex hook (in ui/src/hooks/use-day-index.ts):
function useDayIndex(events: Event[], filterDoctorId: string | null, filterStatus: string | null) {
  return useMemo(() => {
    const byDate = new Map<string, Event[]>();           // 'YYYY-MM-DD' → events
    const byDateByHour = new Map<string, Map<number, number>>(); // 'YYYY-MM-DD' → hour → count
    // …iterate events ONCE, push into both maps, respecting active filters
    return { byDate, byDateByHour };
  }, [events, filterDoctorId, filterStatus]);
}
Day cells then do byDate.get(dateKey) ?? EMPTY — O(1) per cell. The heat strip on Month reads byDateByHour.get(dateKey) once. EMPTY is a module-level frozen [] so missing days don’t create new array identities and don’t force re-renders downstream.

Memoization discipline

  • No new Date() calls inside render bodies. Today’s date is computed once per mount and revalidated by a useTodayKey hook on a 60s interval (driven by setInterval cleaned up in useEffect).
  • Event grouping (groupEventsByTimePeriod, overlap math) memoized per day in useMemo keyed on the day’s events array.
  • All key props are event.id. No index keys, no Date-as-key.
  • setOpen() is only called inside event handlers — never inside AnimatePresence exit handlers or useEffect cleanup, both of which currently fire during teardown and can re-enter render.

Virtualization

Day view’s hour list is small enough to render normally. The two places virtualization matters:
  • Day view, ultra-dense band: when a single hour resolves to >12 overlapping events at the current zoom, the band renders as a horizontal scrollable strip of EventCard (compact variant) rather than stacking 12 unreadable columns. Implemented with overflow-x-auto and snap points; no virtualization library needed.
  • Overflow popover: the “+N more” popover renders up to 24 items inline, then switches to react-window’s FixedSizeList for the rest. We already use react-window in PatientPicker.tsx and InventoryList.tsx.

Data layer fix

Server

server/src/routes/appointments.ts:68 — bump the cap:
limit: z.coerce.number().min(1).max(2000).optional().default(500),
The default goes 50 → 500 so unmodified callers (e.g. older mobile builds) get a sane window. The cap goes 100 → 2000 so a fully-saturated month (100/day × 30 = 3000) still gets ~99% of rows. For the rare clinic that crosses 2000/month, the client falls back to range-splitting (below) — but no clinic in our current cohort is anywhere close. No new endpoint. No projection changes (the route already returns lean rows; joining is on indexed FKs).

Client

ui/src/lib/serverComm.ts:1996getAppointments becomes:
export async function getAppointments(startDate: string, endDate: string): Promise<Appointment[]> {
  const url = `/api/v1/protected/appointments?startDate=${startDate}&endDate=${endDate}&limit=2000`;
  const response = await fetchWithAuth(url);
  const result = await response.json();
  const rows: Appointment[] = result.data ?? [];

  // Defensive overflow: if we hit the cap, fetch the remainder by splitting the range in half.
  // Recursion depth is bounded by Math.log2(rangeDays) which is ≤7 for any realistic call.
  if (rows.length === 2000 && startDate !== endDate) {
    const mid = midpointDate(startDate, endDate);
    if (mid && mid !== startDate && mid !== endDate) {
      const [left, right] = await Promise.all([
        getAppointments(startDate, mid),
        getAppointments(addDays(mid, 1), endDate),
      ]);
      return dedupeById([...left, ...right]);
    }
  }
  return rows;
}
The split-on-cap branch is the safety net for the 0.1% case. For >99% of calls it never fires. AppointmentCalendar.tsx does not change shape — same query key, same envelope math.

View specifications

Day v2

Layout. Generous left hour gutter (96px), tabular-num hour labels, hairline grid. Hour row height defaults to 120px (was 64px). A small zoom control in the view header lets the user pick 60 / 90 / 120 / 160 px per hour; the choice persists via useRolePersistedState('calendar.day.zoom', 120). Hour band. Subtle 1px hairlines between hours, 8px-dashed half-hour lines. Operating-hours window has a faint background tint; before/after operating hours stay neutral so closed time reads as “off-shift” but is still scrollable. “Now” indicator. NowLine renders a 1px primary stroke across the day column with a small live-clock pill anchored in the gutter. Time updates once a minute via a single useEffect interval at the view root — child cards don’t re-render. EventCard (the unit).
  • 4px left status ribbon: confirmed / pending / cancelled / no-show / completed / in-progress / requested — 7 accessible hues (paired light/dark variants).
  • Time block, top-left, semibold, tabular-num: 09:00 → 09:30.
  • Patient name, 15px semibold, single line + ellipsis.
  • Doctor + room as muted micro-chips below, only rendered if the card is ≥60min tall (≥120px at default zoom).
  • Avatar circle (initials) appears when card width ≥75px.
  • Resting elevation is shadow-sm; hover adds shadow-md + 1px primary outline + reveals the drag handle.
  • Selected gets a 2px primary ring — never a fill swap (avoids the “did it just change status?” confusion).
Overlap handling.
  • 1–2 overlapping cards: side-by-side, equal widths.
  • 3 overlapping cards: side-by-side, equal widths, name + time only.
  • 4+ overlapping cards: render the first 2, replace the rest with a single OverflowPill (“+N at 10:00–11:00”) positioned in the right half of the band. Clicking opens a popover with the full list, click an item to open the detail sheet.
By-doctor swim-lane mode. When ≥2 doctors have events today, a header toggle switches the day grid into vertical lanes per doctor. Hour rail stays on the left. Each lane is independently sized (minmax(180px, 1fr) columns, horizontal scroll if >5 doctors). Overlap math runs inside each lane, not across lanes — making conflicts much rarer per column. Dense-day fallback (>60 events). Cards in saturated hours switch to a compact one-line variant: 09:00 · Smith, Jane · ●confirmed. The hour rail still anchors. No agenda takeover, no scroll-trap inside a column. Clicking any compact card expands it inline to full height for inspection. Empty state. Card centred in the day column with two CTAs: “Add appointment” and “Jump to next booking” (queries the next non-empty day from byDate).

Week v2

Day columns. 7 columns (or N visible on mobile/tablet per existing breakpoints), gap 4px, each column padded 8px. Column header shows:
  • Day name (sentence-case, 12px muted).
  • Big date number (28px semibold, accent on today).
  • Total appointment count (small pill).
  • UtilizationBar — 4px tall, full column width, fills proportional to booked-minutes ÷ operating-minutes for that day. Tooltip on hover: “32 of 48 booked-min (67%)”.
Body. Same hour rail, same EventCard, same overlap math as Day v2 — Week is the same machine, just narrower columns and more of them. Dense column (>24 events). Instead of the existing agenda overlay (which currently covers the column), Week v2 stays in timeline mode and uses OverflowPill per hour band. The day stays readable as a timeline. A small “Open day” link appears in the column header for one-click promotion to Day view. Closed days. Existing “Closed” muted wash with the moon icon is kept verbatim. Cross-month weeks. Fixes the getEventsForDay bug at providers/schedular-provider.tsx:217-228 where setDate(day) on currentDate corrupts the date when the visible week spans a month boundary (e.g. March 30 anchor + April 1 column returned events for March 1). v2 indexes events by 'YYYY-MM-DD' so this class of bug cannot recur.

Month v2

Grid. 7 columns × up to 6 rows, gap 8px, cells 200px tall on desktop / 120px on tablet / 80px on mobile. Generous internal padding (12px desktop / 6px mobile). Cell anatomy.
  • Day number top-left: 24px semibold; muted for non-current-month days; accent for today (background ring + primary colour).
  • UtilizationRing top-right: small SVG ring (24px), arc length proportional to booked-minutes ÷ operating-minutes, with the appointment count rendered inside the ring. Hue follows the threshold scale (emerald < 30%, amber 30–70%, rose >70%).
  • HourHeatStrip mid-cell: 10 thin vertical bars spanning the operating-hours window (default 9–18 → 10 bars representing roughly each hour). Bar intensity = bookings landing in that hour. Tooltip on hover: “11:00 — 6 appointments”.
  • Status legend row at the bottom: up to 4 dots (6px circles) representing the dominant statuses in the cell with counts (e.g. ●8 ●3 ●1). One row, single line, ellipsizes after 4 buckets.
Click target. Whole cell is clickable. Desktop click jumps to Day view for that date. Desktop hover opens a day-peek popover (200ms delay, dismisses on cell leave) showing a compact agenda list — up to 8 visible, “+N more” if longer. Mobile cell click opens the existing “show more events” modal. Why no chips in cells. The existing 3-chip-and-overflow pattern wastes a lot of pixels when most days have ≥4 events — you only see the top 3 by time, the rest are buried. A heat strip + ring + status dots gives the same drill-down power (click → day view) and far more information density: shape of the day, intensity, dominant outcomes, all in one glance, all without text wrap. If post-launch feedback says clinics miss seeing patient names directly in cells, an opt-in toggle (“Show appointment names in month”) can be added behind clinic settings without changing the underlying layout — the heat strip stays as the primary visual.

Component contracts

// EventCard.tsx
interface EventCardProps {
  event: Event;
  variant?: 'normal' | 'compact';  // compact for dense-day fallback
  width?: 'narrow' | 'normal' | 'wide';  // affects what meta is shown
  onClick?: (event: Event) => void;
  isSelected?: boolean;
}

// OverflowPill.tsx
interface OverflowPillProps {
  events: Event[];
  band: { startHour: number; endHour: number };
  anchorRef: React.RefObject<HTMLElement>;
}

// UtilizationBar.tsx / UtilizationRing.tsx
interface UtilizationProps {
  bookedMinutes: number;
  operatingMinutes: number;
  count: number;
  showCount?: boolean;  // true for ring (renders inside), false for bar
}

// HourHeatStrip.tsx
interface HourHeatStripProps {
  byHour: Map<number, number> | undefined;  // hour → count
  windowStart: number;  // operating-hour window start (e.g. 9)
  windowEnd: number;    // operating-hour window end (e.g. 18)
}

// NowLine.tsx
interface NowLineProps {
  startHour: number;
  endHour: number;
  pxPerHour: number;
}
All components are pure render — no internal state, no effects. State (zoom, selection, popover open/closed) lives in the parent view and flows down via props.

React-stability checklist (applies to all three v2 views)

  1. No state.events.filter(…) in render. All day lookups go through useDayIndex.
  2. No new Date() inside render bodies. Today’s date comes from useTodayKey. Per-cell dates come from a memoized day list.
  3. All keys are event.id for events, dateKey for day cells, doctor id for swim lanes.
  4. No setOpen() inside useEffect cleanup, AnimatePresence exit handlers, or framer-motion onAnimationComplete. Only inside onClick/onKeyDown.
  5. No state setters during render. All “current view changed, recompute X” logic moves to useMemo.
  6. Memo every derived array — visibleDays, hourLabels, statusBuckets, heatBars.
  7. One subscription per minute, at the view root. NowLine reads useTodayKey from context, doesn’t poll independently.
  8. Stable references for empty arrays via a module-level const EMPTY: readonly Event[] = Object.freeze([]).
  9. No useEffect watching events to derive anything — everything derived is in useMemo.

Data flow

AppointmentCalendar.tsx
  └── SchedulerProvider (events from query, untouched)
       └── SchedulerViewFilteration (reads ?calendar flag)
            ├── DayViewV2 / DailyView (legacy)
            ├── WeekViewV2 / WeeklyView (legacy)
            └── MonthViewV2 / MonthView (legacy)

v2 views internally:
  useScheduler() → events, filterDoctorId, filterStatus, operatingHours
  useDayIndex(events, filterDoctorId, filterStatus) → { byDate, byDateByHour }
  useMemo(visibleDays(currentDate)) → Date[]
  useMemo(perDayLayouts(byDate, visibleDays)) → Map<dateKey, { cards, overflows }>
  render
The perDayLayouts memo is the most expensive computation — overlap clustering for each day. It runs once per (events, currentDate, view, zoom) change. On a 100/day × 7 days week that’s ~700 events processed once, sub-10ms in practice. Today the same work runs 7× per render across renders, and re-runs on every motion frame.

Accessibility

  • All clickable surfaces (day cells, event cards, pills) are real <button> or <a> elements with focus rings.
  • Heat strip bars include aria-label="11:00, 6 appointments".
  • Utilization ring/bar includes an aria-valuenow/aria-valuemax reading.
  • Keyboard nav: arrow keys move focus across day cells in Month, across hour rows in Day. Enter opens; Esc closes popovers.
  • Reduced motion: prefers-reduced-motion disables card hover elevation, NowLine animation, popover slide-in.
  • Status colour pairs all meet WCAG AA against both light and dark surfaces.

Migration & rollback

  • Single PR. Server limit change + client request change + v2 components + view selector flag. CI passes.
  • Default v2. First load on go.odontox.io/appointments renders v2. Legacy is reachable at ?calendar=legacy.
  • Smoke verification. Manual QA on the canonical test tenant (ssh & Associates, b6d3a3f3-…): day with 0 events, day with 1 event, day with 30 events, day with 100 events; week with cross-month boundary; month with mixed-density days.
  • Rollback path. If any clinic reports a regression, telemetry-flip a default in schedular-view-filteration.tsx (one-line change). One release later, after a clean week, legacy + escape hatch are deleted.

Testing

  • use-day-index.test.ts — given N events across M days, the produced byDate and byDateByHour are correct; ignores events filtered by doctor/status; stable across re-renders if events unchanged.
  • event-card.test.tsx — renders normal/compact variants, status ribbon class for each status, omits doctor chip when width === 'narrow'.
  • overflow-pill.test.tsx — pill renders count and band; popover opens on click; popover renders all events; keyboard escape closes.
  • appointments-pagination.test.ts (server) — limit=2000 returns up to 2000; limit=2001 rejects; default is 500.
  • get-appointments.test.ts (client) — when API returns 2000 rows, the function recursively splits the range and dedupes.
  • Manual visual QA matrix on the test tenant covers the smoke verification list above.
No tests cover the legacy components — they remain frozen.

Risks and open questions

  • Heat strip operating-hours window: clinics with operating hours 7–22 will get 15 hours mapped onto 10 bars (1.5h per bar). Acceptable for shape-recognition; if clinics ask, expand to 12 or 15 bars adaptively. Decision: ship with 10; revisit on first feedback.
  • Swim-lane mode on small screens: not enough width for 4+ doctor columns on a 13” laptop. Decision: enable swim lanes only when viewport ≥1280px; below that the toggle is hidden.
  • ?calendar=legacy escape hatch is read once on mount, not reactive. If a user adds it mid-session they need to refresh. Decision: acceptable — the escape hatch is for support cases, not toggling.
  • Day-peek hover popover on Month: 200ms delay is the touch target tradeoff. On laptop trackpads with imprecise hover, this may feel sluggish. Decision: ship with 200ms; instrument click-vs-hover rates; reduce to 150ms if needed.

Sequencing

  1. Server limit bump — single-line schema change, deploys with the rest.
  2. Client getAppointments rewrite — adds limit param + cap-overflow safety net.
  3. use-day-index hook + shared v2 primitives (EventCard, OverflowPill, UtilizationBar/Ring, HourHeatStrip, NowLine).
  4. day-view-v2.tsx — full implementation with zoom, swim lanes, dense-band fallback.
  5. week-view-v2.tsx — built on the same primitives as Day, with column headers and UtilizationBar.
  6. month-view-v2.tsx — heat strips, utilization rings, day-peek popover.
  7. schedular-view-filteration.tsx — switch default to v2, add legacy query escape.
  8. Tests + manual QA matrix against ssh & Associates tenant.
  9. Release notes + commit/deploy via the odontox-commit-deploy skill.