v2.6.0

<bmx-shift-roster>

A workforce roster: people down the side, days across, a shift in each cell - for retail, hospitals and care homes, warehouses, hospitality, contact centres and security.

7 properties · 3 events · 15 methods · 4 parts

Example

Fifteen staff, four shifts and the cover the home needs, with the first two days planned. Choose a shift above the grid and drag across days to paint it, type E, L, D or N in a cell, or press Auto-fill and see what it could not cover and why.

Show markup
<bmx-shift-roster id="ex-sr" label="Willow Court care home, two weeks" today="2026-10-14" locale="en-GB" file-name="willow-court-roster"></bmx-shift-roster>
<p class="note" id="ex-sr-out" role="status">Fifteen staff, four shifts and the cover the home needs, with the first two days planned. Choose a shift above the grid and drag across days to paint it, type E, L, D or N in a cell, or press Auto-fill and see what it could not cover and why.</p>

<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
  <button type="button" id="ex-sr-fill">Auto-fill the second week only</button>
  <label><input type="checkbox" id="ex-sr-readonly" /> Read only</label>
</div>

<script type="module">
  await customElements.whenDefined('bmx-shift-roster');
  const el = document.getElementById('ex-sr');
  const out = document.getElementById('ex-sr-out');

  el.roster = {
    start: '2026-10-12',
    days: 14,
    currency: 'GBP',
    rules: { minRestHours: 11, maxWeekHours: 48, maxDaysInRow: 6, maxNightsInRow: 4 },
    shifts: [
      { id: 'early', code: 'E', name: 'Early', start: '07:00', end: '14:30', breakMinutes: 30, skills: [] },
      { id: 'late', code: 'L', name: 'Late', start: '14:00', end: '22:00', breakMinutes: 30, skills: [] },
      { id: 'long', code: 'D', name: 'Long day', start: '07:00', end: '19:30', breakMinutes: 60, skills: [] },
      { id: 'night', code: 'N', name: 'Night', start: '21:45', end: '07:15', breakMinutes: 45, skills: [] },
    ],
    people: [
      { id: 'p1', name: 'Grace Okafor', role: 'Nurse', skills: ['RN', 'Meds'], minHours: 36, maxHours: 48, rate: 24.5, absences: [] },
      { id: 'p2', name: 'Tomasz Nowak', role: 'Nurse', skills: ['RN', 'Meds'], minHours: 36, maxHours: 48, rate: 24.5, absences: [{ from: '2026-10-19', to: '2026-10-21', reason: 'Annual leave', kind: 'hard' }] },
      { id: 'p3', name: 'Priya Raman', role: 'Nurse', skills: ['RN', 'Meds'], minHours: 22, maxHours: 30, rate: 25, absences: [] },
      { id: 'p4', name: 'Hannah Clarke', role: 'Nurse (bank)', skills: ['RN', 'Meds'], maxHours: 36, rate: 29, absences: [{ from: '2026-10-24', to: '2026-10-25', reason: 'Prefers weekends off', kind: 'preference' }] },
      { id: 'p15', name: 'Agency nurse', role: 'Nurse (agency)', skills: ['RN', 'Meds'], maxHours: 24, rate: 41, absences: [] },
      { id: 'p5', name: 'Samuel Mensah', role: 'Senior carer', skills: ['Meds', 'Senior'], minHours: 36, maxHours: 48, rate: 15.2, absences: [] },
      { id: 'p6', name: 'Lucy Fairbairn', role: 'Senior carer', skills: ['Meds', 'Senior'], minHours: 36, maxHours: 48, rate: 15.2, absences: [] },
      { id: 'p7', name: 'Daniel Byrne', role: 'Carer', skills: ['Meds'], minHours: 30, maxHours: 45, rate: 13.4, absences: [] },
      { id: 'p8', name: 'Aisha Rahman', role: 'Carer', skills: [], minHours: 30, maxHours: 45, rate: 12.6, absences: [{ from: '2026-10-15', reason: 'Training day', kind: 'hard' }] },
      { id: 'p9', name: 'Owen Davies', role: 'Carer', skills: [], minHours: 30, maxHours: 45, rate: 12.6, absences: [] },
      { id: 'p10', name: 'Maria Santos', role: 'Carer', skills: ['Meds'], minHours: 24, maxHours: 36, rate: 13.4, absences: [{ from: '2026-10-18', reason: 'Asked for Sunday off', kind: 'preference' }] },
      { id: 'p11', name: 'Kofi Asante', role: 'Carer (nights)', skills: [], minHours: 30, maxHours: 45, rate: 13.1, absences: [] },
      { id: 'p12', name: 'Emma Lindqvist', role: 'Carer', skills: [], minHours: 16, maxHours: 24, rate: 12.6, absences: [] },
      { id: 'p13', name: 'Jack Harper', role: 'Carer', skills: [], minHours: 30, maxHours: 45, rate: 12.6, absences: [{ from: '2026-10-22', to: '2026-10-25', reason: 'Annual leave', kind: 'hard' }] },
      { id: 'p14', name: 'Chloe Martin', role: 'Carer (bank)', skills: [], maxHours: 30, rate: 14, absences: [] },
    ],
    demand: [
      { id: 'rn-day', label: 'Nurse on duty', skill: 'RN', from: '07:00', to: '22:00', need: 1 },
      { id: 'rn-night', label: 'Nurse at night', skill: 'RN', from: '22:00', to: '07:00', need: 1 },
      { id: 'meds', label: 'Medication rounds', skill: 'Meds', from: '07:30', to: '10:00', need: 2 },
      { id: 'morning', label: 'Mornings', weekdays: [1, 2, 3, 4, 5], from: '07:00', to: '14:00', need: 4 },
      { id: 'morning-weekend', label: 'Weekend mornings', weekdays: [0, 6], from: '07:00', to: '14:00', need: 3 },
      { id: 'afternoon', label: 'Afternoons and evenings', from: '14:00', to: '22:00', need: 3 },
      { id: 'night', label: 'Nights', from: '22:00', to: '07:00', need: 2 },
    ],
    assignments: [
      { id: 'a1', person: 'p1', date: '2026-10-12', shift: 'long', locked: true },
      { id: 'a2', person: 'p1', date: '2026-10-13', shift: 'long', locked: true },
      { id: 'a3', person: 'p3', date: '2026-10-12', shift: 'night', locked: true },
      { id: 'a4', person: 'p3', date: '2026-10-13', shift: 'night', locked: true },
      { id: 'a5', person: 'p5', date: '2026-10-12', shift: 'early' },
      { id: 'a6', person: 'p6', date: '2026-10-12', shift: 'late' },
      { id: 'a7', person: 'p6', date: '2026-10-13', shift: 'early' },
      { id: 'a8', person: 'p7', date: '2026-10-12', shift: 'early' },
      { id: 'a9', person: 'p8', date: '2026-10-12', shift: 'early' },
      { id: 'a10', person: 'p9', date: '2026-10-12', shift: 'late' },
      { id: 'a11', person: 'p11', date: '2026-10-12', shift: 'night', locked: true },
      { id: 'a12', person: 'p11', date: '2026-10-13', shift: 'night', locked: true },
      { id: 'a13', person: 'p10', date: '2026-10-13', shift: 'late' },
      { id: 'a14', person: 'p8', date: '2026-10-15', shift: 'early' },
    ],
  };

  const said = {
    assign: 'Shift set',
    unassign: 'Shift removed',
    paint: 'Painted',
    swap: 'Moved',
    lock: 'Lock changed',
    fill: 'Auto-filled',
    clear: 'Unlocked shifts cleared',
    repeat: 'First week repeated',
    period: 'Period changed',
    undo: 'Undone',
    redo: 'Redone',
    set: 'Roster replaced',
  };
  el.addEventListener('bmxChange', async e => {
    if (e.detail.reason === 'fill') return;
    const problems = await el.check();
    const errors = problems.filter(p => p.severity === 'error').length;
    const totals = await el.getTotals();
    out.textContent = `${said[e.detail.reason] ?? 'Changed'}. ${totals.shifts} shifts, ${Math.round(totals.hours)} hours, £${Math.round(totals.cost).toLocaleString('en-GB')}; ${errors} problem${errors === 1 ? '' : 's'} to fix.`;
  });
  el.addEventListener('bmxAutoFill', e => {
    out.textContent = `Auto-fill added ${e.detail.added.length} shifts. ${e.detail.gaps.length ? `${e.detail.gaps.length} gaps are left - the reasons are under the grid.` : 'Every need is covered.'}`;
  });

  document.getElementById('ex-sr-fill').addEventListener('click', () => {
    const dates = Array.from({ length: 7 }, (_, i) => new Date(Date.UTC(2026, 9, 19 + i)).toISOString().slice(0, 10));
    el.autoFill({ dates });
  });
  document.getElementById('ex-sr-readonly').addEventListener('change', e => (el.readonly = e.target.checked));
</script>

WHAT IT DOES

  • Shift templates (early, late, night, any of your own) painted onto days by click or drag, typed by their code, moved and swapped by dragging, locked so auto-fill and clearing leave them; undo and redo.
  • Labour rules checked as you go: rest between shifts, hours a week (contract or legal limit), days and nights in a row, skills a shift needs, leave and requests not to work. Every problem is marked on its cell and listed.
  • Cover against what each part of each day needs (by skill, weekday or date), day by day and hour by hour.
  • Auto-fill adds shifts where cover is short, keeping every rule and spreading hours fairly, and says for every gap why nobody could fill it.
  • Hours and cost per person, per day and in total; CSV and JSON out.

The roster is plain JSON on the element (roster, bmxChange); nothing is sent anywhere.

Properties

PropertyAttributeTypeDefaultDescription
fileName file-name string 'roster' The name exported files are saved under, without the extension.
label label string — The element's accessible name.
locale locale string — The language for dates and money (the page's when unset).
readonly readonly boolean false Look but do not change.
roster roster Partial<BmxRoster> | string — The roster: dates, people, shifts, assignments, needs and rules (an object, or its JSON). Kept up to date.
strings strings Partial<BmxShiftRosterStrings> | string — Replacements for any of the wording (an object, or its JSON).
today today string — Today, as YYYY-MM-DD (marked in the grid, and where "This week" goes). Unset: the computer's date.

Events

EventDetailDescription
bmxAutoFill BmxShiftRosterFillDetail Fired when auto-fill has run, with what it added and what it could not cover.
bmxCellSelect BmxShiftRosterCellDetail Fired when another cell is chosen.
bmxChange BmxShiftRosterChangeDetail Fired when the roster changes.

Methods

MethodSignatureDescription
assign assign(person: string, date: string, shift: string | null) => Promise<void> Puts a person on a shift on a day (null takes them off).
autoFill autoFill(options?: BmxRosterFillOptions) => Promise<BmxRosterFillResult> Fills short cover, keeping every rule, and returns what it added and could not cover.
check check() => Promise<BmxRosterProblem[]> Every problem on the roster.
download download(kind?: "grid" | "long" | "json") => Promise<void> Saves the roster as CSV or JSON.
exportCsv exportCsv(layout?: "grid" | "long") => Promise<string> The roster as CSV: a grid of shift codes, or a list of shifts.
getCoverage getCoverage() => Promise<BmxRosterCoverageDay[]> Cover for each day.
getRoster getRoster() => Promise<BmxRoster> The roster as it is now.
getTotals getTotals() => Promise<ReturnType<typeof rosterTotals>> Hours and cost per person and in total.
redo redo() => Promise<void> Puts back what undo took.
selectCell selectCell(person: string, date: string) => Promise<void> Chooses a cell (and focuses it).
setLocked setLocked(person: string, date: string, locked: boolean) => Promise<void> Locks or unlocks a person's shift on a day.
setPeriod setPeriod(start: string, days?: number) => Promise<void> Shows another period: its first day, and how many days.
setRoster setRoster(roster: Partial<BmxRoster> | string) => Promise<void> Replaces the roster. Undo goes back to what was there.
swap swap(a: { person: string; date: string; }, b: { person: string; date: string; }) => Promise<void> Swaps (or moves) the shifts of two cells.
undo undo() => Promise<void> Takes back the last change.

CSS shadow parts

PartDescription
cell Each day cell.
details The panel under the grid.
grid The roster grid.
toolbar The tools along the top.

CSS custom properties

PropertyDescription
--bmx-shift-roster-height The most height the grid takes before it scrolls.
--bmx-shift-roster-radius Corner radius of the frame.
--bmx-shift-roster-shift-0 The first shift's colour, unless it names its own. Colours 0 to 7 are used in turn.
--bmx-shift-roster-shift-1 The second shift's colour.
--bmx-shift-roster-shift-2 The third shift's colour.
--bmx-shift-roster-shift-3 The fourth shift's colour.
--bmx-shift-roster-shift-4 The fifth shift's colour.
--bmx-shift-roster-shift-5 The sixth shift's colour.
--bmx-shift-roster-shift-6 The seventh shift's colour.
--bmx-shift-roster-shift-7 The eighth shift's colour.
--bmx-shift-roster-weekend Background of weekend columns.