Kulcare Docs
DeveloperConnectSchedule

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:

ViewComponentBuilt by
Day (agenda list)SchedulePage → groupByDaypages/SchedulePage.tsx
Week (default)components/schedule/WeekGrid.tsxbuildWeekGrid
Monthcomponents/schedule/MonthGrid.tsxbuildMonthGrid

Status and facility filters apply to all three.

Rows from GET /connect/me/appointments

Each row has a kind:

kindSourcetenant, facility, patient, practitionerattendee
facility_appointmentThe user's tenant memberships (per-tenant fan-out)Setnull
vendor_bookingThe user's own Identity::Vendor (appointable), tenant_id NULLnull{ 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)).
  • layoutDayColumn positions the slices for one day and splits overlapping ones into columns.
  • buildWeekGrid and buildMonthGrid build the two grids from these functions.
  • unplaceableAppointments(apts) returns the rows that no grid can place. It uses the same private appointmentInterval parser as sliceAppointmentForDay, 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:

  1. 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 ready phase. It is not shown above the error or offline panel.
  2. 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 no id still counts, and its raw start time takes the place of the id in the key. groupByDay and 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

FileWhat it pins
components/schedule/__tests__/scheduleGrid.test.tsPlacement, overlap, month padding. Timestamps have no zone, so the file passes in any time zone.
components/schedule/__tests__/scheduleGrid.dst.tz.test.tsWhich day a row lands on across both 2026 New York transitions (8 March, 1 November).
src/__tests__/ScheduleGridViews.test.tsxThe 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

On this page