Schedule
A day of the vessel's rota against the clock, one row per crew member or piece of equipment.
On this page
Fri 17 Oct · by crew
- Planned
- In progress
- Done
- Missed
- At risk
Nothing needs cover. Jo's watches went to Mira and Tom at 06:20 UTC.
Off sick, reported 06:10 UTC. Her watches went to Mira and Tom at 06:20 UTC.
Code
import { Schedule, type ScheduleEntry, type ScheduleLane } from "@bosunkit/components/schedule";
import type { ReactElement } from "react";
import { STATE_LABELS, at, formatRest, formatTime } from "./rota-data";
export const description: string =
"A day's watch rota: one row per crew member, the watches where their hours fall, the rest between them on a " +
"dotted line, now drawn at 13:38 UTC, and a crew member off sick whose watches went to the others.";
const LANES: readonly ScheduleLane[] = [
{ key: "ana", label: "Ana Ribeiro", detail: "Skipper · 6 hr" },
{ key: "sam", label: "Sam Okafor", detail: "Mate · 8 hr" },
{ key: "mira", label: "Mira Haddad", detail: "Watch leader · 8 hr" },
{ key: "tom", label: "Tom Kelleher", detail: "Deckhand · 7 hr" },
{ key: "jo", label: "Jo Lindqvist", detail: "Deckhand · off sick", note: "Off sick, reported 06:10 UTC. Her watches went to Mira and Tom at 06:20 UTC." },
];
const ENTRIES: readonly ScheduleEntry[] = [
{ id: "a1", lane: "ana", title: "Passage briefing", start: at(7), end: at(8), state: "done" },
{ id: "a2", lane: "ana", title: "Bridge watch", start: at(12), end: at(16), state: "in-progress" },
{ id: "s1", lane: "sam", title: "Morning watch", start: at(6), end: at(8), state: "done" },
{ id: "s2", lane: "sam", title: "Rig check", start: at(9), end: at(10), state: "missed" },
{ id: "s3", lane: "sam", title: "Forenoon watch", start: at(10), end: at(12), state: "done" },
{ id: "s4", lane: "sam", title: "First watch", start: at(16), end: at(18) },
{ id: "m1", lane: "mira", title: "Forenoon watch", start: at(8), end: at(12), state: "done" },
{ id: "m2", lane: "mira", title: "Engine rounds", start: at(14), end: at(15) },
{ id: "t1", lane: "tom", title: "Galley", start: at(7), end: at(9), state: "done" },
{ id: "t2", lane: "tom", title: "Afternoon watch", start: at(12), end: at(16), state: "at-risk" },
];
export default function WatchRota(): ReactElement {
return (
<Schedule
label="Watch rota, Friday 17 October"
heading="Fri 17 Oct · by crew"
laneHeading="Crew · hours"
lanes={LANES}
entries={ENTRIES}
range={[at(6), at(18)]}
now={at(13, 38)}
formatTime={formatTime}
timeReference="UTC"
stateLabels={STATE_LABELS}
formatGap={formatRest}
onNavigate={(): void => undefined}
summary={{
title: "All watches covered",
detail: "10 of 10 watches",
note: "Nothing needs cover. Jo's watches went to Mira and Tom at 06:20 UTC.",
kind: "covered",
}}
renderDetail={(entry: ScheduleEntry): ReactElement => (
<p style={{ margin: 0 }}>
{formatTime(entry.start)} to {formatTime(entry.end)} UTC.
</p>
)}
footer="Showing 5 of 5 crew"
footerEnd="Dotted lines are rest between watches"
/>
);
}
A schedule lays out one day of a rota: the watches, the maintenance jobs or the tasks in port. Each lane is a crew
member or a piece of equipment, with a name, one more line (the role and the hours that day) and their initials or
photo. Each entry sits where its hours fall, with its title and its hours. The hours run across the top every
step, an hour by default, with the time reference at the end of the axis; with now in the range, a line runs
down every lane with its time on the axis.
An entry is named in full for a screen reader, with its hours, their reference and its state ("Bridge watch,
12:00–16:00 UTC, In progress"). Given renderDetail, an entry is a button that opens its detail in a popover
titled by the entry. The entries are one stop for Tab, and the arrow keys move between them: along a lane, and to
the entry in the next lane nearest in time; Home and End go to a lane's first and last, and Control with them to
the first and last of all.
Zoom in and Zoom out in the head widen and narrow the hours, keeping the same time in the middle of the view; the
whole day fits at the widest. Given onNavigate, Previous day, Today and Next day ask the caller for another day: the
caller works out the new range in the vessel's day, ship's time or UTC, and passes it back.
Given changeAction, the crew can change the rota. An entry dragged along its lane moves its hours, dragged onto
another lane goes to that crew member or piece of equipment, and its start or end edge dragged changes that end; a
lane with a note takes no entries. On a touch screen a swipe scrolls the rota; a finger resting on an entry for a
moment picks it up, ringed, and only then does it move. On a focused entry, Alt with Left or Right moves it, Shift added changes its end,
and Alt with Up or Down moves it to the next lane; each step is announced with the new hours and lane, releasing Alt
saves and Escape puts it back. Every move snaps to moveStep, a quarter of an hour by default, and stays within the
range. While changeAction is pending, the entry stays faded where it was and a dashed ghost shows it at its new
hours; it moves only once the caller passes the entries back changed. A refusal clears the ghost and shows its reason
above the lanes. An entry with locked, such as a watch already stood, does not move.
Entries that share hours in a lane are drawn one above the other, each with a layers mark and named as overlapping
the other ("Bridge watch, 08:00–12:00 UTC, Planned, overlaps Chart update"). A move onto hours another entry in the
new lane holds is refused before changeAction is asked, with the reason ("Not moved: Sam Okafor already has Bilge
check from 09:00 to 10:00 UTC"), and its ghost turns solid while it sits there; allowOverlap lets such a move
through, for lanes where work can run side by side. Given minRest, a gap shorter than it is drawn as a solid line
with an hourglass, and the entry after it is named as having a short rest before it.
restRule checks each lane against the rules on hours of rest over every 24 hours and 7 days that overlap the range:
the least rest in 24 hours and in 7 days, the most periods the day's rest may be split into, the least length of the
longest of them, and the longest work between two rests, each checked when set. A lane that meets them says "Rest hours
met" under its name; one that does not lists each limit it breaks with the caller's durations ("7 hr rest in 24 hours,
under 10 hr"). Time without an entry counts as rest, so the week's limit needs the entries of the days before.
Given onCreate, dragging across an empty part of a lane marks the hours for a new entry, covering every moveStep
the drag touched, with a ghost showing them; a click marks one step from the moveStep clicked in. The schedule
hands the slot to the caller, which opens its own form to name the entry and passes it back in entries. New entry
in the head places a slot one step long from the quarter hour of now (or the range's start) in the first lane that
takes entries, and focuses it: Left and Right move it, Shift with them changes its end, Up and Down move it to another
lane, each step read out; Enter hands it to onCreate and Escape drops it, and focus returns to New entry.
On passage the ship's clock is advanced or retarded at a set time. Every time in a schedule is an instant, so a watch
across the change keeps its true length. Each of clockChanges draws a dashed line down the lanes at its instant, with
its words on the axis ("Clocks advanced 1 hr"), and every time is printed with the reference in force when it falls: an
entry across the change is named "12:00 UTC+1–17:00 UTC+2". formatTime writes each instant in the ship's time of
that moment, so the hours on the axis skip one after an advance and repeat one after a retard.
formatGap writes the time between two entries in a lane on a dotted line between them, such as the rest between
two watches. A lane with a note shows it across its hours in place of entries, its name dimmed. A summary sums
the day up above the lanes. On a narrow screen the hours scroll sideways while the lanes' names stay put.
The caller writes every word of the rota and every time: formatTime writes a time without its reference, which
timeReference gives. The component's own words, its buttons and the hint on moving an entry, come from the catalog.
Examples
Rescheduling
Fri 17 Oct · by crew
- Planned
- In progress
- Done
- Missed
- At risk
Code
import {
Schedule,
type ScheduleChange,
type ScheduleChangeResult,
type ScheduleEntry,
type ScheduleLane,
type ScheduleRestRule,
type ScheduleSlot,
} from "@bosunkit/components/schedule";
import { useState, type ReactElement } from "react";
import { STATE_LABELS, at, formatDuration, formatRest, formatTime } from "./rota-data";
export const description: string =
"A rota the crew can change: drag a watch along its row or onto another crew member's, drag its edges, or hold " +
"Alt and use the arrow keys. Watches already stood are locked, a move onto hours the crew member already has is " +
"refused with the reason, and rest under two hours is marked. Each row checks the rules on hours of rest. Dragging " +
"across an empty part of a row adds a task.";
const LANES: readonly ScheduleLane[] = [
{ key: "ana", label: "Ana Ribeiro", detail: "Skipper" },
{ key: "sam", label: "Sam Okafor", detail: "Mate" },
{ key: "mira", label: "Mira Haddad", detail: "Watch leader" },
{ key: "tom", label: "Tom Kelleher", detail: "Deckhand" },
];
const FIRST_ENTRIES: readonly ScheduleEntry[] = [
{ id: "a1", lane: "ana", title: "Morning watch", start: at(6), end: at(8), state: "done", locked: true },
{ id: "a2", lane: "ana", title: "Afternoon watch", start: at(12), end: at(16) },
{ id: "s1", lane: "sam", title: "Forenoon watch", start: at(8), end: at(12), state: "done", locked: true },
{ id: "s2", lane: "sam", title: "First dog watch", start: at(16), end: at(18) },
{ id: "m1", lane: "mira", title: "Engine rounds", start: at(14), end: at(15) },
{ id: "t1", lane: "tom", title: "Galley", start: at(7), end: at(9), state: "done", locked: true },
{ id: "t2", lane: "tom", title: "Rig check", start: at(10), end: at(11) },
];
const HOUR: number = 3_600_000;
/** The rules on hours of rest at sea: 10 hours in any 24, 77 in any 7 days, two periods at most, one of 6 hours. */
const REST_RULE: ScheduleRestRule = {
perDay: 10 * HOUR,
perWeek: 77 * HOUR,
mostPeriods: 2,
longestAtLeast: 6 * HOUR,
longestWork: 14 * HOUR,
formatDuration,
};
/** The time a save takes, so the pending ghost shows. */
const SAVE_DELAY: number = 600;
export default function Rescheduling(): ReactElement {
const [entries, setEntries]: [readonly ScheduleEntry[], (entries: readonly ScheduleEntry[]) => void] =
useState<readonly ScheduleEntry[]>(FIRST_ENTRIES);
// A stand-in for bosund, which saves every change; the schedule itself refuses a move onto taken hours.
async function changeAction(change: ScheduleChange): Promise<ScheduleChangeResult> {
await new Promise((resolve: (value: unknown) => void): void => {
setTimeout(resolve, SAVE_DELAY);
});
setEntries(
entries.map(
(entry: ScheduleEntry): ScheduleEntry =>
entry.id === change.entry.id ? { ...entry, lane: change.lane, start: change.start, end: change.end } : entry,
),
);
return { status: "saved" };
}
// A stand-in for the form a caller opens to name the new entry: it adds a planned task in the marked hours.
function onCreate(slot: ScheduleSlot): void {
setEntries([...entries, { id: `new-${String(slot.start)}-${slot.lane}`, title: "New task", ...slot }]);
}
return (
<Schedule
label="Watch rota, Friday 17 October"
heading="Fri 17 Oct · by crew"
laneHeading="Crew"
lanes={LANES}
entries={entries}
range={[at(6), at(18)]}
formatTime={formatTime}
timeReference="UTC"
stateLabels={STATE_LABELS}
formatGap={formatRest}
minRest={2 * HOUR}
restRule={REST_RULE}
changeAction={changeAction}
onCreate={onCreate}
/>
);
}
Clock change on passage
Fri 17 Oct · passage to Horta
- Planned
- In progress
- Done
- Missed
- At risk
Code
import { Schedule, type ScheduleEntry, type ScheduleLane } from "@bosunkit/components/schedule";
import type { ReactElement } from "react";
import { STATE_LABELS, at, formatRest, formatTime } from "./rota-data";
export const description: string =
"A watch rota on passage east, where the ship's clock is advanced an hour at 14:00 UTC: the line marks the jump, " +
"the hours on the axis skip one, and the afternoon watch across it still lasts four hours, 12:00 UTC+1 to " +
"17:00 UTC+2.";
const HOUR: number = 3_600_000;
/** When the clock is advanced, from UTC+1 to UTC+2. */
const CHANGE: number = at(14);
/** A time in ship's time: UTC+1 before the change, UTC+2 from it. */
function shipTime(time: number): string {
return formatTime(time + (time < CHANGE ? 1 : 2) * HOUR);
}
const LANES: readonly ScheduleLane[] = [
{ key: "ana", label: "Ana Ribeiro", detail: "Skipper · red watch" },
{ key: "sam", label: "Sam Okafor", detail: "Mate · white watch" },
{ key: "tom", label: "Tom Kelleher", detail: "Deckhand · blue watch" },
];
const ENTRIES: readonly ScheduleEntry[] = [
{ id: "a1", lane: "ana", title: "Forenoon watch", start: at(7), end: at(11), state: "done" },
{ id: "s1", lane: "sam", title: "Afternoon watch", start: at(11), end: at(15), state: "in-progress" },
{ id: "t1", lane: "tom", title: "Morning watch", start: at(3), end: at(7), state: "done" },
{ id: "t2", lane: "tom", title: "Evening watch", start: at(15), end: at(19) },
];
export default function ClockChange(): ReactElement {
return (
<Schedule
label="Watch rota on passage, Friday 17 October"
heading="Fri 17 Oct · passage to Horta"
laneHeading="Crew"
lanes={LANES}
entries={ENTRIES}
range={[at(5), at(17)]}
now={at(13, 10)}
formatTime={shipTime}
timeReference="UTC+1"
clockChanges={[{ at: CHANGE, reference: "UTC+2", label: "Clocks advanced 1 hr" }]}
stateLabels={STATE_LABELS}
formatGap={formatRest}
/>
);
}
Maintenance by equipment
Fri 17 Oct · by equipment
- Planned
- In progress
- Done
- Missed
- At risk
Code
import { Schedule, type ScheduleEntry, type ScheduleLane } from "@bosunkit/components/schedule";
import type { ReactElement } from "react";
import { STATE_LABELS, at, formatTime } from "./rota-data";
export const description: string =
"The day's maintenance by piece of equipment: one job is at risk of overrunning the watermaker's service, and the " +
"jobs open nothing, because this schedule only shows the plan.";
const LANES: readonly ScheduleLane[] = [
{ key: "engine", label: "Main engine", detail: "Yanmar 4JH · 2 jobs" },
{ key: "watermaker", label: "Watermaker", detail: "1 job" },
{ key: "rig", label: "Standing rig", detail: "1 job" },
];
const ENTRIES: readonly ScheduleEntry[] = [
{ id: "e1", lane: "engine", title: "Impeller", start: at(8), end: at(9, 30), state: "done" },
{ id: "e2", lane: "engine", title: "Oil and filter", start: at(10), end: at(11), state: "in-progress" },
{ id: "w1", lane: "watermaker", title: "Membrane flush", start: at(9), end: at(12), state: "at-risk" },
{ id: "r1", lane: "rig", title: "Shroud tension", start: at(13), end: at(14) },
];
export default function Maintenance(): ReactElement {
return (
<Schedule
label="Maintenance, Friday 17 October"
heading="Fri 17 Oct · by equipment"
laneHeading="Equipment · jobs"
lanes={LANES}
entries={ENTRIES}
range={[at(8), at(14)]}
now={at(10, 20)}
formatTime={formatTime}
timeReference="UTC"
stateLabels={STATE_LABELS}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | – | The schedule's name for a screen reader ("Watch rota, Friday 17 October"), translated by the caller. |
| heading | string | undefined | – | The line at the top of the schedule ("Fri 17 Oct · by crew"), written by the caller. |
| laneHeadingrequired | string | – | The heading of the lanes' column ("Crew · hours"), translated by the caller. |
| lanesrequired | readonly ScheduleLane[] | – | The rows, in the order they are drawn. |
| entriesrequired | readonly ScheduleEntry[] | – | The entries, in any order; each names its lane. |
| rangerequired | readonly [number, number] | – | The hours the schedule spans, as [start, end) in epoch milliseconds; whole hours draw the clearest axis. |
| formatTimerequired | (at: number) => string | – | Writes a time as the crew read it ("08:00"), without its reference, which timeReference gives. |
| timeReferencerequired | string | – | The reference every time on the schedule is in ("UTC", "ship's time"), printed with the axis and the now line. |
| clockChanges | readonly ScheduleClockChange[] | undefined | [] | Changes of the ship's clock: each draws a line down the lanes with its words on the axis, and every time after it
is printed with its reference. formatTime writes each instant in the ship's time in force then. timeReference
is the reference before the first change. |
| stateLabelsrequired | Readonly<Record<ScheduleEntryState, string>> | – | The name of each state, for the legend and each entry's name, translated by the caller. |
| now | number | undefined | – | Now, in epoch milliseconds: a line across the lanes with its time, when it falls in range. |
| step | number | undefined | HOUR | The axis' step, in milliseconds; an hour by default. |
| formatGap | ((gap: number) => string | undefined) | undefined | – | Writes the time between two entries in a lane ("8 hr rest"), drawn on a dotted line between them; nothing is
drawn between entries when unset or when it returns undefined. Given the gap in milliseconds. |
| summary | ScheduleSummary | undefined | – | A line above the lanes that sums the day up. |
| onNavigate | ((target: ScheduleNavigation) => void) | undefined | – | Asks the caller for another day: Previous day, Today and Next day appear in the head when set. The caller works
out the new range, in the vessel's day (ship's time or UTC), and passes it back. |
| changeAction | ((change: ScheduleChange) => Promise<ScheduleChangeResult>) | undefined | – | Saves a change the crew made to an entry: dragged along its lane or onto another, an edge dragged, or moved with Alt and the arrow keys. Entries can be moved only when set. Until it resolves the entry shows at its new hours as pending; a refusal puts it back and shows the reason. |
| moveStep | number | undefined | HOUR / 4 | The step a move, a resize or a new slot snaps to, in milliseconds; a quarter of an hour by default. |
| allowOverlap | boolean | undefined | false | Whether a move may put an entry on hours another entry in its new lane holds; refused with the reason unless set, since one crew member cannot stand two watches. Entries that overlap are drawn one above the other either way. |
| minRest | number | undefined | – | The least rest between two entries in a lane, in milliseconds. A shorter gap is marked on its line, and the entry after it is named as having a short rest before it; nothing is marked when unset. |
| restRule | ScheduleRestRule | undefined | – | The rules on hours of rest, checked for each lane over every 24 hours and 7 days that overlap the range: each lane's row says the rest hours are met, or which limit its entries break. Time without an entry counts as rest, so pass the entries of the days before the range for the week's limit. Nothing is checked when unset. |
| onCreate | ((slot: ScheduleSlot) => void) | undefined | – | Asks the caller to create an entry in the hours the crew marked: dragged across an empty part of a lane, covering
every moveStep the drag touched, or clicked, one step from the moveStep clicked in. The caller opens its own
form to name the entry and passes it back in entries. A lane with a note takes no new entries. |
| renderDetail | ((entry: ScheduleEntry) => ReactNode) | undefined | – | What an entry's popover shows under its title; entries open nothing when unset. |
| footer | string | undefined | – | Text at the start of the foot ("Showing 11 of 14 crew"). |
| footerEnd | string | undefined | – | Text at the end of the foot ("Dotted lines are rest between watches"). |
| className | string | undefined | – | Classes added to the schedule's own. |
| testId | string | undefined | – | data-testid on the schedule. |
| ref | Ref<HTMLElement> | undefined | – | The schedule's element. |
Bridge watch 12:00 – 16:00 · 4 hr rest · First watch 20:00 – 24:00
Rig check 09:00 – 10:00 (flashing)
Related
- State timeline: a device's states over time, with unknown history shown as gaps.
- Popover: the panel an entry opens its detail in.
- Data table: the same entries as rows, to sort and filter.
The periods of rest are counted in every 24 hours that starts where a rest starts and every 24 hours that ends where a rest ends. A window that starts in the middle of a rest would cut it in two, and a four-on, eight-off watch, which the rules accept, would count three periods.