Connect Schedule
Status: Day, Week and Month views shipped (#2388). Coverage below is partial: it describes the grid module and how it handles bad data, not every page detail.
Overview
/connect/schedule shows a practitioner's appointments across every facility they work at. It
also shows a vendor's own 1:1 bookings, which have no tenant and no facility. It reads
useMyAppointments (@kulcare/shared-services) and offers three views:
| View | Component | Built by |
|---|---|---|
| Day (agenda list) | SchedulePage → groupByDay | pages/SchedulePage.tsx |
| Week (default) | components/schedule/WeekGrid.tsx | buildWeekGrid |
| Month | components/schedule/MonthGrid.tsx | buildMonthGrid |
Status and facility filters apply to all three.
Rows from GET /connect/me/appointments
Each row has a kind:
kind | Source | tenant, facility, patient, practitioner | attendee |
|---|---|---|---|
facility_appointment | The user's tenant memberships (per-tenant fan-out) | Set | null |
vendor_booking | The user's own Identity::Vendor (appointable), tenant_id NULL | null | { id, name } of the client, or null if the account was deleted |
The endpoint reads vendor bookings outside the membership fan-out, so a vendor with no membership
still gets them. A facility_id filter leaves vendor bookings out. Inside the fan-out, each scope
is pinned to the membership tenant, because the RLS policy lets a NULL-tenant row through. The
dashboard summary (GET /connect/dashboard) counts vendor bookings the same way. attendee has
no phone or email.
Technical architecture
All placement logic is in frontend/apps/connect/src/components/schedule/scheduleGrid.ts. The
module is pure: it takes appointments and dates, and returns plain data. It does not log or render.
sliceAppointmentForDay(apt, day)clips an appointment to one local calendar day. An overnight appointment gives one slice on each day. Intervals are half-open ([start, end)).layoutDayColumnpositions the slices for one day and splits overlapping ones into columns.buildWeekGridandbuildMonthGridbuild the two grids from these functions.unplaceableAppointments(apts)returns the rows that no grid can place. It uses the same privateappointmentIntervalparser assliceAppointmentForDay, so what the grids drop and what the page reports as dropped cannot drift apart.
Appointments with a bad timestamp
A row is left out of the grids when its start_time does not parse, or when its end_time is
present but does not parse. (If end_time is missing, the row is drawn as a point at its start.)
The day list needs only a start time. unlistableAppointments in SchedulePage.tsx counts the
rows the day list skips, using the same rule that groupByDay applies.
A missing row must never look like a free slot, so SchedulePage:
- Shows a status line above the view: "N appointments have an invalid time and are not shown."
The line appears only when the query is in the
readyphase. It is not shown above the error or offline panel. - Logs
console.warn('[SchedulePage] appointments left off the schedule: …', { count, appointmentIds })in every view, whenever the set of hidden rows changes. Re-renders, refetches, and view changes that leave the same rows hidden do not log again. A row with noidstill counts, and its raw start time takes the place of the id in the key.groupByDayand the grid builders do not log, because they run on every render.
The week footer ("N appointments this week") counts only the rows it placed. The status line reports the rows it left out.
Days that are not 24 hours long
A day's end is the next local midnight, not 24 hours after the day starts. On a DST transition day
(23 or 25 hours long), this puts each chip in the correct column. The position of a chip within a
transition day can still be off by one hour, because startMinute counts elapsed time since
midnight. The comment in sliceAppointmentForDay records this.
Testing
| File | What it pins |
|---|---|
components/schedule/__tests__/scheduleGrid.test.ts | Placement, overlap, month padding. Timestamps have no zone, so the file passes in any time zone. |
components/schedule/__tests__/scheduleGrid.dst.tz.test.ts | Which day a row lands on across both 2026 New York transitions (8 March, 1 November). |
src/__tests__/ScheduleGridViews.test.tsx | The page end to end, including the invalid-time status line. |
Zone-pinned tests must be named *.tz.test.ts(x). A vitest worker thread keeps the time zone
it started with, so setting process.env.TZ in a thread has no effect. The test then runs in the
runner's zone and still passes. apps/connect/vitest.config.ts uses poolMatchGlobs to run
*.tz.test.* files in a forked process, where the change takes effect. The connect entry in
frontend/vitest.workspace.ts does the same for workspace runs. The DST file first checks
that the zone is active: its first test in each block asserts that the transition day is 23 or 25
hours long.
Last Updated: 2026-09-29