# Linked schedules

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/linked-schedules/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms-full.txt).

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.

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

Demo page: https://romanhaendler.github.io/umriss-ui/schedule/linked-schedules/

Each schedule has its own [`useSchedule`](https://romanhaendler.github.io/umriss-ui/schedule/api/#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.

```tsx
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.

```tsx
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

- The lanes belong together in one plan → Lane groups

## Keyboard

The keys of [First schedule](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#keyboard-schedule) apply here.

## Props on this page

From `ScheduleProps` — the full table stands on [First schedule](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-ScheduleProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`height`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. |
| [`children`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-ariaLabel) *required* | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38". |

From `LaneProps` — the full table stands on [Lanes](https://romanhaendler.github.io/umriss-ui/schedule/lane/#type-LaneProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. |
| [`label`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-label) | `ReactNode` | `id` | What the lane header says - the person, vehicle or room. Real text, read by a screen reader. |

From `SubtasksProps` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-SubtasksProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-data) *required* | `readonly `[`Subtask`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. |
| [`tasks`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-tasks) *required* | `readonly `[`Task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-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](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-id) *required* | `string` | — | The caller's identity of the subtask. |
| [`task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-task) *required* | `string` | — | The id of the task it belongs to. |
| [`lane`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-lane) *required* | `string` | — | The id of the lane it sits on. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-from) *required* | `number` | — | Start of the main time. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-to) *required* | `number` | — | End of the main time. |
| [`leadIn`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-leadIn) | `number` | — | Duration of the lead-in before the main time; none is zero. |
| [`leadOut`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-leadOut) | `number` | — | Duration of the lead-out after the main time; none is zero. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. |

From `Task` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Task).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-id) *required* | `string` | — | The caller's identity of the task. |
| [`color`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-color) *required* | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-name) | `string` | — | What the task is called, where the schedule names it. |

From `ScheduleOptions` — the full table stands on [View](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`initialView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleOptions-initialView) | [`ScheduleView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-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. |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| [`onViewChange`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleOptions-onViewChange) | `(view: `[`ScheduleView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-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](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`domain`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleView-domain) | `readonly [number, number]` | — | The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within `zoomLimits`. |

## Known limits

- Only the view is shared - the span and the folded groups, not the selection, the hover or the vertical scroll.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
