Skip to content
design
Components

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
Crew · hoursUTC
06:0007:0008:0009:0010:0011:0012:0013:0014:0015:0016:0017:0013:38 UTC
All watches covered10 of 10 watches

Nothing needs cover. Jo's watches went to Mira and Tom at 06:20 UTC.

Ana RibeiroSkipper · 6 hr
4 hr rest
Sam OkaforMate · 8 hr
1 hr rest4 hr rest
Mira HaddadWatch leader · 8 hr
2 hr rest
Tom KelleherDeckhand · 7 hr
3 hr rest
Jo LindqvistDeckhand · off sick

Off sick, reported 06:10 UTC. Her watches went to Mira and Tom at 06:20 UTC.

Showing 5 of 5 crewDotted lines are rest between watches
A day's watch rota: watches where their hours fall, rest between them, now at 13:38 UTC and a crew member off sick.
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.

01Told by shape, never color alonePlanned is outlined, in progress filled, done muted with a check, missed dashed with a cross, at risk thick-edged with an eye.
02Not an alertA missed watch on a rota is something to follow up, so no state takes an alert color or flashes; an alarm belongs in the alert zone.
03Every time with its referenceThe axis, the now line and every entry's name carry the time reference, so a rota read in port and at sea agrees.

Examples

Rescheduling

Fri 17 Oct · by crew

  • Planned
  • In progress
  • Done
  • Missed
  • At risk
Left and Right move the new entry, Shift with them changes its end, Up and Down move it to another lane. Enter creates it, Escape cancels.Drag to move or resize it, or hold Alt and use the arrow keys: Left and Right move it, Shift with them changes its end, Up and Down move it to another lane. Release Alt to save, Escape to cancel.
CrewUTC
06:0007:0008:0009:0010:0011:0012:0013:0014:0015:0016:0017:00
Ana RibeiroSkipperRest hours met
4 hr rest
Sam OkaforMateRest hours met
4 hr rest
Mira HaddadWatch leaderRest hours met
Tom KelleherDeckhandRest hours met
1 hr rest
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.
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
CrewUTC+1
06:0007:0008:0009:0010:0011:0012:0013:0014:0016:0017:0018:0014:10 UTC+1
Clocks advanced 1 hr
Ana RibeiroSkipper · red watch
Sam OkaforMate · white watch
Tom KelleherDeckhand · blue watch
8 hr rest
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.
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
Equipment · jobsUTC
08:0009:0010:0011:0012:0013:0010:20 UTC
Main engineYanmar 4JH · 2 jobs
Watermaker1 job
Standing rig1 job
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.
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

PropTypeDefaultDescription
labelrequiredstring–The schedule's name for a screen reader ("Watch rota, Friday 17 October"), translated by the caller.
headingstring | undefined–The line at the top of the schedule ("Fri 17 Oct · by crew"), written by the caller.
laneHeadingrequiredstring–The heading of the lanes' column ("Crew · hours"), translated by the caller.
lanesrequiredreadonly ScheduleLane[]–The rows, in the order they are drawn.
entriesrequiredreadonly ScheduleEntry[]–The entries, in any order; each names its lane.
rangerequiredreadonly [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.
timeReferencerequiredstring–The reference every time on the schedule is in ("UTC", "ship's time"), printed with the axis and the now line.
clockChangesreadonly 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.
stateLabelsrequiredReadonly<Record<ScheduleEntryState, string>>–The name of each state, for the legend and each entry's name, translated by the caller.
nownumber | undefined–Now, in epoch milliseconds: a line across the lanes with its time, when it falls in range.
stepnumber | undefinedHOURThe 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.
summaryScheduleSummary | 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.
moveStepnumber | undefinedHOUR / 4The step a move, a resize or a new slot snaps to, in milliseconds; a quarter of an hour by default.
allowOverlapboolean | undefinedfalseWhether 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.
minRestnumber | 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.
restRuleScheduleRestRule | 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.
footerstring | undefined–Text at the start of the foot ("Showing 11 of 14 crew").
footerEndstring | undefined–Text at the end of the foot ("Dotted lines are rest between watches").
classNamestring | undefined–Classes added to the schedule's own.
testIdstring | undefined–data-testid on the schedule.
refRef<HTMLElement> | undefined–The schedule's element.
Do

Bridge watch 12:00 – 16:00 · 4 hr rest · First watch 20:00 – 24:00

Keep one rota per schedule, with the rest between watches written on the dotted lines.
Don't

Rig check 09:00 – 10:00 (flashing)

Don't mark a missed watch in an alert color: the alert zone carries alarms, and a schedule never competes with it.
  • 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.