Reports what the pointer does on the plan - hover, click, right-click - with what it hit and the time and lane under it. Reach for it to open a menu, fill a detail panel or write a status line.
import { useSchedule } from "@umriss-ui/schedule";
A hit names a subtask together with the part of it (lead-in, main time, lead-out), a dependency, a lane, or nothing.
While onInteraction is set, a right-click opens no browser menu: your application is expected to answer it.
onInteraction hears hover, click and right-click with the target, its part, the time and the lane - here written into a status line.
import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { useSchedule } from "@umriss-ui/schedule";
import type { Dependency, ScheduleInteraction, 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 CONSIGNMENTS: readonly Task[] = [
{ id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" },
];
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) },
];
const TRANSFERS: readonly Dependency[] = [{ id: "t-2041-1", from: "c-2041-1", to: "c-2041-2", lag: min(10) }];
const describe = (interaction: ScheduleInteraction): string => {
const { hit } = interaction;
/* To the nearest minute: a pixel is about a minute wide here. */
const time = new Date(Math.round(interaction.time / 60_000) * 60_000).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
const target =
hit.kind === "subtask"
? `subtask ${hit.subtask.id} (${hit.part})`
: hit.kind === "dependency"
? `dependency ${hit.dependency.id}`
: hit.kind === "lane"
? "free time"
: "nothing";
return `${interaction.type}: ${target} at ${time}, lane ${interaction.lane ?? "none"}`;
};
export default function ReportThePointer() {
const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } });
const [last, setLast] = useState("Move the pointer over the plan");
return (
<Stack gap={3}>
<Schedule
ariaLabel="One consignment on a truck and a van"
height={152}
onInteraction={(interaction) => setLast(describe(interaction))}
>
<Lane id="truck-118" label="Truck FP 118 R" />
<Lane id="van-402" label="Van FP 402 R" />
<Dependencies data={TRANSFERS} />
<Subtasks data={LEGS} tasks={CONSIGNMENTS} />
</Schedule>
<Text size="sm" mono tone="secondary" data-last-interaction>
{last}
</Text>
</Stack>
);
}
On a contextmenu interaction, open ContextMenu from @umriss-ui/core at clientX and clientY; its entries change your data.
import { useState } from "react";
import { ContextMenu, MenuItem } from "@umriss-ui/core";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { ScheduleInteraction, 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();
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();
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
const ROTATIONS: readonly Task[] = [
{ id: "primary", name: "Primary", color: "light-dark(#2563eb, #6b9bff)" },
{ id: "secondary", name: "Secondary", color: "light-dark(#0d9488, #3cc7b8)" },
];
/* The rotation is the task, the engineer the lane. */
const START: readonly Subtask[] = ONCALL.map((duty) => ({
id: duty.id,
task: duty.rotation,
lane: duty.engineer,
from: duty.from,
to: duty.to,
name: duty.rotation === "primary" ? "Primary" : "Secondary",
}));
export default function OpenAContextMenu() {
const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16, 6), day(23, 12)] } });
const [rota, setRota] = useState<readonly Subtask[]>(START);
const [menu, setMenu] = useState<{ interaction: ScheduleInteraction; duty: Subtask } | null>(null);
/* While `onInteraction` is set, a right-click opens no browser menu. */
const onInteraction = (interaction: ScheduleInteraction) => {
if (interaction.type !== "contextmenu") return;
setMenu(interaction.hit.kind === "subtask" ? { interaction, duty: interaction.hit.subtask } : null);
};
return (
<>
<Schedule
ariaLabel="On-call rota, 16 to 23 March"
height={420}
onInteraction={onInteraction}
>
{ENGINEERS.map((engineer) => (
<Lane key={engineer.id} id={engineer.id} label={engineer.name} />
))}
<Subtasks data={rota} tasks={ROTATIONS} />
</Schedule>
<ContextMenu
open={menu !== null}
position={{ x: menu?.interaction.clientX ?? 0, y: menu?.interaction.clientY ?? 0 }}
onOpenChange={(open) => {
if (!open) setMenu(null);
}}
ariaLabel={menu !== null ? `Hand over ${menu.duty.id}` : "Hand over"}
>
{menu !== null &&
ENGINEERS.filter((engineer) => engineer.id !== menu.duty.lane).map((engineer) => (
<MenuItem
key={engineer.id}
onSelect={() => {
const intent = { kind: "lane", subtask: menu.duty.id, lane: engineer.id } as const;
setRota((current) => current.map((duty) => applyIntent(duty, intent)));
}}
>
Hand to {engineer.name}
</MenuItem>
))}
</ContextMenu>
</>
);
}
The keys of First schedule apply here.
ScheduleInteraction| Name | Type | Default | Description |
|---|---|---|---|
typerequired | | "click" | — | "hover" is reported when the target under the pointer changes, not on
every movement.Shown in: Report what the pointer is on, Open a context menu, Show a detail panel instead (Tooltip) and 1 more |
hitrequired | ScheduleHit | — | What the pointer is on. Shown in: Report what the pointer is on, Open a context menu, Show a detail panel instead (Tooltip) and 2 more |
clientXrequired | number | — | The pointer's horizontal client coordinate - where a ContextMenu opens.Shown in: Open a context menu, Place a mark of your own (The handle), Replan the day on the line (Scenarios) |
clientYrequired | number | — | The pointer's vertical client coordinate. Shown in: Open a context menu, Place a mark of your own (The handle), Replan the day on the line (Scenarios) |
timerequired | number | — | The wall-clock instant under the pointer. Shown in: Report what the pointer is on |
lanerequired | string | null | — | The lane under the pointer, or null below the last one. Shown in: Report what the pointer is on |
ScheduleHitWhat a pointer is on.
type ScheduleHit =
| { readonly kind: "subtask"; readonly subtask: Subtask; readonly part: "leadIn" | "main" | "leadOut" }
| { readonly kind: "dependency"; readonly dependency: Dependency }
| { readonly kind: "milestone"; readonly milestone: Milestone }
| { readonly kind: "lane"; readonly lane: string }
| { readonly kind: "nothing" };