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.
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>
);
}
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>
);
}
The keys of First schedule apply here.
From ScheduleProps — the full table stands on First schedule.
| Name | Type | Default | Description |
|---|---|---|---|
height | number | 400 | Height in CSS pixels, both bands included. The width is the host's. |
children | ReactNode | — | Lane, Dependencies and Subtasks, in the order they are to stand and
be drawn. |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabelrequired | string | — | What the schedule shows, for a screen reader: "Plan of week 38". |
From LaneProps — the full table stands on Lanes.
From SubtasksProps — the full table stands on Subtasks.
From Subtask — the full table stands on Subtasks.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | The caller's identity of the subtask. |
taskrequired | string | — | The id of the task it belongs to. |
lanerequired | string | — | The id of the lane it sits on. |
fromrequired | number | — | Start of the main time. |
torequired | number | — | End of the main time. |
leadIn | number | — | Duration of the lead-in before the main time; none is zero. |
leadOut | number | — | Duration of the lead-out after the main time; none is zero. |
name | string | — | What the subtask is called, where the schedule names it. |
From Task — the full table stands on Subtasks.
From ScheduleOptions — the full table stands on View.
| Name | Type | Default | Description |
|---|---|---|---|
initialView | ScheduleView | — | 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. |
| Name | Type | Default | Description |
|---|---|---|---|
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. |
What umriss deliberately does not build, and why: ADR-0032.