Linked schedules

Keeps two schedules on the same hours: pan or zoom one and the other follows (also called synchronised views). Reach for it when two sets of lanes stand apart on a screen but belong to the same time.

import { useSchedule } from "@umriss-ui/schedule";

Each schedule has its own useSchedule. Send what one reports to the other's setView, in both directions: the other goes there at once, without a remount, and setView is not reported, so the two never answer each other.

While a drag is in flight the two can stand a frame apart; they settle as soon as it ends.

Examples

Keep two schedules in step

Send what onViewChange reports to the other schedule's setView: pan or zoom either one and the other follows - no key, no remount.

import { Stack, Text } from "@umriss-ui/core";
import { useSchedule } from "@umriss-ui/schedule";
import type { ScheduleParts, Subtask, Task } from "@umriss-ui/schedule";

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const min = (n: number) => n * 60_000;

const DIESEL = [
  { id: "truck-118", label: "Truck FP 118 R" },
  { id: "van-214", label: "Van FP 214 K" },
  { id: "van-402", label: "Van FP 402 R" },
  { id: "truck-520", label: "Truck FP 520 E" },
];

const ELECTRIC = [
  { id: "van-455", label: "E-van FP 455 R" },
  { id: "van-377", label: "E-van FP 377 K" },
];

const CONSIGNMENTS: readonly Task[] = [
  { id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "c-2042", name: "C-2042 Marlow & Finch Books", color: "light-dark(#0d9488, #3cc7b8)" },
  { id: "c-2043", name: "C-2043 Oakridge Pharmacy", color: "light-dark(#c2410c, #f08a52)" },
];

const LEGS: readonly Subtask[] = [
  { id: "c-2041-1", task: "c-2041", lane: "truck-118", from: at(6), to: at(7), leadIn: min(15), leadOut: min(10) },
  { id: "c-2041-2", task: "c-2041", lane: "van-402", from: at(8), to: at(10, 30), leadIn: min(30), leadOut: min(15) },
  { id: "c-2041-3", task: "c-2041", lane: "van-377", from: at(11, 30), to: at(12, 15) },
  { id: "c-2042-1", task: "c-2042", lane: "truck-118", from: at(7, 30), to: at(8, 15), leadIn: min(10) },
  { id: "c-2042-2", task: "c-2042", lane: "van-214", from: at(9), to: at(11), leadIn: min(20), leadOut: min(15) },
  { id: "c-2043-1", task: "c-2043", lane: "truck-520", from: at(6, 30), to: at(8), leadIn: min(30), leadOut: min(15) },
  { id: "c-2043-2", task: "c-2043", lane: "van-402", from: at(10), to: at(11, 30), leadIn: min(15) },
  { id: "c-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) },
];

const on = (lanes: readonly { id: string }[]) => LEGS.filter((leg) => lanes.some((lane) => lane.id === leg.lane));
const DIESEL_LEGS = on(DIESEL);
const ELECTRIC_LEGS = on(ELECTRIC);

const time = (instant: number) => new Date(instant).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });

export default function InStep() {
  /* Both start on the same span; each reports what the reader does to it and
     the other goes there by `setView`, which is not reported in turn. */
  const start = { domain: [at(5, 30), at(18)] } as const;
  const diesel: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => electric.setView(v) });
  const electric: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => diesel.setView(v) });
  const span = diesel.view.domain ?? start.domain;
  return (
    <Stack gap={2}>
      <diesel.Schedule ariaLabel="Diesel vehicles on Tuesday, 17 March" height={240}>
        {DIESEL.map((vehicle) => (
          <diesel.Lane key={vehicle.id} id={vehicle.id} label={vehicle.label} />
        ))}
        <diesel.Subtasks data={DIESEL_LEGS} tasks={CONSIGNMENTS} />
      </diesel.Schedule>
      <electric.Schedule ariaLabel="Electric vans, the same hours" height={152}>
        {ELECTRIC.map((vehicle) => (
          <electric.Lane key={vehicle.id} id={vehicle.id} label={vehicle.label} />
        ))}
        <electric.Subtasks data={ELECTRIC_LEGS} tasks={CONSIGNMENTS} />
      </electric.Schedule>
      <Text size="xs" mono tone="muted" data-span>
        {time(span[0])} – {time(span[1])}
      </Text>
    </Stack>
  );
}

Read a rota beside the incidents

Two schedules with different lanes over one span: who was on call, above the incidents they were paged for.

import { Stack } from "@umriss-ui/core";
import { useSchedule } from "@umriss-ui/schedule";
import type { ScheduleParts, Subtask, Task } from "@umriss-ui/schedule";

/* Data from the operations world, written out here so the example runs on its own. */

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface Engineer {
  id: string;
  name: string;
  team: string;
}

const ENGINEERS: readonly Engineer[] = [
  { id: "priya", name: "Priya Raman", team: "Payments" },
  { id: "jonas", name: "Jonas Keller", team: "Payments" },
  { id: "ada", name: "Ada Mwangi", team: "Identity" },
  { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
  { id: "leila", name: "Leila Haddad", team: "Discovery" },
  { id: "sam", name: "Sam Okafor", team: "Messaging" },
  { id: "ines", name: "Ines Duarte", team: "Integrations" },
  { id: "felix", name: "Felix Brandt", team: "Insights" },
];

interface OnCall {
  id: string;
  engineer: string;
  rotation: "primary" | "secondary";
  from: number;
  to: number;
}

/** Monday 16 to Monday 23 March, handed over every morning at 09:00: the
    secondary of one day is the primary of the next. */
const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [
  { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id },
  { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id },
].map(({ rotation, engineer }) => ({
  id: `${rotation}-${16 + day}`,
  engineer,
  rotation,
  from: at(16 + day, 9),
  to: at(17 + day, 9),
}))).flat();

interface Incident {
  id: string;
  title: string;
  service: string;
  severity: "SEV1" | "SEV2" | "SEV3";
  opened: number;
  acknowledged?: number;
  resolved?: number;
  /** An engineer's id. */
  assignee: string;
}

const INCIDENTS: readonly Incident[] = [
  { id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" },
  { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
  { id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" },
  { id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" },
  { id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" },
  { id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" },
  { id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" },
  { id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" },
];

const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();

const COLORS = [
  "light-dark(#2563eb, #6b9bff)",
  "light-dark(#0d9488, #3cc7b8)",
  "light-dark(#7c3aed, #a98bfa)",
  "light-dark(#c2410c, #f08a52)",
  "light-dark(#be185d, #f06aa6)",
  "light-dark(#4d7c0f, #8fc43e)",
  "light-dark(#0369a1, #5cb8f0)",
  "light-dark(#a16207, #e0b44a)",
];

/* Above, the engineer is the task and the rotation the lane. */
const PEOPLE: readonly Task[] = ENGINEERS.map((engineer, i) => ({ id: engineer.id, name: engineer.name, color: COLORS[i]! }));
const DUTIES: readonly Subtask[] = ONCALL.map((duty) => ({ id: duty.id, task: duty.engineer, lane: duty.rotation, from: duty.from, to: duty.to }));

/* Below, the severity is the task and the service the lane; an open incident runs to now. */
const SEVERITIES: readonly Task[] = [
  { id: "SEV1", name: "SEV1", color: "light-dark(#b91c1c, #f87171)" },
  { id: "SEV2", name: "SEV2", color: "light-dark(#c2410c, #f08a52)" },
  { id: "SEV3", name: "SEV3", color: "light-dark(#57534e, #a8a29e)" },
];
const PAGED: readonly Subtask[] = INCIDENTS.map((incident) => ({
  id: incident.id,
  task: incident.severity,
  lane: incident.service,
  from: incident.opened,
  to: incident.resolved ?? NOW,
  name: `${incident.id} ${incident.title}`,
}));
const HIT = SERVICES.filter((service) => PAGED.some((incident) => incident.lane === service.id));

export default function RotaBesideIncidents() {
  const start = { domain: [day(16, 6), day(17, 14)] } as const;
  const rota: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => incidents.setView(v) });
  const incidents: ScheduleParts = useSchedule({ initialView: start, onViewChange: (v) => rota.setView(v) });
  return (
    <Stack gap={2}>
      <rota.Schedule ariaLabel="Who was on call" height={140}>
        <rota.Lane id="primary" label="Primary" />
        <rota.Lane id="secondary" label="Secondary" />
        <rota.Subtasks data={DUTIES} tasks={PEOPLE} />
      </rota.Schedule>
      <incidents.Schedule ariaLabel="Incidents, the same hours" height={290}>
        {HIT.map((service) => (
          <incidents.Lane key={service.id} id={service.id} label={service.name} />
        ))}
        <incidents.Subtasks data={PAGED} tasks={SEVERITIES} />
      </incidents.Schedule>
    </Stack>
  );
}

When to use something else

Keyboard

The keys of First schedule apply here.

Props on this page

From ScheduleProps — the full table stands on First schedule.

NameTypeDefaultDescription
heightnumber400Height in CSS pixels, both bands included. The width is the host's.
childrenReactNode—Lane, Dependencies and Subtasks, in the order they are to stand and be drawn.

Accessibility

NameTypeDefaultDescription
ariaLabelrequiredstring—What the schedule shows, for a screen reader: "Plan of week 38".

From LaneProps — the full table stands on Lanes.

NameTypeDefaultDescription
idrequiredstring—The identity subtasks name in their lane.
labelReactNodeidWhat the lane header says - the person, vehicle or room. Real text, read by a screen reader.

From SubtasksProps — the full table stands on Subtasks.

NameTypeDefaultDescription
datarequiredreadonly Subtask[]—The subtasks, as the caller holds them. The schedule never changes one.
tasksrequiredreadonly Task[]—The tasks the subtasks belong to - their identity and their colour. A subtask whose task is not among them is drawn in a muted colour.

From Subtask — the full table stands on Subtasks.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the subtask.
taskrequiredstring—The id of the task it belongs to.
lanerequiredstring—The id of the lane it sits on.
fromrequirednumber—Start of the main time.
torequirednumber—End of the main time.
leadInnumber—Duration of the lead-in before the main time; none is zero.
leadOutnumber—Duration of the lead-out after the main time; none is zero.
namestring—What the subtask is called, where the schedule names it.

From Task — the full table stands on Subtasks.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the task.
colorrequiredstring—Any CSS colour - a literal, a token, light-dark(…). The colour means what the caller's domain needs it to mean.
namestring—What the task is called, where the schedule names it.

From ScheduleOptions — the full table stands on View.

NameTypeDefaultDescription
initialViewScheduleView—The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed defaultValue. To go to a view later, call setView. The schedule remembers none: where a view is kept is the application's decision.

Events

NameTypeDefaultDescription
onViewChange(view: ScheduleView) => void—Every change the planner makes, once, always the whole view - the one to keep, or to hand another schedule's setView. A pan or zoom is reported at most once per frame. Not called for the view the schedule starts with, nor for one setView applies.

From ScheduleView — the full table stands on View.

NameTypeDefaultDescription
domainreadonly [number, number]—The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within zoomLimits.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/schedule

Getting started

Plan

Time

Reading

Editing

Measuring

API index