Airline — Crew Schedule
A crew operations roster board pairing pilots and cabin crew against a week of long-haul flights. Each row shows a duty-time bar with rest and weekly-limit indicators, while each cell is an assignable slot. Click an open slot to pick from eligible crew ranked by legality, swap or unassign rostered members from a detail drawer, and watch rest warnings, open-slot counts and duty loads recompute live as you build the schedule.
MCP
Code
:root {
--sky: #0a66c2;
--sky-d: #084e95;
--sky-50: #e9f2fb;
--cloud: #f5f8fc;
--sunrise: #ff7a33;
--sunrise-50: #fff0e7;
--ink: #13233b;
--ink-2: #3a4d68;
--muted: #6b7c93;
--bg: #f5f8fc;
--surface: #ffffff;
--line: rgba(19, 35, 59, 0.1);
--line-2: rgba(19, 35, 59, 0.18);
--ok: #1f9d62;
--warn: #e0962a;
--danger: #d4493e;
--boarding: #1f9d62;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-sm: 0 1px 2px rgba(19, 35, 59, 0.06), 0 1px 3px rgba(19, 35, 59, 0.08);
--shadow-md: 0 6px 18px rgba(19, 35, 59, 0.1);
--shadow-lg: 0 18px 48px rgba(19, 35, 59, 0.18);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.tab { font-variant-numeric: tabular-nums; }
.app {
max-width: 1240px;
margin: 0 auto;
padding: 18px 20px 56px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 40px; height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--sky), var(--sky-d));
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-text strong { font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
.brand-text span { font-size: 12px; color: var(--muted); font-weight: 500; }
.topbar-meta { display: flex; align-items: center; gap: 12px; }
.basebadge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: var(--sky-50);
border-radius: 999px;
}
.basebadge .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px rgba(31, 157, 98, 0.18);
}
/* ---------- Buttons ---------- */
.btn {
font: inherit;
font-weight: 600;
font-size: 13.5px;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 9px 15px;
cursor: pointer;
transition: transform 0.08s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--sky); color: #fff; box-shadow: var(--shadow-sm); }
.btn.primary:hover { background: var(--sky-d); }
.btn.ghost { background: var(--surface); color: var(--ink); border-color: var(--line-2); }
.btn.ghost:hover { background: var(--sky-50); border-color: var(--sky); color: var(--sky-d); }
.iconbtn {
font: inherit;
font-size: 22px;
line-height: 1;
width: 36px; height: 36px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
border-radius: 10px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.iconbtn:hover { background: var(--sky-50); color: var(--ink); }
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin: 16px 0;
}
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink-2);
cursor: pointer;
transition: all 0.15s ease;
}
.chip:hover { border-color: var(--sky); color: var(--sky-d); }
.chip.active {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.kpis { display: flex; gap: 10px; }
.kpi {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 8px 14px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-sm);
min-width: 78px;
}
.kpi-num { font-size: 20px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.kpi-lbl { font-size: 11px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.kpi.warn .kpi-num { color: var(--warn); }
/* ---------- Board / grid ---------- */
.board-wrap {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.board {
display: grid;
/* columns set inline; first col = crew, rest = flights */
overflow-x: auto;
}
.cell {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
min-height: 64px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
/* header row */
.cell.head {
position: sticky;
top: 0;
background: linear-gradient(180deg, #fbfdff, #f3f7fc);
border-bottom: 2px solid var(--line-2);
min-height: auto;
padding: 12px 10px;
z-index: 3;
}
.cell.corner {
position: sticky;
left: 0;
z-index: 5;
background: linear-gradient(180deg, #fbfdff, #f3f7fc);
}
.flight-h { display: flex; flex-direction: column; gap: 3px; }
.flight-no { font-size: 13px; font-weight: 800; color: var(--ink); letter-spacing: -0.01em; }
.flight-route {
display: flex; align-items: center; gap: 5px;
font-size: 12.5px; font-weight: 700; color: var(--sky-d);
font-variant-numeric: tabular-nums;
}
.flight-route svg { color: var(--muted); }
.flight-time { font-size: 11.5px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.flight-day { font-size: 10.5px; color: var(--sunrise); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
/* crew label column (sticky left) */
.cell.crew-h {
position: sticky;
left: 0;
z-index: 2;
background: var(--surface);
flex-direction: row;
align-items: center;
gap: 10px;
min-width: 210px;
}
.avatar {
width: 34px; height: 34px; flex: none;
border-radius: 50%;
display: grid; place-items: center;
font-size: 12px; font-weight: 700; color: #fff;
letter-spacing: 0.02em;
}
.crew-meta { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.crew-name { font-size: 13.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.crew-sub { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted); font-weight: 600; }
.role-tag {
font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 5px;
text-transform: uppercase; letter-spacing: 0.03em;
}
.role-tag.flight { background: var(--sky-50); color: var(--sky-d); }
.role-tag.cabin { background: var(--sunrise-50); color: #b8541c; }
/* duty bar */
.duty {
margin-top: 3px;
height: 5px;
border-radius: 3px;
background: var(--line);
overflow: hidden;
width: 100%;
}
.duty > i {
display: block; height: 100%;
border-radius: 3px;
background: var(--ok);
}
.duty.warn > i { background: var(--warn); }
.duty.over > i { background: var(--danger); }
.duty-lbl { font-size: 10.5px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.duty-lbl b { color: var(--ink-2); }
/* roster cells */
.slot {
cursor: pointer;
border-radius: var(--r-sm);
margin: -2px;
padding: 4px;
transition: background 0.12s ease;
}
.slot:hover { background: var(--sky-50); }
.slot:focus-visible { outline: 2px solid var(--sky); outline-offset: 1px; }
.assigned {
display: flex; align-items: center; gap: 8px;
padding: 7px 9px;
border-radius: 10px;
background: var(--sky-50);
border: 1px solid rgba(10, 102, 194, 0.18);
}
.assigned.warned {
background: #fdf4e6;
border-color: rgba(224, 150, 42, 0.4);
}
.assigned .mini-av { width: 22px; height: 22px; font-size: 9px; }
.assigned .a-name { font-size: 12px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.assigned .a-warn {
margin-left: auto; flex: none;
color: var(--warn);
display: grid; place-items: center;
}
.open {
display: flex; align-items: center; justify-content: center; gap: 6px;
height: 40px;
border: 1.5px dashed var(--line-2);
border-radius: 10px;
color: var(--muted);
font-size: 12px; font-weight: 600;
}
.slot:hover .open { border-color: var(--sky); color: var(--sky-d); }
.empty-cell { opacity: 0.4; font-size: 11px; color: var(--muted); }
.hint {
margin: 0;
padding: 11px 16px;
font-size: 12px;
color: var(--muted);
background: #fbfdff;
border-top: 1px solid var(--line);
}
/* ---------- Drawer ---------- */
.drawer-scrim {
position: fixed; inset: 0;
background: rgba(19, 35, 59, 0.4);
backdrop-filter: blur(2px);
z-index: 40;
animation: fade 0.18s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.drawer {
position: fixed;
top: 0; right: 0; bottom: 0;
width: min(400px, 92vw);
background: var(--surface);
border-left: 1px solid var(--line);
box-shadow: var(--shadow-lg);
z-index: 50;
transform: translateX(100%);
transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
}
.drawer.open { transform: translateX(0); }
.drawer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.drawer-head h2 { margin: 0; font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
.drawer-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
.flight-summary {
padding: 12px 14px;
border-radius: var(--r-md);
background: var(--sky-50);
margin-bottom: 16px;
}
.flight-summary .fs-route { font-size: 18px; font-weight: 800; color: var(--sky-d); font-variant-numeric: tabular-nums; }
.flight-summary .fs-meta { font-size: 12.5px; color: var(--ink-2); font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; }
.section-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--muted);
margin: 0 0 9px;
}
/* candidate / detail list */
.cand {
display: flex; align-items: center; gap: 11px;
width: 100%;
text-align: left;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
cursor: pointer;
font: inherit;
transition: border-color 0.15s ease, background 0.15s ease;
}
.cand:hover { border-color: var(--sky); background: var(--sky-50); }
.cand:disabled { cursor: not-allowed; opacity: 0.55; }
.cand .c-body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.cand .c-name { font-size: 13.5px; font-weight: 700; }
.cand .c-sub { font-size: 11.5px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.cand .c-flag {
font-size: 10.5px; font-weight: 700; padding: 3px 8px; border-radius: 6px;
}
.c-flag.ok { background: rgba(31, 157, 98, 0.12); color: var(--ok); }
.c-flag.warn { background: rgba(224, 150, 42, 0.14); color: #a86d12; }
.c-flag.block { background: rgba(212, 73, 62, 0.12); color: var(--danger); }
.detail-rows { display: flex; flex-direction: column; gap: 1px; margin-bottom: 16px; }
.drow {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.drow span { color: var(--muted); font-weight: 600; }
.drow b { font-weight: 700; font-variant-numeric: tabular-nums; }
.warn-banner {
display: flex; gap: 10px;
padding: 11px 13px;
border-radius: var(--r-md);
background: #fdf4e6;
border: 1px solid rgba(224, 150, 42, 0.35);
margin-bottom: 16px;
font-size: 12.5px;
color: #8a5a0f;
font-weight: 600;
line-height: 1.45;
}
.warn-banner svg { flex: none; color: var(--warn); margin-top: 1px; }
.drawer-actions { display: flex; gap: 10px; }
.drawer-actions .btn { flex: 1; }
/* status pill */
.pill {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
}
.pill.ok { background: rgba(31, 157, 98, 0.12); color: var(--ok); }
.pill.warn { background: rgba(224, 150, 42, 0.14); color: #a86d12; }
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
bottom: 22px; left: 50%;
transform: translateX(-50%);
z-index: 80;
display: flex; flex-direction: column; gap: 8px;
align-items: center;
}
.toast {
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
box-shadow: var(--shadow-lg);
display: flex; align-items: center; gap: 8px;
animation: toastIn 0.25s ease;
}
.toast .tdot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.toast.warn .tdot { background: var(--warn); }
@keyframes toastIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.app { padding: 12px 12px 48px; }
.topbar { border-radius: var(--r-md); }
.brand-text strong { font-size: 15px; }
.toolbar { gap: 12px; }
.kpis { width: 100%; }
.kpi { flex: 1; min-width: 0; }
.cell.crew-h { min-width: 168px; }
.hint { font-size: 11.5px; }
}(function () {
"use strict";
// ---------- Data ----------
var AV_COLORS = ["#0a66c2", "#ff7a33", "#1f9d62", "#7b5cff", "#d4493e", "#0e9aa7", "#b8541c", "#5a6b85"];
function initials(name) {
return name.split(/\s+/).map(function (p) { return p[0]; }).join("").slice(0, 2).toUpperCase();
}
// Crew roster — flight deck + cabin. dutyMax = max legal duty minutes for the week.
var crew = [
{ id: "c1", name: "Capt. Elena Marsh", role: "Captain", deck: "flight", base: "JFK", duty: 0, dutyMax: 600, restSince: 720 },
{ id: "c2", name: "Capt. Omar Reyes", role: "Captain", deck: "flight", base: "JFK", duty: 0, dutyMax: 600, restSince: 540 },
{ id: "c3", name: "F/O Priya Nair", role: "First Officer", deck: "flight", base: "JFK", duty: 0, dutyMax: 600, restSince: 660 },
{ id: "c4", name: "F/O Daniel Kovač", role: "First Officer", deck: "flight", base: "JFK", duty: 0, dutyMax: 600, restSince: 480 },
{ id: "c5", name: "F/O Aiko Tanaka", role: "First Officer", deck: "flight", base: "JFK", duty: 0, dutyMax: 600, restSince: 900 },
{ id: "c6", name: "Lena Brandt", role: "Purser", deck: "cabin", base: "JFK", duty: 0, dutyMax: 660, restSince: 600 },
{ id: "c7", name: "Marco Ferraro", role: "Purser", deck: "cabin", base: "JFK", duty: 0, dutyMax: 660, restSince: 420 },
{ id: "c8", name: "Sofia Almeida", role: "Cabin Crew", deck: "cabin", base: "JFK", duty: 0, dutyMax: 660, restSince: 780 },
{ id: "c9", name: "Hassan Idris", role: "Cabin Crew", deck: "cabin", base: "JFK", duty: 0, dutyMax: 660, restSince: 510 },
{ id: "c10", name: "Grace Okonkwo", role: "Cabin Crew", deck: "cabin", base: "JFK", duty: 0, dutyMax: 660, restSince: 690 },
{ id: "c11", name: "Tom Castellano", role: "Cabin Crew", deck: "cabin", base: "JFK", duty: 0, dutyMax: 660, restSince: 350 }
];
crew.forEach(function (c, i) { c.color = AV_COLORS[i % AV_COLORS.length]; });
// Flights. each needs flight-deck (Captain + F/O) and cabin (Purser + Cabin Crew) roles.
// dur = block minutes; rolesNeeded: list of {role, n}
var flights = [
{ id: "f1", no: "SA118", from: "JFK", to: "LHR", day: "Mon", dep: "21:40", arr: "09:30", dur: 410 },
{ id: "f2", no: "SA204", from: "JFK", to: "CDG", day: "Tue", dep: "19:05", arr: "08:25", dur: 430 },
{ id: "f3", no: "SA336", from: "JFK", to: "DUB", day: "Wed", dep: "20:15", arr: "07:10", dur: 375 },
{ id: "f4", no: "SA119", from: "LHR", to: "JFK", day: "Thu", dep: "11:20", arr: "14:35", dur: 460 },
{ id: "f5", no: "SA512", from: "JFK", to: "LIS", day: "Fri", dep: "22:00", arr: "09:50", dur: 395 }
];
var REQUIRED = [
{ role: "Captain", n: 1 },
{ role: "First Officer", n: 1 },
{ role: "Purser", n: 1 },
{ role: "Cabin Crew", n: 3 }
];
// assignments keyed by "flightId|role|index" -> crewId
var assign = {};
// legality thresholds
var MIN_REST = 600; // minutes rest needed before duty
var WARN_REST = 720;
// ---------- Seed a realistic partial roster ----------
function seed() {
var pre = {
"f1|Captain|0": "c1", "f1|First Officer|0": "c3",
"f1|Purser|0": "c6", "f1|Cabin Crew|0": "c8", "f1|Cabin Crew|1": "c9",
"f2|Captain|0": "c2", "f2|First Officer|0": "c4",
"f2|Purser|0": "c7", "f2|Cabin Crew|0": "c10",
"f3|Captain|0": "c1", "f3|First Officer|0": "c5",
"f3|Purser|0": "c6", "f3|Cabin Crew|0": "c11", "f3|Cabin Crew|1": "c8",
"f4|Captain|0": "c2", "f4|First Officer|0": "c3",
"f4|Purser|0": "c7", "f4|Cabin Crew|0": "c9", "f4|Cabin Crew|1": "c10", "f4|Cabin Crew|2": "c8",
"f5|Captain|0": "c1", "f5|Purser|0": "c6"
};
Object.keys(pre).forEach(function (k) { assign[k] = pre[k]; });
recompute();
}
// ---------- Derived state ----------
function crewById(id) { return crew.filter(function (c) { return c.id === id; })[0]; }
function flightById(id) { return flights.filter(function (f) { return f.id === id; })[0]; }
// recompute each crew member's duty minutes from current assignments
function recompute() {
crew.forEach(function (c) { c.duty = 0; c.legs = 0; });
Object.keys(assign).forEach(function (k) {
var c = crewById(assign[k]);
if (!c) return;
var f = flightById(k.split("|")[0]);
c.duty += f.dur;
c.legs += 1;
});
}
// Is crew member legal for a flight? returns {status:'ok'|'warn'|'block', reason}
function legality(c, flight) {
// already on this flight in another seat? block (shouldn't double-book)
var dbl = Object.keys(assign).some(function (k) {
return assign[k] === c.id && k.split("|")[0] === flight.id;
});
if (dbl) return { status: "block", reason: "Already rostered on this flight" };
var newDuty = c.duty + flight.dur;
if (newDuty > c.dutyMax) {
return { status: "block", reason: "Exceeds weekly duty limit (" + fmtHm(newDuty) + " / " + fmtHm(c.dutyMax) + ")" };
}
if (c.restSince < MIN_REST) {
return { status: "block", reason: "Insufficient rest — " + fmtHm(c.restSince) + " since last duty (min " + fmtHm(MIN_REST) + ")" };
}
if (c.restSince < WARN_REST || newDuty > c.dutyMax * 0.85) {
return { status: "warn", reason: "Tight rest / high duty load — review before publishing" };
}
return { status: "ok", reason: "Within all duty & rest limits" };
}
// duty load class for a crew row
function dutyClass(c) {
if (c.duty > c.dutyMax) return "over";
if (c.duty > c.dutyMax * 0.85 || c.restSince < WARN_REST) return "warn";
return "";
}
function fmtHm(min) {
var h = Math.floor(min / 60), m = min % 60;
return h + "h" + (m ? " " + (m < 10 ? "0" : "") + m + "m" : "");
}
// count rest/duty warnings across roster
function warningCount() {
var n = 0;
crew.forEach(function (c) {
if (c.legs > 0 && (c.duty > c.dutyMax * 0.85 || c.restSince < WARN_REST)) n++;
if (c.duty > c.dutyMax) n++;
});
return n;
}
// ---------- Rendering ----------
var board = document.getElementById("board");
var activeRole = "all";
function visibleCrew() {
if (activeRole === "all") return crew;
return crew.filter(function (c) { return c.role === activeRole; });
}
var ARROW = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M5 12h13m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
var WARNICO = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M12 3 2 20h20L12 3Zm0 6v5m0 3h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function render() {
recompute();
var vc = visibleCrew();
board.style.gridTemplateColumns = "minmax(168px, 230px) repeat(" + flights.length + ", minmax(150px, 1fr))";
var html = "";
// header row
html += '<div class="cell head corner">' +
'<div class="flight-day">Flight</div><div class="flight-no">Crew · ' + crew[0].base + '</div></div>';
flights.forEach(function (f) {
html += '<div class="cell head">' +
'<div class="flight-h">' +
'<div class="flight-day">' + f.day + '</div>' +
'<div class="flight-no">' + f.no + '</div>' +
'<div class="flight-route tab">' + f.from + ' ' + ARROW + ' ' + f.to + '</div>' +
'<div class="flight-time tab">' + f.dep + '–' + f.arr + ' · ' + fmtHm(f.dur) + '</div>' +
'</div></div>';
});
// crew rows
vc.forEach(function (c) {
var dc = dutyClass(c);
var pct = Math.min(100, Math.round((c.duty / c.dutyMax) * 100));
html += '<div class="cell crew-h">' +
'<span class="avatar" style="background:' + c.color + '">' + initials(c.name) + '</span>' +
'<div class="crew-meta">' +
'<div class="crew-name">' + c.name + '</div>' +
'<div class="crew-sub"><span class="role-tag ' + c.deck + '">' + shortRole(c.role) + '</span>' +
'<span class="tab">' + fmtHm(c.duty) + '</span></div>' +
'<div class="duty ' + dc + '"><i style="width:' + pct + '%"></i></div>' +
'<div class="duty-lbl tab"><b>' + pct + '%</b> of ' + fmtHm(c.dutyMax) + ' · rest ' + fmtHm(c.restSince) + '</div>' +
'</div></div>';
flights.forEach(function (f) {
html += renderSlot(c, f);
});
});
board.innerHTML = html;
bindSlots();
updateKpis();
}
function renderSlot(c, f) {
// which required role does this crew fill on this flight? find key matching crew role
var req = REQUIRED.filter(function (r) { return r.role === c.role; })[0];
if (!req) {
return '<div class="cell"><span class="empty-cell">—</span></div>';
}
// find an assignment key for this crew on this flight, OR an open index for assign
var assignedKey = null;
for (var i = 0; i < req.n; i++) {
if (assign[f.id + "|" + c.role + "|" + i] === c.id) { assignedKey = f.id + "|" + c.role + "|" + i; break; }
}
if (assignedKey) {
var warned = c.restSince < WARN_REST || c.duty > c.dutyMax * 0.85 || c.duty > c.dutyMax;
return '<div class="cell"><div class="slot" tabindex="0" role="button" ' +
'data-key="' + assignedKey + '" data-flight="' + f.id + '" data-crew="' + c.id + '" data-mode="detail" ' +
'aria-label="View duty: ' + c.name + ' on ' + f.no + '">' +
'<div class="assigned ' + (warned ? "warned" : "") + '">' +
'<span class="avatar mini-av" style="background:' + c.color + '">' + initials(c.name) + '</span>' +
'<span class="a-name">' + shortName(c.name) + '</span>' +
(warned ? '<span class="a-warn">' + WARNICO + '</span>' : '') +
'</div></div></div>';
}
// open slot (only show as open if this flight still needs this role)
if (openIndex(f, c.role) === -1) {
return '<div class="cell"><span class="empty-cell">—</span></div>';
}
return '<div class="cell"><div class="slot" tabindex="0" role="button" ' +
'data-flight="' + f.id + '" data-role="' + c.role + '" data-mode="assign" ' +
'aria-label="Assign ' + c.role + ' to ' + f.no + '">' +
'<div class="open">+ Assign ' + shortRole(c.role) + '</div></div></div>';
}
// find first open index for a role on a flight, -1 if full
function openIndex(f, role) {
var req = REQUIRED.filter(function (r) { return r.role === role; })[0];
if (!req) return -1;
for (var i = 0; i < req.n; i++) {
if (!assign[f.id + "|" + role + "|" + i]) return i;
}
return -1;
}
function shortRole(r) {
return { "Captain": "CPT", "First Officer": "F/O", "Purser": "PSR", "Cabin Crew": "CC" }[r] || r;
}
function shortName(n) {
return n.replace(/^(Capt\.|F\/O)\s+/, "");
}
function updateKpis() {
var assigned = Object.keys(assign).length;
var totalSlots = 0;
flights.forEach(function (f) {
REQUIRED.forEach(function (r) { totalSlots += r.n; });
});
document.getElementById("kpiAssigned").textContent = assigned;
document.getElementById("kpiOpen").textContent = totalSlots - assigned;
document.getElementById("kpiWarn").textContent = warningCount();
}
// ---------- Slot interaction ----------
function bindSlots() {
var slots = board.querySelectorAll(".slot");
Array.prototype.forEach.call(slots, function (el) {
el.addEventListener("click", onSlot);
el.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSlot.call(el); }
});
});
}
function onSlot() {
var mode = this.getAttribute("data-mode");
var fId = this.getAttribute("data-flight");
if (mode === "assign") {
openAssign(fId, this.getAttribute("data-role"));
} else {
openDetail(this.getAttribute("data-key"), fId, this.getAttribute("data-crew"));
}
}
// ---------- Drawer ----------
var drawer = document.getElementById("drawer");
var scrim = document.getElementById("scrim");
var drawerBody = document.getElementById("drawerBody");
var drawerTitle = document.getElementById("drawerTitle");
function showDrawer() {
scrim.hidden = false;
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
}
function hideDrawer() {
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
scrim.hidden = true;
}
scrim.addEventListener("click", hideDrawer);
document.getElementById("drawerClose").addEventListener("click", hideDrawer);
document.addEventListener("keydown", function (e) { if (e.key === "Escape") hideDrawer(); });
function flightSummaryHtml(f) {
return '<div class="flight-summary">' +
'<div class="fs-route tab">' + f.from + ' ' + ARROW + ' ' + f.to + '</div>' +
'<div class="fs-meta tab">' + f.no + ' · ' + f.day + ' · ' + f.dep + '–' + f.arr +
' · block ' + fmtHm(f.dur) + '</div></div>';
}
function openAssign(fId, role, swapKey) {
var f = flightById(fId);
drawerTitle.textContent = swapKey ? "Swap " + role : "Assign " + role;
var idx = swapKey ? parseInt(swapKey.split("|")[2], 10) : openIndex(f, role);
var key = swapKey || (fId + "|" + role + "|" + idx);
// candidates = crew of this role not already on this flight
var onFlight = {};
Object.keys(assign).forEach(function (k) {
if (k.split("|")[0] === fId) onFlight[assign[k]] = true;
});
var cands = crew.filter(function (c) {
return c.role === role && (assign[key] === c.id || !onFlight[c.id]);
});
// sort: ok first, then warn, then block; within, by duty asc
cands.forEach(function (c) { c._leg = legality(c, f); });
var rank = { ok: 0, warn: 1, block: 2 };
cands.sort(function (a, b) {
if (rank[a._leg.status] !== rank[b._leg.status]) return rank[a._leg.status] - rank[b._leg.status];
return a.duty - b.duty;
});
var html = flightSummaryHtml(f);
html += '<p class="section-label">' + cands.length + ' eligible · ' + role + '</p>';
cands.forEach(function (c) {
var lg = c._leg;
var dis = lg.status === "block" ? "disabled" : "";
html += '<button class="cand" ' + dis + ' data-assign="' + c.id + '" data-key="' + key + '">' +
'<span class="avatar" style="background:' + c.color + '">' + initials(c.name) + '</span>' +
'<span class="c-body"><span class="c-name">' + c.name + '</span>' +
'<span class="c-sub tab">Duty ' + fmtHm(c.duty) + ' / ' + fmtHm(c.dutyMax) + ' · rest ' + fmtHm(c.restSince) + '</span></span>' +
'<span class="c-flag ' + lg.status + '">' +
(lg.status === "ok" ? "OK" : lg.status === "warn" ? "TIGHT" : "BLOCKED") + '</span>' +
'</button>';
});
drawerBody.innerHTML = html;
Array.prototype.forEach.call(drawerBody.querySelectorAll(".cand"), function (btn) {
btn.addEventListener("click", function () {
var cId = btn.getAttribute("data-assign");
var k = btn.getAttribute("data-key");
doAssign(k, cId, f, role);
});
});
showDrawer();
}
function doAssign(key, crewId, f, role) {
var prev = assign[key];
assign[key] = crewId;
recompute();
var c = crewById(crewId);
var lg = legality(c, f);
render();
hideDrawer();
if (lg.status === "warn" || c.restSince < WARN_REST || c.duty > c.dutyMax * 0.85) {
toast(shortName(c.name) + " assigned to " + f.no + " — rest/duty flagged", "warn");
} else {
toast(shortName(c.name) + (prev ? " swapped onto " : " assigned to ") + f.no, "ok");
}
}
function openDetail(key, fId, crewId) {
var f = flightById(fId);
var c = crewById(crewId);
var role = key.split("|")[1];
drawerTitle.textContent = "Duty detail";
var warned = c.restSince < WARN_REST || c.duty > c.dutyMax * 0.85;
var over = c.duty > c.dutyMax;
// legality for an already-rostered member: judge current standing, no double-count
var lg = over
? { status: "block" }
: warned
? { status: "warn" }
: { status: "ok" };
var html = flightSummaryHtml(f);
if (over || warned) {
var msg = over
? "Weekly duty limit exceeded (" + fmtHm(c.duty) + " / " + fmtHm(c.dutyMax) + "). Reassign before publishing."
: (c.restSince < WARN_REST
? "Rest period below recommended buffer (" + fmtHm(c.restSince) + " vs " + fmtHm(WARN_REST) + ")."
: "Approaching weekly duty cap. Monitor remaining legs.");
html += '<div class="warn-banner">' + WARNICO + '<span>' + msg + '</span></div>';
}
html += '<p class="section-label">' + role + ' · ' + shortName(c.name) + '</p>';
html += '<div class="detail-rows">' +
drow("Role", shortRole(c.role) + " — " + c.role) +
drow("Base", c.base) +
drow("Weekly duty", fmtHm(c.duty) + " / " + fmtHm(c.dutyMax)) +
drow("Legs this week", String(c.legs)) +
drow("Rest before duty", fmtHm(c.restSince)) +
drow("Legality", '<span class="pill ' + (lg.status === "ok" ? "ok" : "warn") + '">' +
(lg.status === "ok" ? "Compliant" : "Review") + '</span>') +
'</div>';
html += '<div class="drawer-actions">' +
'<button class="btn ghost" id="swapBtn" type="button">Swap crew</button>' +
'<button class="btn primary" id="unassignBtn" type="button">Unassign</button>' +
'</div>';
drawerBody.innerHTML = html;
document.getElementById("swapBtn").addEventListener("click", function () {
openAssign(fId, role, key);
});
document.getElementById("unassignBtn").addEventListener("click", function () {
delete assign[key];
recompute();
render();
hideDrawer();
toast(shortName(c.name) + " removed from " + f.no, "warn");
});
showDrawer();
}
function drow(label, val) {
return '<div class="drow"><span>' + label + '</span><b>' + val + '</b></div>';
}
// ---------- Filters ----------
Array.prototype.forEach.call(document.querySelectorAll(".chip"), function (chip) {
chip.addEventListener("click", function () {
document.querySelectorAll(".chip").forEach(function (c) { c.classList.remove("active"); });
chip.classList.add("active");
activeRole = chip.getAttribute("data-role");
render();
});
});
// publish
document.getElementById("publishBtn").addEventListener("click", function () {
var w = warningCount();
var open = parseInt(document.getElementById("kpiOpen").textContent, 10);
if (open > 0) {
toast(open + " open slot" + (open > 1 ? "s" : "") + " remain — fill before publishing", "warn");
} else if (w > 0) {
toast("Published with " + w + " rest/duty flag" + (w > 1 ? "s" : "") + " noted", "warn");
} else {
toast("Roster published — all duties within limits", "ok");
}
});
// ---------- Toast ----------
var toastWrap = document.getElementById("toastWrap");
function toast(msg, kind) {
var t = document.createElement("div");
t.className = "toast" + (kind === "warn" ? " warn" : "");
t.innerHTML = '<span class="tdot"></span>' + msg;
toastWrap.appendChild(t);
setTimeout(function () {
t.style.transition = "opacity .3s ease, transform .3s ease";
t.style.opacity = "0";
t.style.transform = "translateY(8px)";
setTimeout(function () { t.remove(); }, 320);
}, 2600);
}
// ---------- Boot ----------
seed();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skyward Atlantic — Crew Schedule</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V18l-2 1.5V21l3.5-1 3.5 1v-1.5L11 18v-4.5L21 16Z" fill="currentColor"/></svg>
</span>
<div class="brand-text">
<strong>Skyward Atlantic</strong>
<span>Crew Ops · Roster Board</span>
</div>
</div>
<div class="topbar-meta">
<div class="basebadge"><span class="dot"></span>Base JFK · Wk 24</div>
<button class="btn ghost" id="publishBtn" type="button">Publish roster</button>
</div>
</header>
<div class="toolbar" role="region" aria-label="Filters and stats">
<div class="filters" role="group" aria-label="Filter by role">
<button class="chip active" data-role="all" type="button">All crew</button>
<button class="chip" data-role="Captain" type="button">Captains</button>
<button class="chip" data-role="First Officer" type="button">First officers</button>
<button class="chip" data-role="Purser" type="button">Pursers</button>
<button class="chip" data-role="Cabin Crew" type="button">Cabin crew</button>
</div>
<div class="kpis">
<div class="kpi"><span class="kpi-num" id="kpiAssigned">0</span><span class="kpi-lbl">Assigned</span></div>
<div class="kpi"><span class="kpi-num" id="kpiOpen">0</span><span class="kpi-lbl">Open slots</span></div>
<div class="kpi warn"><span class="kpi-num" id="kpiWarn">0</span><span class="kpi-lbl">Rest warnings</span></div>
</div>
</div>
<main class="board-wrap">
<div class="board" id="board" aria-label="Crew roster grid">
<!-- grid injected by JS -->
</div>
<p class="hint">Tip: click an open cell to assign crew · click an assigned chip to swap or view duty detail.</p>
</main>
</div>
<!-- Assign / swap drawer -->
<div class="drawer-scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="drawerTitle">
<header class="drawer-head">
<h2 id="drawerTitle">Assign crew</h2>
<button class="iconbtn" id="drawerClose" type="button" aria-label="Close panel">×</button>
</header>
<div class="drawer-body" id="drawerBody"><!-- injected --></div>
</aside>
<div class="toast-wrap" id="toastWrap" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Crew Schedule
A crew-ops roster board for fictional carrier Skyward Atlantic, laid out as a sticky grid: crew members down the left (captains, first officers, pursers and cabin crew) and a week of long-haul departures across the top, each header showing flight number, airport pair, 24h block times and duration. Every crew row carries a duty-time bar that fills toward the weekly limit and turns amber or red as rest buffers shrink or duty caps approach, with live percentage and rest readouts beneath the name.
Each grid cell is interactive. Empty cells for the relevant role appear as dashed assign slots — click one to open a side drawer listing eligible crew, ranked OK, TIGHT or BLOCKED by a legality check that weighs weekly duty limits, minimum rest and double-booking. Blocked candidates can’t be selected; tight ones assign with a flagged toast. Click a rostered chip instead to open its duty detail, where you can swap the crew member or unassign them. The KPI strip and the per-row duty bars recompute on every change, the role chips filter the grid, and the publish button reports any open slots or rest flags before sign-off.
Illustrative UI only — fictional airline, not a real booking or flight system.