Calendar v2 — Spacious, Production-Grade Day/Week/Month
Problem
-
Data starvation on Week and Month.
GET /api/v1/protected/appointmentscaps 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. - 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?”
-
React fragility. Existing components do per-cell
filter(state.events …)7× (Week) or up to 42× (Month) per render, mutateDateobjects in derivations, and callsetOpenfrom 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 inAppointmentCalendar.tsx:226-230.
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
setOpenfrom 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
handleDragEndsemantics; 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
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, inui/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.tsxandUtilizationRing.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):
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 auseTodayKeyhook on a 60s interval (driven bysetIntervalcleaned up inuseEffect). - Event grouping (
groupEventsByTimePeriod, overlap math) memoized per day inuseMemokeyed on the day’s events array. - All
keyprops areevent.id. No index keys, no Date-as-key. setOpen()is only called inside event handlers — never insideAnimatePresenceexit handlers oruseEffectcleanup, 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’sFixedSizeListfor the rest. We already use react-window inPatientPicker.tsxandInventoryList.tsx.
Data layer fix
Server
server/src/routes/appointments.ts:68 — bump the cap:
Client
ui/src/lib/serverComm.ts:1996 — getAppointments becomes:
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 viauseRolePersistedState('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 addsshadow-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).
- 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.
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%)”.
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, cells200px 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).
UtilizationRingtop-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%).HourHeatStripmid-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.
Component contracts
React-stability checklist (applies to all three v2 views)
- No
state.events.filter(…)in render. All day lookups go throughuseDayIndex. - No
new Date()inside render bodies. Today’s date comes fromuseTodayKey. Per-cell dates come from a memoized day list. - All keys are
event.idfor events,dateKeyfor day cells, doctor id for swim lanes. - No
setOpen()insideuseEffectcleanup,AnimatePresenceexit handlers, or framer-motiononAnimationComplete. Only insideonClick/onKeyDown. - No state setters during render. All “current view changed, recompute X” logic moves to
useMemo. - Memo every derived array — visibleDays, hourLabels, statusBuckets, heatBars.
- One subscription per minute, at the view root.
NowLinereadsuseTodayKeyfrom context, doesn’t poll independently. - Stable references for empty arrays via a module-level
const EMPTY: readonly Event[] = Object.freeze([]). - No
useEffectwatchingeventsto derive anything — everything derived is inuseMemo.
Data flow
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-valuemaxreading. - 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-motiondisables 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/appointmentsrenders 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 producedbyDateandbyDateByHourare 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 whenwidth === '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=2000returns up to 2000;limit=2001rejects; 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.
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=legacyescape 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
- Server limit bump — single-line schema change, deploys with the rest.
- Client
getAppointmentsrewrite — adds limit param + cap-overflow safety net. use-day-indexhook + shared v2 primitives (EventCard,OverflowPill,UtilizationBar/Ring,HourHeatStrip,NowLine).day-view-v2.tsx— full implementation with zoom, swim lanes, dense-band fallback.week-view-v2.tsx— built on the same primitives as Day, with column headers andUtilizationBar.month-view-v2.tsx— heat strips, utilization rings, day-peek popover.schedular-view-filteration.tsx— switch default to v2, add legacy query escape.- Tests + manual QA matrix against ssh & Associates tenant.
- Release notes + commit/deploy via the
odontox-commit-deployskill.

