Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Care schedule

New

A home-care agency’s day board: visits on a 7 AM to 7 PM timeline per caregiver, a live now line, travel links, a visit popover and drag to reassign.

care-schedule/care-schedule.tsx
Live · interactive

Schedule

Monday, Oct 5 · 48 visits · 14 caregivers on shift

Mon, Oct 5 · By caregiver

Caregiver · Hours today
7 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM
All covered48 of 48 visits

Nothing needs cover · Kwame's 3 visits went to Inés, Karim and Siobhán at 8:52 AM

Inés ValderramaCNA · 7.5 h
Adaeze OkaforHHA · 4.5 h
Tomás RibeiroCNA · 7.5 h
Paloma OrtegaCNA · 6.0 h
Selam TesfayeHHA · 5.5 h
Freya LindholmPCA · 4.5 h
Yuki TanabeRN · 5.0 h
Karim NasserHHA · 7.5 h
Siobhán FarrellCNA · 8.5 h
Anders HolmqvistPCA · 4.0 h
Kwame AsanteHHA · Called out

Unavailable today · sick, reported 8:31 AM from the app · his 3 visits were reassigned at 8:52 AM

Dotted links are travel time between visits

Drag, or press Alt with the arrow keys, to move it to another caregiver or time.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A coordinator’s day board for people who go to places: caregivers, technicians, cleaners, each with timed visits across one working day.
  • Days where who covers what changes by the hour: someone calls out, a visit is moved to another row or another time by dragging it.
  • Work whose state comes from the clock: a visit is confirmed, in progress or completed as the now line passes it, with missed and at risk as flags.
  • Schedules where the gap between two jobs matters, because it is time on the road: the dotted links show the travel minutes.
  • Teams of a dozen or two rows that one person reads at a glance, filtered by area and searched by caregiver or client.

When not to use

  • Booking a time for oneself (a client picking a slot). Use a date picker with a list of free times.
  • Rooms, desks or other resources that do not move. Use a resource calendar with columns per resource; there is no travel between them.
  • Planning weeks or months ahead. The Week view only counts visits per day; use a month calendar or a Gantt chart for long plans.
  • Hundreds of rows. The board lists ten caregivers before “Show all” and draws every block; page or virtualise a long roster instead.
  • A single person’s day on a phone. Use a vertical agenda: the timeline reflows narrow but still scrolls sideways.

You can add this component with the shadcn CLI or by hand:

Into components/care-schedule of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/care-schedule.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

schedule-page.tsx
import { CareSchedule } from './care-schedule';export function SchedulePage() {  return (    <CareSchedule      autoplay={false}      defaultArea="harbor"      onReassign={(visit, from) => console.log(visit.client, 'moved from', from.caregiver)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

The scheduling screen of an invented home-care agency. A sidebar holds the agency switcher, search, the sections with an alerts count, the service areas with their visit counts and the coordinator. The schedule lays out the day’s 48 visits on a timeline, one row per caregiver: confirmed, in progress, completed, missed and at-risk visits each have their look, dotted links show the travel time between them, a coverage row sits on top and the caregiver who called out sits at the bottom. A clock moves the now line and starts and finishes visits as it passes them. Click a visit for its popover, drag it to another caregiver or time, step through days, switch to the week or to clients, filter by area, search, or highlight one state from the legend.

  • The board is one 1440 × 1025 design. From 880 px up it is scaled to the root’s width in CSS alone: transform: scale(tan(atan2(100cqw, 1440px))) turns a length over a length into a number, so it is laid out once and looks the same at any size. Under 880 px a container query reflows it: the sidebar becomes a drawer behind a menu button, the controls wrap and the timeline scrolls sideways inside its card with the names pinned.
  • The timeline is shares of the day. Each visit, travel link, hour line and drop target is placed by two custom properties, --s (where it starts) and --w (how long it lasts), as calc(8px + var(--s) * (100% - 16px)), so the same rules serve every width.
  • A visit’s state comes from the clock, not the data: it is confirmed before its start, in progress until its end and completed after, while missed and at risk are flags. The clock runs twelve times faster than real time while the board is on screen (once under reduced motion), and the now line is a full-width track moved by translateX(var(--now) * 100%), with only its left edge seen.
  • Dragging uses pointer events with pointer capture: past 4 px the block follows the pointer by a transform, the row under the pointer lights up, and a dashed slot shows where it would land, snapped to 15 minutes, green when it fits and red when it would overlap. A drop that fits moves the visit, recomputes the travel links around it and announces it; one that does not springs back. Alt and the arrow keys do the same from the keyboard.
  • The popover is placed under the visit’s row and kept inside the board, above the row when there is no room below; a click elsewhere, Escape or a scroll of the timeline closes it.
  • Other days are made from today’s plan: each caregiver’s day slides by up to half an hour, a visit is dropped now and then and weekends are lighter, so day stepping and the week view always have plausible data. Clients regroups the same visits by client.
  • A demo pointer plays the board while it is on screen and untouched: it hovers and opens visits, drags one to another caregiver half an hour later, filters an area, steps to tomorrow and back and highlights a state. Any real pointer, key or wheel input hands control to the reader; it resumes six seconds after they leave, and stops off screen, in a hidden tab, under reduced motion and with autoplay={false}.
  • Avatars and the agency mark are inline SVG illustrations, never photos, and every colour is a light-dark() token on the root.
PropTypeDefaultDescription
autoplaybooleantruePlays the demo pointer while the board is on screen and untouched. Never under reduced motion.
colorScheme'light' | 'dark' | 'system'—Forces a colour scheme. Without it the board follows the page’s color-scheme.
defaultNowstring'13:38'The time the clock starts at, H:MM on a 24-hour clock.
clockSpeednumber12How many times faster than real time the clock runs (always 1 under reduced motion).
defaultSelectedstring | null'ines-1'The visit whose popover is open at first, or null for none.
defaultArea'all' | 'millbrook' | 'harbor' | 'elm' | 'fallow''all'The service area the board opens filtered to.
defaultView'day' | 'week''day'Opens on the day timeline or on the week.
defaultGroup'caregivers' | 'clients''caregivers'One row per caregiver, or one per client.
defaultHighlight'confirmed' | 'in-progress' | 'completed' | 'missed' | 'at-risk' | nullnullThe state the legend highlights at first; the other visits fade. Set, it keeps the demo off.
defaultQuerystring''What the search holds at first: matching caregivers and clients stay, the rest fade or go. Set, it keeps the demo off.
onReassign(visit: Visit, from: { caregiver: string; start: number }) => void—Called when a visit is dropped on another caregiver or time, by the reader or the demo; start is in minutes after midnight.
onOpenVisit(visit: Visit) => void—Called by the popover’s Open visit button.
onMessage(visit: Visit) => void—Called by the popover’s Message button.
classNamestring—Added to the root, to size or place the board.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

care-schedule.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useMemo,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { Avatar } from './avatar';import {  AREAS,  CAREGIVERS,  DAY_END,  DAY_START,  FIRST_ROWS,
care-schedule.module.css
/* Care schedule (care-schedule.tsx): a warm paper frame with a white panel, one near-black for   what is happening now, greys for what is done, red for missed and amber for at risk; by night   a warm charcoal frame with the same roles.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme).   Wide (the root 880 px and up): the frame is the 1440 × 1025 design scaled to the root's width   by a transform (tan(atan2()) turns a length over a length into a number), so it is laid out   once and looks the same at every size. Narrow: a container query reflows it: the sidebar   becomes a drawer, the controls wrap and the timeline scrolls sideways inside its card.   The timeline is shares of the day: each block, link and line is placed by --s (where it   starts) and --w (how long it lasts), so the same rules serve every width. The now line moves   by a transform. */@font-face {  font-family: CareInter;  font-weight: 300 700;  font-style: normal;  font-display: swap;  src:    local('Inter Variable'),
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The invented agency behind the schedule: its caregivers, today's 48 visits and the clients they * go to, and how any other day of the week is made from today (each caregiver's day slides by up * to half an hour and loses a visit now and then, so the schedule changes but stays plausible). * Times are minutes after midnight; the day runs from 7 AM to 7 PM. */export type AreaId = 'millbrook' | 'harbor' | 'elm' | 'fallow';export const AREAS: { id: AreaId; label: string }[] = [  { id: 'millbrook', label: 'Millbrook' },  { id: 'harbor', label: 'Harbor Point' },  { id: 'elm', label: 'Elm Ridge' },  { id: 'fallow', label: 'West Fallow' },];/** How an illustrated avatar is drawn: skin, hair colour and cut, and the backdrop. */export type Look = {  skin: string;  hair: string;  cut: 'short' | 'bun' | 'long' | 'curly' | 'crop' | 'bald' | 'wavy';  backdrop: string;  shirt: string;
avatar.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { Look } from './data';/* * An illustrated portrait, never a photo: a backdrop, shoulders, a face and a haircut, from the * person's `look`. Drawn on a 40-unit grid; the stylesheet sets its size and its round or * rounded-square crop. Decorative: the name always stands next to it. */const HAIR: Record<Look['cut'], { back?: string; front: string }> = {  short: {    front:      'M11.5 17.5c-.6-6.4 3.5-9.8 8.5-9.8s9.2 3.4 8.5 9.8c-1.6-2.9-4.2-4.6-8.5-4.6-4.2 0-6.9 1.7-8.5 4.6z',  },  crop: { front: 'M12 16.5c0-5.3 3.4-8 8-8s8 2.7 8 8c-2.2-1.8-4.8-2.5-8-2.5s-5.8.7-8 2.5z' },  bun: {    back: 'M20 3.6a3.6 3.6 0 1 1 0 7.2 3.6 3.6 0 0 1 0-7.2z',    front:      'M11.4 19c-.9-6.8 3.2-10.6 8.6-10.6s9.5 3.8 8.6 10.6c-1.1-3.6-3.9-6.2-8.6-6.2s-7.5 2.6-8.6 6.2z',  },  long: {    back: 'M10.4 30c-1.6-4.6-1.9-10.4-.2-14.6C12 10.9 15.6 8.4 20 8.4s8 2.5 9.8 7c1.7 4.2 1.4 10-.2 14.6z',    front:
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { ReactNode } from 'react';/* * The schedule's icons: line drawings on a 20-unit grid (framed to its middle 15 units) in the current colour, sized by the * stylesheet (the lab forces unsized SVGs to 16 px). All of them are decorative: the control * that holds one carries the name. */const paths: Record<string, ReactNode> = {  switch: (    <>      <path d="M7 15V5M4.5 7.5 7 5l2.5 2.5" />      <path d="M13 5v10m-2.5-2.5L13 15l2.5-2.5" />    </>  ),  search: (    <>      <circle cx="9" cy="9" r="5.25" />      <path d="m13 13 3.5 3.5" />    </>  ),  dashboard: (
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {  /** Resolves after `ms`. */
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` and `reduced` are handed out too, for * the clock (it stops off screen) and the transitions. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);
  • The schedule is a table of rows: each caregiver is a row header and each visit a button named by its client, time and state, described by how to move it. The popover is a labelled dialog the visit controls (aria-expanded); Escape closes it and returns focus to the visit.
  • Alt with the arrow keys moves a focused visit to the caregiver above or below, or 15 minutes earlier or later; a move that would overlap is refused and said. Every move, filter and day change is announced in a polite live region.
  • The area picker is a listbox button: arrow keys, Home and End move, Enter picks and Escape closes and returns focus. Day/Week, Caregivers/Clients, the legend and the sidebar areas are toggle buttons with aria-pressed; the current section has aria-current.
  • On the narrow layout the menu button reports aria-expanded and Escape closes the drawer. ⌘K or Ctrl+K focuses the search.
  • Illustrated avatars, icons, the travel links, hour lines and the now line are hidden from assistive technology; the demo pointer is decorative. Reduced motion stops the demo and the transitions and runs the clock at real time.