4 Commits

Author SHA1 Message Date
3e54475d0b Merge pull request 'docs/rfc-timetable-v0.2-foundation' (#14) from docs/rfc-timetable-v0.2-foundation into main
Reviewed-on: #14
2026-05-08 17:28:47 +02:00
296e352e2d docs(rfc-timetable): mark v0.2 as Approved 2026-05-08 17:25:31 +02:00
c9863ee4f8 Add design en information for developing the Artist Management module 2026-05-08 17:01:13 +02:00
a57437a4b7 audit(timetable): complete prototype audit for RFC v0.2
Capture inventory, data model, component architecture, interaction
patterns, pure logic algorithms (with verbatim excerpts), design tokens,
and 20 RFC v0.2 observations from the standalone React prototype at
resources/Crewli - Artist  Timetable Management/.

Read-only audit; no prototype files modified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:04:00 +02:00
15 changed files with 8371 additions and 1 deletions

View File

@@ -18,4 +18,5 @@ dev-docs/GLITCHTIP.md
dev-docs/ARCH-OBSERVABILITY.md
dev-docs/runbooks/observability-triage.md
dev-docs/runbooks/observability-erasure.md
dev-docs/RFC-WS-6.md
dev-docs/RFC-WS-6.md
dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Crewli · Timetable</title>
<meta name="viewport" content="width=1280" />
<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&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css" />
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="data.js"></script>
<script src="helpers.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="modals.jsx"></script>
<script type="text/babel" src="popover.jsx"></script>
<script type="text/babel" src="timetable.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,517 @@
// Main app — wires data, state, modals, popover, drawer, tweaks together.
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"zoom": 1,
"density": "regular",
"showPercent": true
}/*EDITMODE-END*/;
function App() {
const D = window.CrewliData;
const H = window.CrewliHelpers;
const M = window.CrewliModals;
const P = window.CrewliPopover;
const TT = window.CrewliTimetable;
// ─── Mutable state (local, no backend) ────────────────────────────
const [stages, setStages] = React.useState(D.STAGES);
const [stageDays, setStageDays] = React.useState(D.STAGE_DAYS);
const [artists, setArtists] = React.useState(D.ARTISTS);
const [performances, setPerformances] = React.useState(D.PERFORMANCES);
const [parked, setParked] = React.useState(D.PARKED);
const [pending, setPending] = React.useState(D.PENDING);
const [activeDayId, setActiveDayId] = React.useState(D.EVENT.days[0].id);
// ─── UI state ─────────────────────────────────────────────────────
const [popover, setPopover] = React.useState(null); // { perf, rect } or { kind: 'queue', queueKey, item, rect }
const [drawer, setDrawer] = React.useState(null); // { perf }
const [perfModal, setPerfModal] = React.useState(null); // { mode, perf }
const [stageModal, setStageModal] = React.useState(null); // stage
const [matrixOpen, setMatrixOpen] = React.useState(false);
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const activeDay = D.EVENT.days.find(d => d.id === activeDayId);
// Stages active on this day
const dayStages = React.useMemo(() => {
const ids = new Set(stageDays.filter(sd => sd.day_id === activeDayId).map(sd => sd.stage_id));
return stages.filter(s => ids.has(s.id));
}, [stages, stageDays, activeDayId]);
const dayPerformances = performances.filter(p => p.day_id === activeDayId);
// Reducer-ish dispatch
function dispatch(action) {
if (action.type === "update_perf") {
setPerformances(prev => prev.map(p => p.id === action.perf.id ? action.perf : p));
} else if (action.type === "delete_perf") {
setPerformances(prev => prev.filter(p => p.id !== action.id));
} else if (action.type === "add_perf") {
setPerformances(prev => [...prev, action.perf]);
} else if (action.type === "update_stage") {
setStages(prev => prev.map(s => s.id === action.stage.id ? action.stage : s));
} else if (action.type === "delete_stage") {
setStages(prev => prev.filter(s => s.id !== action.id));
setStageDays(prev => prev.filter(sd => sd.stage_id !== action.id));
// Move scheduled performances on this stage to the wachtrij (parked) i.p.v. ze te verwijderen.
setPerformances(prev => prev.filter(p => p.stage_id !== action.id));
setParked(prev => [
...prev,
...performances
.filter(p => p.stage_id === action.id)
.map(p => ({ ...p, stage_id: null, dur: (p.end - p.start) || 60 })),
]);
} else if (action.type === "add_stage") {
setStages(prev => [...prev, action.stage]);
setStageDays(prev => [...prev, ...action.days.map(did => ({ stage_id: action.stage.id, day_id: did }))]);
} else if (action.type === "reorder_stages") {
// dayStageIds = the new ordering for the day's stages.
// Strategy: keep non-day stages in their relative positions; replace the
// dayStages slice with the new order.
setStages(prev => {
const dayIdSet = new Set(action.dayStageIds);
const dayLookup = Object.fromEntries(prev.filter(s => dayIdSet.has(s.id)).map(s => [s.id, s]));
const orderedDay = action.dayStageIds.map(id => dayLookup[id]).filter(Boolean);
const result = [];
let cursor = 0;
for (const s of prev) {
if (dayIdSet.has(s.id)) {
// Replace with the next entry from orderedDay
if (cursor < orderedDay.length) result.push(orderedDay[cursor++]);
} else {
result.push(s);
}
}
return result;
});
} else if (action.type === "set_stage_days_for_stage") {
setStageDays(prev => [
...prev.filter(sd => sd.stage_id !== action.stageId),
...action.dayIds.map(did => ({ stage_id: action.stageId, day_id: did })),
]);
} else if (action.type === "set_stage_days_all") {
setStageDays(action.list);
} else if (action.type === "park_perf") {
const p = performances.find(x => x.id === action.id);
if (!p) return;
setPerformances(prev => prev.filter(x => x.id !== action.id));
setParked(prev => [...prev, { ...p, stage_id: null }]);
} else if (action.type === "unpark_perf") {
const p = parked.find(x => x.id === action.perf.id);
if (!p) return;
setParked(prev => prev.filter(x => x.id !== action.perf.id));
setPerformances(prev => [...prev, action.perf]);
} else if (action.type === "schedule_pending") {
setPending(prev => prev.filter(x => x.id !== action.pendingId));
setPerformances(prev => [...prev, action.perf]);
} else if (action.type === "delete_pending") {
setPending(prev => prev.filter(x => x.id !== action.id));
} else if (action.type === "delete_parked") {
setParked(prev => prev.filter(x => x.id !== action.id));
} else if (action.type === "update_pending_status") {
// Pending items don't actually carry a status — promoting their status
// means moving them into parked (no stage_id) with that status.
const pa = pending.find(x => x.id === action.id);
if (!pa) return;
const newParked = {
id: "p_" + Math.random().toString(36).slice(2, 8),
artist_id: pa.artist_id, day_id: pa.day_id,
stage_id: null, start: null, end: null,
status: action.status,
};
setPending(prev => prev.filter(x => x.id !== action.id));
setParked(prev => [...prev, newParked]);
} else if (action.type === "update_parked_status") {
setParked(prev => prev.map(p => p.id === action.id ? { ...p, status: action.status } : p));
} else if (action.type === "add_parked") {
setParked(prev => [...prev, action.parked]);
} else if (action.type === "add_artist") {
setArtists(prev => [...prev, action.artist]);
}
}
// Move parked → performances when used via drag
React.useEffect(() => {
const movedBack = performances.filter(p => p.stage_id !== null && parked.some(pk => pk.id === p.id));
if (movedBack.length > 0) {
setParked(prev => prev.filter(pk => !movedBack.some(m => m.id === pk.id)));
}
}, [performances]);
// Layout numerics
const pxPerMin = 4 * t.zoom;
const rowHeight = t.density === "compact" ? 52 : t.density === "comfy" ? 84 : 64;
// ─── Conflict counts (for header chip) ─────────────────────────────
const conflictIds = H.findConflicts(dayPerformances);
// Day counts
const stageCountByDay = {};
D.EVENT.days.forEach(d => {
stageCountByDay[d.id] = stageDays.filter(sd => sd.day_id === d.id).length;
});
// ─── Handlers ─────────────────────────────────────────────────────
function handleSelectPerf(perf, rect) {
setPopover({ kind: "perf", perfId: perf.id, rect });
setDrawer(null);
}
function handleSelectQueueItem(item, rect) {
// item: { kind, id, artist, status, dur, src }
setPopover({ kind: "queue", queueKey: item.kind + ":" + item.id, item, rect });
setDrawer(null);
}
function handleClickEmpty(stage, minute, end, lane) {
setPerfModal({
mode: "add",
perf: {
stage_id: stage.id,
start: minute,
end: typeof end === "number" ? end : minute + 60,
status: "concept",
...(Number.isInteger(lane) ? { lane } : {}),
},
});
setPopover(null);
}
function handleEditStage(stage) { setStageModal(stage); setPopover(null); }
function handleAddStage() {
// Draft stage — niet meteen toevoegen aan state. StageEditor commit'et bij Opslaan.
const id = "s_" + Math.random().toString(36).slice(2, 8);
const draft = { id, name: "Nieuwe stage", color: "#3cc2a8", capacity: 2000, __draft: true };
setStageModal(draft);
}
const popoverPerf = popover && popover.kind === "perf" ? performances.find(p => p.id === popover.perfId) : null;
const drawerPerf = drawer ? performances.find(p => p.id === drawer.perfId) : null;
return (
<div className="cw-app">
<Sidebar />
<main className="cw-main">
<Header
dayCounts={stageCountByDay}
conflicts={conflictIds.size}
activeDayId={activeDayId}
onChangeDay={setActiveDayId}
onOpenMatrix={() => setMatrixOpen(true)}
onAddStage={handleAddStage}
onAddPerformance={() => setPerfModal({
mode: "add",
// No stage/time = lands in wachtrij
perf: { status: "requested" }
})}
/>
<div className="cw-tt-wrap">
<TT.Timetable
dispatch={(action) => {
if (action.type === "update_perf" && parked.some(pk => pk.id === action.perf.id) && action.perf.stage_id) {
dispatch({ type: "unpark_perf", perf: action.perf });
} else {
dispatch(action);
}
}}
activeDay={activeDay}
stages={dayStages}
performances={dayPerformances}
parked={parked.filter(p => p.day_id === activeDayId)}
pending={pending.filter(p => p.day_id === activeDayId)}
artists={artists}
onSelectPerf={handleSelectPerf}
onClickEmptyCell={handleClickEmpty}
onEditStage={handleEditStage}
onScheduleFromParking={({ pending: pa, stage, minute, lane, openModal }) => {
if (openModal) {
setPerfModal({
mode: "add",
perf: { artist_id: pa.artist_id, stage_id: stage.id, start: minute, end: minute + 60, status: "requested", lane },
pendingId: pa.id,
});
} else {
const newPerf = {
id: "p_" + Math.random().toString(36).slice(2, 8),
artist_id: pa.artist_id, stage_id: stage.id, day_id: pa.day_id,
start: minute, end: minute + 60, status: "requested",
...(Number.isInteger(lane) ? { lane } : {}),
};
dispatch({ type: "schedule_pending", pendingId: pa.id, perf: newPerf });
}
}}
pxPerMin={pxPerMin}
baseRowHeight={rowHeight}
density={t.density}
showPercent={t.showPercent}
onSelectQueueItem={handleSelectQueueItem}
selectedQueueId={popover && popover.kind === "queue" ? popover.queueKey : null}
/>
</div>
<FooterToolbar
stagesActive={dayStages.length}
performances={dayPerformances.length}
conflicts={conflictIds.size}
/>
</main>
{/* Popover — performance */}
{popover && popover.kind === "perf" && popoverPerf && (() => {
const stage = stages.find(s => s.id === popoverPerf.stage_id);
const artist = artists.find(a => a.id === popoverPerf.artist_id);
return (
<P.Popover
perf={popoverPerf} artist={artist} stage={stage}
anchorRect={popover.rect} sections={D.ADVANCE_SECTIONS}
onChangeStatus={(k) => dispatch({ type: "update_perf", perf: { ...popoverPerf, status: k }})}
onOpenDetailPage={(a, p) => alert(`Navigatie naar /events/${D.EVENT.id || "ezf_2026"}/artists/${a.id} (out of scope voor deze PoC)`)}
onClose={() => setPopover(null)}
/>
);
})()}
{/* Popover — queue item (read-only summary, status switch, navigate to detail) */}
{popover && popover.kind === "queue" && (() => {
const it = popover.item;
const artist = artists.find(a => a.id === it.artist.id);
return (
<P.QueuePopover
item={it} artist={artist}
anchorRect={popover.rect} sections={D.ADVANCE_SECTIONS}
onChangeStatus={(k) => {
if (it.kind === "pending") dispatch({ type: "update_pending_status", id: it.id, status: k });
else dispatch({ type: "update_parked_status", id: it.id, status: k });
}}
onOpenDetailPage={(a) => alert(`Navigatie naar /events/${D.EVENT.id || "ezf_2026"}/artists/${a.id} (out of scope voor deze PoC)`)}
onClose={() => setPopover(null)}
/>
);
})()}
{/* Drawer is no longer used — detail navigation is handled via Open detailpagina */}
{/* Modals */}
{perfModal && (
<M.PerformanceModal
mode={perfModal.mode} perf={perfModal.perf}
day={activeDay} stages={dayStages.length ? dayStages : stages}
artists={artists}
onSave={({ newArtist, perf }) => {
if (perfModal.mode === "add") {
if (newArtist) dispatch({ type: "add_artist", artist: newArtist });
if (perf.stage_id) {
dispatch({ type: "add_perf", perf });
} else {
dispatch({ type: "add_parked", parked: { ...perf, dur: perf.dur || 60 } });
}
} else {
dispatch({ type: "update_perf", perf });
}
setPerfModal(null);
}}
onDelete={(id) => { dispatch({ type: "delete_perf", id }); setPerfModal(null); }}
onClose={() => setPerfModal(null)}
/>
)}
{stageModal && (
<M.StageEditor
stage={stageModal} days={D.EVENT.days} stageDays={stageDays}
mode={stageModal.__draft ? "create" : "edit"}
defaultDayIds={stageModal.__draft ? [activeDayId] : null}
onSave={(s, dayIds) => {
const { __draft, ...clean } = s;
if (stageModal.__draft) {
dispatch({ type: "add_stage", stage: clean, days: dayIds });
} else {
dispatch({ type: "update_stage", stage: clean });
dispatch({ type: "set_stage_days_for_stage", stageId: clean.id, dayIds });
}
setStageModal(null);
}}
onDelete={(id) => {
const affected = performances.filter(p => p.stage_id === id).length;
const stageName = stages.find(s => s.id === id)?.name || "deze stage";
const msg = affected > 0
? `Weet je zeker dat je "${stageName}" wilt verwijderen?\n\n${affected} ingeplande act${affected === 1 ? "" : "s"} ${affected === 1 ? "wordt" : "worden"} naar de wachtrij verplaatst (niet verwijderd).`
: `Weet je zeker dat je "${stageName}" wilt verwijderen?`;
if (!window.confirm(msg)) return;
dispatch({ type: "delete_stage", id });
setStageModal(null);
}}
onClose={() => setStageModal(null)}
/>
)}
{matrixOpen && (
<M.LineupMatrix
stages={stages} days={D.EVENT.days} stageDays={stageDays}
onSave={(list) => { dispatch({ type: "set_stage_days_all", list }); setMatrixOpen(false); }}
onClose={() => setMatrixOpen(false)}
/>
)}
{/* Tweaks */}
<TweaksPanel>
<TweakSection label="Weergave" />
<TweakRadio label="Dichtheid" value={t.density}
options={["compact", "regular", "comfy"]}
onChange={(v) => setTweak("density", v)} />
<TweakSlider label="Zoom" value={t.zoom} min={0.5} max={2.5} step={0.1} unit="×"
onChange={(v) => setTweak("zoom", v)} />
<TweakSection label="Block details" />
<TweakToggle label="Advancing %" value={t.showPercent} onChange={(v) => setTweak("showPercent", v)} />
</TweaksPanel>
</div>
);
}
// ─── Sidebar (matching Crewli look) ─────────────────────────────────
function Sidebar() {
const items = [
{ icon: "home", label: "Dashboard" },
{ icon: "cal", label: "Evenementen", active: true },
];
const orgItems = [
{ icon: "build", label: "Mijn Organisatie" },
{ icon: "users", label: "Leden" },
{ icon: "biz", label: "Bedrijven" },
{ icon: "alert", label: "Form failures" },
{ icon: "cog", label: "Instellingen" },
];
return (
<aside className="cw-side">
<div className="cw-side-brand">
<span className="cw-side-logo">c</span>
<span className="cw-side-brand-name">Crewli</span>
<span className="cw-side-brand-dot"></span>
</div>
<div className="cw-side-org">
<span className="cw-side-org-icon"></span>
<span className="cw-side-org-name">Stichting Feestfab</span>
</div>
<nav className="cw-side-nav">
{items.map(it => (
<a key={it.label} className={"cw-side-item" + (it.active ? " is-active" : "")}>
<Icon name={it.icon} /> {it.label}
</a>
))}
</nav>
<div className="cw-side-section">Stichting Feestfabriek</div>
<nav className="cw-side-nav">
{orgItems.map(it => (
<a key={it.label} className="cw-side-item">
<Icon name={it.icon} /> {it.label}
</a>
))}
</nav>
</aside>
);
}
// minimal inline icons
function Icon({ name }) {
const common = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" };
const paths = {
home: <path d="M2 7 L8 2 L14 7 V13 H10 V9 H6 V13 H2 Z" />,
cal: <g><rect x="2.5" y="3.5" width="11" height="9.5" rx="1.5"/><path d="M2.5 6.5 H13.5"/><path d="M5 2 V5 M11 2 V5"/></g>,
build: <g><rect x="3" y="2.5" width="10" height="11" rx="1"/><path d="M6 5 H7 M9 5 H10 M6 8 H7 M9 8 H10 M6 11 H7 M9 11 H10"/></g>,
users: <g><circle cx="8" cy="6" r="2.2"/><path d="M3.5 13 C 3.5 10 6 9 8 9 C 10 9 12.5 10 12.5 13"/></g>,
biz: <g><rect x="2.5" y="4.5" width="11" height="9" rx="1"/><path d="M6 4.5 V3 H10 V4.5"/><path d="M2.5 8.5 H13.5"/></g>,
alert: <g><path d="M8 2 L14 13 L2 13 Z"/><path d="M8 7 V10 M8 11.5 V11.7"/></g>,
cog: <g><circle cx="8" cy="8" r="2"/><path d="M8 2 V4 M8 12 V14 M2 8 H4 M12 8 H14 M3.8 3.8 L5.2 5.2 M10.8 10.8 L12.2 12.2 M3.8 12.2 L5.2 10.8 M10.8 5.2 L12.2 3.8"/></g>,
plus: <g><path d="M8 3 V13 M3 8 H13"/></g>,
edit: <g><path d="M2.5 13.5 V11 L11 2.5 L13.5 5 L5 13.5 Z"/></g>,
grid: <g><rect x="2.5" y="2.5" width="4" height="4"/><rect x="9.5" y="2.5" width="4" height="4"/><rect x="2.5" y="9.5" width="4" height="4"/><rect x="9.5" y="9.5" width="4" height="4"/></g>,
bell: <g><path d="M3.5 11 H12.5 C 11 10 11 5.5 8 5.5 C 5 5.5 5 10 3.5 11 Z"/><path d="M6.5 12.5 C 7 13.5 9 13.5 9.5 12.5"/></g>,
sun: <g><circle cx="8" cy="8" r="2.5"/><path d="M8 2 V3.5 M8 12.5 V14 M2 8 H3.5 M12.5 8 H14 M3.5 3.5 L4.5 4.5 M11.5 11.5 L12.5 12.5 M3.5 12.5 L4.5 11.5 M11.5 4.5 L12.5 3.5"/></g>,
search:<g><circle cx="7" cy="7" r="4"/><path d="M10 10 L13.5 13.5"/></g>,
arrow: <g><path d="M9 3 L4 8 L9 13"/></g>,
share: <g><circle cx="4" cy="8" r="1.5"/><circle cx="12" cy="4" r="1.5"/><circle cx="12" cy="12" r="1.5"/><path d="M5.3 7.2 L10.7 4.7 M5.3 8.8 L10.7 11.3"/></g>,
chev: <g><path d="M5 6.5 L8 9.5 L11 6.5"/></g>,
};
return <svg {...common}>{paths[name] || null}</svg>;
}
// ─── Header ─────────────────────────────────────────────────────────
function Header({ dayCounts, conflicts, activeDayId, onChangeDay, onOpenMatrix, onAddPerformance, onAddStage }) {
const D = window.CrewliData;
return (
<div className="cw-hdr">
<div className="cw-hdr-top">
<button className="cw-hdr-back"><Icon name="arrow" /></button>
<div>
<div className="cw-hdr-title">
{D.EVENT.name}
<span className="cw-hdr-pill">Festival</span>
</div>
<div className="cw-hdr-sub">10-07-2026 11-07-2026 · Timetable</div>
</div>
<div style={{ flex: 1 }}></div>
<button className="cw-btn"><Icon name="share" /> Delen</button>
<button className="cw-btn"><Icon name="chev" /> Registratie open</button>
</div>
<div className="cw-hdr-tabs">
<button className="cw-tab">Overzicht</button>
<button className="cw-tab">Programmaonderdelen</button>
<button className="cw-tab">Tijdsloten</button>
<button className="cw-tab">Secties &amp; Shifts</button>
<button className="cw-tab">Personen</button>
<button className="cw-tab">Artiesten</button>
<button className="cw-tab is-active">Timetable</button>
</div>
<div className="cw-hdr-bar">
<div className="cw-day-tabs" role="tablist">
{D.EVENT.days.map(d => (
<button key={d.id}
role="tab"
className={"cw-day-tab" + (d.id === activeDayId ? " is-active" : "")}
onClick={() => onChangeDay(d.id)}>
<span className="cw-day-tab-day">{d.label}</span>
<span className="cw-day-tab-meta">{dayCounts[d.id] || 0} stages</span>
</button>
))}
</div>
<div className="cw-hdr-meta">
{conflicts > 0 && (
<span className="cw-pill cw-pill-warn">
<span className="cw-pill-dot" style={{ background: "#d63d4b" }}></span>
{conflicts} {conflicts === 1 ? "conflict" : "conflicten"}
</span>
)}
</div>
<div style={{ flex: 1 }}></div>
<button className="cw-btn cw-btn-soft" onClick={onAddPerformance}>
<Icon name="plus" /> Performance toevoegen
</button>
<button className="cw-btn cw-btn-soft" onClick={onAddStage}>
<Icon name="plus" /> Stage toevoegen
</button>
<button className="cw-btn cw-btn-soft" onClick={onOpenMatrix}>
<Icon name="grid" /> Stages per {D.EVENT.sub_event_label || "dag"}
</button>
</div>
</div>
);
}
function FooterToolbar({ stagesActive, performances, conflicts }) {
return (
<div className="cw-foot">
<div className="cw-foot-stats">
<span><b>{stagesActive}</b> stages</span>
<span><b>{performances}</b> performances</span>
<span><b>{conflicts}</b> conflicten</span>
</div>
<div style={{ flex: 1 }}></div>
</div>
);
}
// Mount
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

View File

@@ -0,0 +1,159 @@
// Echt Zomer Feesten 2026 - demo data
// Schema mirrors Crewli §3.5.7: stages, stage_days, artists, performances
window.CrewliData = (function () {
const EVENT = {
id: "ezf_2026",
name: "Echt Zomer Feesten",
edition: "2026",
sub_event_label: "dag", // configurable in Crewli — could be "dag", "deelevent", "fase", etc.
days: [
{ id: "d_fr", date: "2026-07-10", label: "Vrijdag", short: "Vr" },
{ id: "d_sa", date: "2026-07-11", label: "Zaterdag", short: "Za" },
],
};
// Stages — physical platforms. color is the stage swatch (used as left-band on row)
const STAGES = [
{ id: "s_hardstyle", name: "Hardstyle District", color: "#e85d75", capacity: 4500 },
{ id: "s_techno", name: "Techno × House", color: "#7a8af0", capacity: 3000 },
{ id: "s_hollandse", name: "Hollandse Hoek", color: "#f0a04b", capacity: 5000 },
{ id: "s_urban", name: "Echt Urban", color: "#5fc9a8", capacity: 2800 },
{ id: "s_silent", name: "Silent Disco", color: "#c89af0", capacity: 800 },
{ id: "s_schirm", name: "Schirmbar", color: "#e8d05f", capacity: 600 },
];
// stage_days pivot: which stages run on which day
const STAGE_DAYS = [
// Vrijdag
{ stage_id: "s_hardstyle", day_id: "d_fr" },
{ stage_id: "s_techno", day_id: "d_fr" },
{ stage_id: "s_silent", day_id: "d_fr" },
{ stage_id: "s_schirm", day_id: "d_fr" },
// Zaterdag
{ stage_id: "s_hollandse", day_id: "d_sa" },
{ stage_id: "s_urban", day_id: "d_sa" },
{ stage_id: "s_silent", day_id: "d_sa" },
{ stage_id: "s_schirm", day_id: "d_sa" },
];
// Advance sections (per artist, configurable in real schema; here we use a
// pragmatic standard set so the popover dots have meaning)
const ADVANCE_SECTIONS = [
{ key: "tour", label: "Tourmanager" },
{ key: "hosp", label: "Hospitality" },
{ key: "travel", label: "Travel party" },
{ key: "flight", label: "Flight" },
{ key: "rider", label: "Tech rider" },
];
// Artists. `draw` = expected pull (for capacity warnings). `advance` = which
// sections are completed (just demo state for the popover).
// Genres (closed list — drives filters in the wachtrij and the small label on blocks)
const GENRES = ["Hardstyle", "Techno", "House", "Hollands", "Pop", "Urban", "Disco", "Aprés"];
const ARTISTS = [
{ id: "a_1", name: "D-Block & S-te-Fan", initials: "DS", genre: "Hardstyle", draw: 4200, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
{ id: "a_2", name: "Sub Zero Project", initials: "SZ", genre: "Hardstyle", draw: 3800, advance: { tour: true, hosp: true, travel: false, flight: false, rider: true } },
{ id: "a_3", name: "Warface", initials: "WF", genre: "Hardstyle", draw: 3000, advance: { tour: true, hosp: false, travel: false, flight: false, rider: false } },
{ id: "a_4", name: "Devin Wild", initials: "DW", genre: "Hardstyle", draw: 2400, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
{ id: "a_5", name: "Reinier Zonneveld", initials: "RZ", genre: "Techno", draw: 2800, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
{ id: "a_6", name: "Boris Brejcha", initials: "BB", genre: "Techno", draw: 3200, advance: { tour: true, hosp: true, travel: false, flight: true, rider: true } },
{ id: "a_7", name: "Mau P", initials: "MP", genre: "House", draw: 2200, advance: { tour: true, hosp: false, travel: false, flight: false, rider: true } },
{ id: "a_8", name: "Snollebollekes", initials: "SB", genre: "Hollands", draw: 5200, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
{ id: "a_9", name: "Mart Hoogkamer", initials: "MH", genre: "Hollands", draw: 3600, advance: { tour: true, hosp: true, travel: false, flight: false, rider: true } },
{ id: "a_10", name: "Suzan & Freek", initials: "SF", genre: "Pop", draw: 4800, advance: { tour: false, hosp: false, travel: false, flight: false, rider: false } },
{ id: "a_11", name: "Frans Duijts", initials: "FD", genre: "Hollands", draw: 2600, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
{ id: "a_12", name: "Bizzey", initials: "BZ", genre: "Urban", draw: 2400, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
{ id: "a_13", name: "Frenna", initials: "FN", genre: "Urban", draw: 2800, advance: { tour: true, hosp: true, travel: false, flight: false, rider: false } },
{ id: "a_14", name: "Kris Kross Amsterdam", initials: "KK", genre: "Pop", draw: 2200, advance: { tour: true, hosp: false, travel: false, flight: false, rider: true } },
{ id: "a_15", name: "DJ Marlon", initials: "DM", genre: "Disco", draw: 600, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
{ id: "a_16", name: "DJ Senna", initials: "DS", genre: "Disco", draw: 500, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
{ id: "a_17", name: "Café Catootje", initials: "CC", genre: "Aprés", draw: 400, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
{ id: "a_18", name: "Apré Heroes", initials: "AH", genre: "Aprés", draw: 350, advance: { tour: false, hosp: true, travel: false, flight: true, rider: false } },
];
// Performances — booking_status: concept | requested | option | confirmed | contracted | cancelled
// Times are minutes since the day's anchor (14:00). End times are minute offsets too.
// To keep math simple we use the same minute-grid for both days.
const PERFORMANCES = [
// ─── Vrijdag · Hardstyle District (s_hardstyle, d_fr) ─────────────
{ id: "p_1", artist_id: "a_3", stage_id: "s_hardstyle", day_id: "d_fr", start: 240, end: 360, status: "confirmed" }, // 18:00-20:00
{ id: "p_2", artist_id: "a_4", stage_id: "s_hardstyle", day_id: "d_fr", start: 360, end: 480, status: "confirmed" }, // 20:00-22:00
{ id: "p_3", artist_id: "a_2", stage_id: "s_hardstyle", day_id: "d_fr", start: 480, end: 600, status: "contracted" }, // 22:00-00:00
// Concept-fase: 3 parallelle aanvragen voor de 00:00-02:00 slot — pas één wordt vastgelegd
{ id: "p_4", artist_id: "a_1", stage_id: "s_hardstyle", day_id: "d_fr", start: 600, end: 720, status: "option" }, // 00:00-02:00
{ id: "p_4b", artist_id: "a_2", stage_id: "s_hardstyle", day_id: "d_fr", start: 600, end: 720, status: "requested" }, // 00:00-02:00 (parallel aanvraag)
{ id: "p_4c", artist_id: "a_4", stage_id: "s_hardstyle", day_id: "d_fr", start: 615, end: 705, status: "concept" }, // 00:15-01:45 (alternatief concept)
// ─── Vrijdag · Techno × House (s_techno, d_fr) ────────────────────
{ id: "p_5", artist_id: "a_7", stage_id: "s_techno", day_id: "d_fr", start: 300, end: 420, status: "confirmed" }, // 19:00-21:00
{ id: "p_6", artist_id: "a_5", stage_id: "s_techno", day_id: "d_fr", start: 420, end: 555, status: "contracted" }, // 21:00-23:15
{ id: "p_7", artist_id: "a_6", stage_id: "s_techno", day_id: "d_fr", start: 555, end: 690, status: "requested" }, // 23:15-01:30
// ─── Vrijdag · Silent Disco (s_silent, d_fr) ──────────────────────
{ id: "p_8", artist_id: "a_15", stage_id: "s_silent", day_id: "d_fr", start: 360, end: 540, status: "confirmed" }, // 20:00-23:00
{ id: "p_9", artist_id: "a_16", stage_id: "s_silent", day_id: "d_fr", start: 540, end: 720, status: "confirmed" }, // 23:00-02:00
// ─── Vrijdag · Schirmbar (s_schirm, d_fr) ─────────────────────────
{ id: "p_10", artist_id: "a_17", stage_id: "s_schirm", day_id: "d_fr", start: 180, end: 360, status: "confirmed" }, // 17:00-20:00
{ id: "p_11", artist_id: "a_18", stage_id: "s_schirm", day_id: "d_fr", start: 360, end: 540, status: "concept" }, // 20:00-23:00
// ─── Zaterdag · Hollandse Hoek (s_hollandse, d_sa) ────────────────
{ id: "p_12", artist_id: "a_11", stage_id: "s_hollandse", day_id: "d_sa", start: 240, end: 345, status: "confirmed" }, // 18:00-19:45
{ id: "p_13", artist_id: "a_9", stage_id: "s_hollandse", day_id: "d_sa", start: 345, end: 465, status: "confirmed" }, // 19:45-21:45
{ id: "p_14", artist_id: "a_10", stage_id: "s_hollandse", day_id: "d_sa", start: 465, end: 600, status: "option" }, // 21:45-00:00 (cap warning - draw 4800 vs 5000)
{ id: "p_15", artist_id: "a_8", stage_id: "s_hollandse", day_id: "d_sa", start: 600, end: 720, status: "contracted" }, // 00:00-02:00 (cap warning — draw 5200 > 5000)
// ─── Zaterdag · Echt Urban (s_urban, d_sa) ────────────────────────
{ id: "p_16", artist_id: "a_14", stage_id: "s_urban", day_id: "d_sa", start: 300, end: 420, status: "confirmed" }, // 19:00-21:00
{ id: "p_17", artist_id: "a_13", stage_id: "s_urban", day_id: "d_sa", start: 420, end: 540, status: "confirmed" }, // 21:00-23:00
{ id: "p_18", artist_id: "a_12", stage_id: "s_urban", day_id: "d_sa", start: 540, end: 660, status: "requested" }, // 23:00-01:00
// Demo conflict: two performances overlapping on Echt Urban
{ id: "p_19", artist_id: "a_7", stage_id: "s_urban", day_id: "d_sa", start: 510, end: 600, status: "concept" }, // 22:30-00:00 overlap with Frenna
// ─── Zaterdag · Silent Disco (s_silent, d_sa) ─────────────────────
{ id: "p_20", artist_id: "a_16", stage_id: "s_silent", day_id: "d_sa", start: 360, end: 540, status: "confirmed" },
{ id: "p_21", artist_id: "a_15", stage_id: "s_silent", day_id: "d_sa", start: 540, end: 720, status: "confirmed" },
// ─── Zaterdag · Schirmbar (s_schirm, d_sa) ────────────────────────
{ id: "p_22", artist_id: "a_18", stage_id: "s_schirm", day_id: "d_sa", start: 180, end: 360, status: "concept" },
{ id: "p_23", artist_id: "a_17", stage_id: "s_schirm", day_id: "d_sa", start: 360, end: 540, status: "confirmed" },
];
// ─── Parked performances (stage_id === null) ─────────────────────
// Artists picked up but not yet placed on a stage. Day-id keeps day-context.
const PARKED = [
{ id: "pk_1", artist_id: "a_5", stage_id: null, day_id: "d_fr", start: 480, end: 600, status: "option" },
{ id: "pk_2", artist_id: "a_13", stage_id: null, day_id: "d_sa", start: 540, end: 660, status: "requested" },
{ id: "pk_3", artist_id: "a_7", stage_id: null, day_id: "d_fr", start: 420, end: 540, status: "concept" },
{ id: "pk_4", artist_id: "a_9", stage_id: null, day_id: "d_sa", start: 360, end: 450, status: "confirmed" },
{ id: "pk_5", artist_id: "a_11", stage_id: null, day_id: "d_fr", start: 600, end: 690, status: "contracted" },
];
// ─── Pending availability requests ───────────────────────────────
// Artists waarvoor beschikbaarheid is opgevraagd maar nog geen tijdvak gekozen.
const PENDING = [
{ id: "pa_1", artist_id: "a_6", day_id: "d_fr", requested_on: "2026-04-12", note: "Wachten op terugkoppeling agent" },
{ id: "pa_2", artist_id: "a_8", day_id: "d_sa", requested_on: "2026-04-08", note: "Beschikbaar — wacht op fee-onderhandeling" },
{ id: "pa_3", artist_id: "a_12", day_id: "d_sa", requested_on: "2026-04-15", note: "Boeking via Top Notch" },
{ id: "pa_4", artist_id: "a_10", day_id: "d_fr", requested_on: "2026-04-18", note: "Optie tot 30 april" },
];
// Time grid: 14:00 -> 03:00 next day = 13 hours = 780 minutes
// start=0 means 14:00, start=600 means 00:00, start=780 means 03:00
const TIME = {
startHour: 14, // grid starts at 14:00
totalMinutes: 780, // 13 hours
snapMinutes: 15, // drag snap
cellMinutes: 30, // grid cell width
};
return { EVENT, STAGES, STAGE_DAYS, ADVANCE_SECTIONS, ARTISTS, GENRES, PERFORMANCES, PARKED, PENDING, TIME };
})();

View File

@@ -0,0 +1,530 @@
# RFC-TIMETABLE — Artist Timetable Module
## 1. Status
- **State:** Draft for review
- **Created:** 2026-05-08
- **Version:** v0.1
- **Owner:** Bert Hausmans
- **Origin:** UX brainstorm session 2026-05-08 (Claude Chat) — concept + three PoC iterations + six locked decisions
- **Related:**
- `SCHEMA.md` §3.5.7 (artists, performances, stages, stage_days, advance_sections)
- `BACKLOG.md` ARCH-09 (Artist model — hard prerequisite), ART-02 (Timetable backlog item)
- `ARCH-FORM-BUILDER.md` §17 (artist_advance purpose subject_type)
- `CLAUDE.md` "Order of work" (17-step module-generation sequence)
- `design-document.md` §3.5.10 Database design rules
- `dev-docs/PurposeRegistry` — artist_advance purpose with subject_type=artist
## 2. Why this RFC exists
The Artist Timetable is the central planning surface for festival programming.
It is operationally critical: production managers spend hours per week on it,
last-minute changes are common, and a bug here visibly breaks the show. Three
properties make this module non-trivial enough to warrant up-front spec:
1. It depends on a model (Artist) that does not exist yet — see ARCH-09.
2. It bridges three concerns that festival operators conflate but Crewli must
keep distinct: stages (physical), festival_sections (organisational), and
advancing (per-section workflow).
3. The interaction surface (drag-drop Gantt with conflict detection,
cross-day stage variance, popover with advancing summary) does not exist
off-the-shelf in the current frontend stack — the build-vs-buy choice for
the rendering library has multi-week consequences.
This RFC captures every architectural decision so implementation does not
re-litigate them under time pressure. PoC iterations (chat artefacts v1/v2/v3)
are visual references, not authoritative — this document is.
## 3. Scope & non-scope
### In scope (v1)
- CRUD for stages and stage_days (which stage runs which day)
- CRUD for performances (artist on stage at time)
- Horizontal Gantt timetable: stages as rows, time as x-axis, day tabs
- Drag/drop performance: re-time and re-stage in one gesture
- Resize performance duration (snap to 15 min)
- Click-to-add performance from empty grid cell
- Detail popover with avatar, status pill, advancing aggregate, status switch,
delete, manage-booking link
- Conflict detection: same-stage same-day overlapping non-cancelled performances
- Back-to-back marker: ≤5 min gap between consecutive performances on same stage
- "Edit lineup" matrix: bulk toggle stages × days
- Empty-day state: copy-from-other-day affordance
- Activity log on all mutations
- Multi-tenancy via OrganisationScope (FK-chain through event)
### Out of scope (v1)
- **Artist Handling / show-day check-in view** — separate Mission Control module
- **Capacity warning** on performance blocks — requires a new column
(`performances.expected_attendance` or similar). Defer to v2; backlog as
ART-04.
- **Advance section CRUD** — covered by separate Artist Advancing module
(BACKLOG ART-01). The popover only *reads* advance_sections aggregate.
- **PDF / print export** of running order — backlog as ART-05.
- **Multi-select bulk shift** of performances ("move everything on Mainstage
+15 min") — backlog as ART-06.
- **Undo/redo stack** — backlog as ART-07.
- **Mobile-optimised view** — timetable is a desktop tool. Mobile gets a
read-only list view in a later iteration.
- **Stage templates across events** ("copy stages from last year's event")
— backlog as ARCH-10, parallel to ARCH-03 (festival_section templates).
## 4. Locked design decisions
### D1 — Block visual: stage-stripe + status-fill
Each performance block carries:
- Background fill = booking_status colour (6 colours, see §10).
- 3px left stripe = `stages.color` for stage-grouping cue.
- Right grab handle for resize.
Rationale: status-fill alone (PoC v2) lost stage grouping when many stages
shared similar status. The stripe restores the visual stage-cluster signal at
zero cost. (Bert decision 2026-05-08.)
### D2 — Advancing aggregate, not per-section dots
The detail popover shows `n/m completed` (e.g. "3/5 advancing complete") and
the underlying section list as a tooltip on hover. No per-section dots on the
block itself.
Rationale: `advance_sections` is configurable per artist — fixed 5-dot icons
break the moment one artist has 4 sections and another has 7. Aggregate
fraction degrades gracefully across configurations and reads cleanly at small
sizes. (Bert decision 2026-05-08.)
The aggregate is computed as:
```
n = count(advance_sections WHERE artist_id = X AND submission_status = 'accepted')
m = count(advance_sections WHERE artist_id = X)
```
Implementation: cached on `artists` model as a denormalised
`advancing_completed_count` + `advancing_total_count` pair, recomputed via
observer on `advance_section` create/update/delete. Avoids N+1 on timetable
load. To be added in same migration as Artist model (ARCH-09).
### D3 — Manage booking opens artist page (not modal)
Click "Manage booking" in popover → navigate to
`/events/{event}/artists/{artist}?return=timetable&day=fri&t=210000`. The
artist page surfaces all related records (performances, advance_sections,
contacts, riders, itinerary) as tabs. A prominent return banner at top:
"← Back to Timetable (Vr 21:00)" restores scroll/tab context.
Rationale: artist record is too large for modal (5+ relation tables). Page
navigation matches every other Crewli edit surface (shifts, persons,
festival_sections). Bookmarkability + cmd-click-to-new-tab are bonuses
no modal can provide. (Bert decision 2026-05-08.)
### D4 — Stage-day filtering is enforced everywhere
The timetable for day D shows only stages where `stage_days(stage_id, D)`
exists. Performances scheduled on `(stage_id, D)` where `stage_days(...)` does
not exist are **hidden but not deleted** — toggling a stage off a day must be
reversible without data loss.
This is enforced at:
- API list endpoint (filter at SQL level)
- Frontend rendering (defensive — trust API but verify)
- Performance creation — the FormRequest validates that `stage_days`
exists for the requested `(stage_id, performance.date)` and rejects
with 422 otherwise.
### D5 — Conflict detection scope: same-stage same-day overlap only
Cross-stage overlap (same artist on two stages simultaneously) is **not**
flagged in v1. Reason: an artist on two stages at once is rare but legitimate
at small festivals where one DJ runs main set + silent disco simultaneously.
Surface this on the artist page in a future iteration, not on the timetable.
Cancelled performances participate in **no** conflict check.
### D6 — B2B marker rule
Two consecutive non-cancelled performances on the same stage with
`p2.start - p1.end ∈ [0, 5]` minutes get a B2B marker (small dot at the
boundary). Useful for stage managers planning changeover windows.
Pure rendering concern — no schema impact.
### D7 — Drag-drop interaction model
- Drag block horizontally → re-time (snap to 15 min)
- Drag block vertically → re-stage (only across stages active on current day)
- Drag right edge → resize duration (snap to 15 min, min 15 min)
- Drag is committed on mouseup with single PATCH request
- Failed PATCH (validation error, conflict the user wants to refuse) reverts
block to its origin position with a toast
### D8 — Rendering library: custom Vue components, not FullCalendar
**Recommendation: build custom.**
FullCalendar timeline view (`resourceTimelinePlugin`) was the obvious choice
on paper but has three blockers:
1. Premium-licensed (~€600/yr commercial). Acceptable cost but adds
procurement friction and a vendor dependency.
2. The advancing-aggregate badge, stage-stripe, B2B marker, and conflict
ring all require `eventDidMount` render hooks — at that point we're
reimplementing rendering inside FC's render cycle, fighting its DOM.
3. Cross-day stage filtering (D4) does not map cleanly to FC's resource
model. Workaround is per-day resource list rebuild on tab switch, which
defeats incremental-render benefits.
Custom Vue components (TimetableGrid, StageRow, PerformanceBlock,
PerformancePopover, LineupMatrix) on top of native HTML5 drag-and-drop give
total control at ~3 days additional frontend cost. The PoC v3 is already
~80% of the rendering logic. PoC code must be rewritten as proper Vue 3
components (Composition API, `<script setup lang="ts">`) — the PoC is not
production code.
**Open verification before commit:** test drag-and-drop accessibility with
screen reader. If insurmountable, revisit FullCalendar Premium decision.
### D9 — `performances.booking_status` migrates from string to PHP Enum
SCHEMA §3.5.7 currently lists `booking_status` on performances as `string`.
This violates the zero-compromise rule "PHP Enums mandatory for all
type/operator/status fields". Must be promoted to a backed PHP enum during
implementation:
```php
namespace App\Enums\Artist;
enum PerformanceBookingStatus: string {
case Concept = 'concept';
case Requested = 'requested';
case Option = 'option';
case Confirmed = 'confirmed';
case Contracted = 'contracted';
case Cancelled = 'cancelled';
}
```
Same enum is reused on `artists.booking_status` (currently inline string-enum
in migration). Rename of existing column type to use the enum cast in the
Eloquent model. No column-level migration needed — both are already string
columns. SCHEMA.md needs update to reflect this in the implementation prompt.
## 5. Schema impact
### No new tables
All four needed tables exist in §3.5.7: `artists`, `performances`, `stages`,
`stage_days`.
### Two column additions on `artists` (ARCH-09 migration)
```
advancing_completed_count unsigned int default 0
advancing_total_count unsigned int default 0
```
Denormalised aggregate for D2 popover. Recomputed via
`AdvanceSectionObserver`. Both columns indexed only as part of the artist
PK (no separate index needed — read pattern is by artist).
### One enum upgrade
`performances.booking_status` cast to `PerformanceBookingStatus` enum at
Eloquent layer. SCHEMA.md updated to reflect enum constraint in §3.5.7.
### Soft-delete strategy
- `artists`: soft-delete YES (already in schema)
- `performances`: soft-delete YES (cascade with artist via observer; restore
cascades back)
- `stages`: soft-delete NO. Reason: deleting a stage is rare and destructive;
if you mistakenly delete you can recreate. Soft-delete on stages adds
query complexity (joining performances→stages with `withTrashed`) without
meaningful safety win.
- `stage_days`: pure pivot, no soft-delete, hard delete on day-toggle-off.
This is a deviation from the §3.5.10 default ("artists soft-delete yes,
stages not listed") — explicitly noted here.
## 6. Routes & API
All routes scoped under `events.{event}` with `OrganisationScope` enforced
via FK-chain (stage.event.organisation_id, performance.stage.event.organisation_id).
```
GET /api/v1/events/{event}/stages
POST /api/v1/events/{event}/stages
GET /api/v1/events/{event}/stages/{stage}
PATCH /api/v1/events/{event}/stages/{stage}
DELETE /api/v1/events/{event}/stages/{stage}
PUT /api/v1/events/{event}/stages/{stage}/days
Body: { day_dates: ["2026-07-10", "2026-07-11"] }
Replaces stage_days for stage atomically. Returns 200 with
new stage_days collection.
GET /api/v1/events/{event}/performances?day=2026-07-10
POST /api/v1/events/{event}/performances
GET /api/v1/events/{event}/performances/{performance}
PATCH /api/v1/events/{event}/performances/{performance}
DELETE /api/v1/events/{event}/performances/{performance}
```
Notes:
- The `PUT /stages/{stage}/days` endpoint is the matrix-editor backend.
Single-day toggle from the stage editor uses the same endpoint with the
full new array. This is REST-correct (replace resource collection) and
avoids race conditions inherent in N individual POST/DELETE calls.
- `GET /performances?day=YYYY-MM-DD` filters by `performances.date`. Without
the parameter returns all performances for the event (used for cross-day
artist views).
- Resource includes: `artist:id,name,booking_status,advancing_completed_count,advancing_total_count`,
`stage:id,name,color`. Always eager-loaded — no N+1.
- Idempotency-Key header required on POST `/performances` and POST
`/stages` per ARCH §10. Same pattern as form_submissions.
## 7. Frontend architecture
```
apps/app/src/pages/events/[id]/timetable/
index.vue ← page entry, day tab state, ?day query sync
TimetableGrid.vue ← scroll container, time axis, stage rows
StageRow.vue ← left cell + draggable performance row
StageHeaderCell.vue ← swatch + name + capacity + day chips
PerformanceBlock.vue ← single block; emits drag/resize/click events
PerformancePopover.vue ← floating detail; teleported to body
LineupMatrix.vue ← bulk stages × days editor (modal)
StageEditor.vue ← single stage CRUD (modal)
AddPerformanceDialog.vue ← click-to-add modal
EmptyDayState.vue ← "no stages on this day, copy from..."
apps/app/src/composables/timetable/
useTimetable.ts ← TanStack queries: stages, performances
useTimetableMutations.ts ← performance CRUD mutations + optimistic updates
useStageDays.ts ← stage-day matrix mutation
useDragDrop.ts ← pointer event handling, snap math
useTimetableConflicts.ts ← computed conflict + b2b state per day
apps/app/src/types/timetable.ts
Stage, StageDay, Performance, PerformanceBookingStatus (zod schemas)
apps/app/src/api/timetable.ts
Axios calls; types inferred from zod schemas
```
State pattern:
- TanStack Query holds canonical server state.
- Pinia store (`useTimetableStore`) holds *only* UI state (selectedDay,
selectedPerformanceId, popoverPosition, dragState). Never duplicate
server data into Pinia.
- Optimistic updates on drag/drop and resize via `onMutate` rollback pattern.
Form validation: VeeValidate + Zod on AddPerformanceDialog and StageEditor.
Schemas mirrored from `apps/app/src/types/timetable.ts`.
## 8. Activity log
Spatie ActivityLog with `LogsActivity` trait on Stage, Performance, Artist
(latter already covered by ARCH-09).
Logged events:
- `stage.created`, `stage.updated`, `stage.deleted`
- `stage.day_added`, `stage.day_removed` (custom events on PUT /days
diffing the before/after sets)
- `performance.created`, `performance.updated`, `performance.deleted`
- `performance.moved` — special event when stage_id or date changes (high
signal for production managers; surfaces in event audit log)
Stage and performance updates capture before/after on: name, color, capacity
(stages); start_time, end_time, date, stage_id, booking_status (performances).
Activity log entries scoped to organisation + event for audit log filtering.
## 9. Authorization (Policy)
```
StagePolicy:
viewAny(User, Event) — user must be member of event.organisation
view(User, Stage) — same + stage.event_id check
create(User, Event) — user must have permission 'events.manage_program'
update(User, Stage) — same as create + organisation match
delete(User, Stage) — same as update + no related performances or
performances are also deletable
PerformancePolicy:
viewAny(User, Event) — same as Stage
view(User, Performance) — via stage.event.organisation
create(User, Event) — 'events.manage_program' permission
update(User, Performance) — same as create
delete(User, Performance) — same as create
```
`events.manage_program` is a new Spatie permission to add. Roles that get it:
`event_admin`, `program_manager` (new role — added in same sprint).
Volunteers, crew, suppliers do not have it.
## 10. Validation rules
### CreatePerformanceRequest / UpdatePerformanceRequest
```
artist_id required ulid exists:artists,id (scoped to event via stage)
stage_id required ulid exists:stages,id (scoped to event)
date required date
between: event.start_date and event.end_date inclusive
must exist in stage_days(stage_id=value of stage_id,
day_date=value)
→ custom rule StageActiveOnDay
start_time required time format H:i
end_time required time format H:i
after start_time within same date
(cross-midnight handled via end_date if needed; v1 forbids
cross-midnight performances — flag as a known limitation)
booking_status required PerformanceBookingStatus enum value
check_in_status optional CheckInStatus enum, default 'expected'
```
Overlap is **not** a hard validation error — it produces a 200 response with
a `warnings: ["overlap"]` field in the resource. The frontend renders the
conflict ring; the production manager decides whether to resolve.
### CreateStageRequest
```
name required string max:120 unique:stages,name,NULL,id,event_id,{event_id}
color required string regex:/^#[0-9A-Fa-f]{6}$/
capacity nullable integer min:1 max:1000000
active_days required array min:1
active_days.* date between event.start_date and event.end_date
```
### Stage-day matrix replacement (PUT /stages/{stage}/days)
```
day_dates required array min:1 (a stage must always have ≥1 active day)
day_dates.* date between event.start_date and event.end_date
```
If the request would remove a day with scheduled non-cancelled performances,
return 409 Conflict with `{performances_on_removed_days: [...]}`. The frontend
shows the matrix-editor confirmation dialog and resends with
`?force_orphan=true` to commit. The orphaned performances persist as soft
references (still in DB, hidden from views) — recovered when the day is
re-added.
## 11. Open questions / future work
- **Stage capacity-vs-draw warning** — requires `expected_attendance` column
on performances OR `expected_draw_default` on artists. Add as ART-04 in
backlog. Out of v1.
- **Stage templates** — copy stage configuration across events. Backlog as
ARCH-10. Out of v1.
- **Artist double-booking detection across stages** — defer to artist page,
not timetable. Backlog as ART-08.
- **Cross-midnight performances** — currently forbidden by validation
(end_time after start_time within same date). For show-day operations
this is restrictive (a 23:30 → 00:30 set spans midnight). Pragmatic v1
workaround: store as 23:30 → 24:30 with `end_time > 23:59:59` allowed
via custom validation. Or add `end_date` column and treat properly.
**Decision required before implementation.**
## 12. Implementation order
Three Claude Code sessions, sequential:
### Session 1 — ARCH-09 + Backend models
1. Artist model + migration + factory + seeder (ARCH-09)
2. Update `PURPOSE_SUBJECT_FQCN` constant: string-literal → `Artist::class`
3. Stage + StageDay + Performance models + migrations
4. PHP Enums: PerformanceBookingStatus, CheckInStatus (artists)
5. AdvanceSectionObserver to recompute advancing aggregate
6. PerformanceObserver for cascade soft-delete with artist
7. OrganisationScope registration on all three models (FK-chain)
8. SCHEMA.md update (enum upgrade, advancing_*_count columns)
### Session 2 — Backend API + business logic
1. StagePolicy, PerformancePolicy
2. New permission `events.manage_program`, new role `program_manager`
3. StageService, PerformanceService (StageDayService for matrix replace)
4. FormRequests (CreateStageRequest, UpdateStageRequest, etc.)
5. Custom rules: StageActiveOnDay
6. API Resources (StageResource, PerformanceResource with includes)
7. Controllers (5 routes per module)
8. Routes registered in api.php
9. Activity log integration on all mutations
10. Tests: feature tests for all endpoints, policy tests, validation tests,
overlap-warning tests, day-removal-with-orphans flow
11. API.md update
### Session 3 — Frontend
1. Types + zod schemas
2. API client
3. TanStack composables
4. Pinia store (UI state only)
5. Page entry + routing
6. TimetableGrid + StageRow + StageHeaderCell
7. PerformanceBlock with drag/resize composable
8. PerformancePopover with advancing aggregate fetch
9. AddPerformanceDialog
10. StageEditor + LineupMatrix modals
11. EmptyDayState
12. Vitest component tests on critical math (snap, overlap, conflict)
13. End-to-end flow test with Playwright (or whatever Crewli uses): drag
performance, switch days, edit lineup matrix
Estimate: 2.5 + 2 + 3 = **7.5 days across three sessions**.
## 13. Test strategy
### Backend unit + feature
- StageActiveOnDay rule
- PerformanceService overlap detection (warns, does not block)
- StageDayService atomic replacement with orphan-performance handling
- AdvanceSectionObserver count recomputation
- PerformanceObserver cascade soft-delete with artist
- All policy methods (positive + negative)
- All endpoints (200, 201, 204, 401, 403, 404, 409, 422)
### Frontend
- Pure: snap math, conflict detection, B2B detection, day-filter
- Component: PerformanceBlock renders correctly per booking_status
- Component: drag invokes mutation with optimistic update + rollback on error
- Integration: full add → drag → edit → delete flow with mocked API
- Accessibility: keyboard navigation through stages, screen-reader on
performance blocks (focus order, aria-labels)
## 14. Dependencies
### Hard
- ARCH-09 (Artist model) — must land in Session 1 before stages/performances
can be created. Currently a string-literal `'App\\Models\\Artist'` in
`PURPOSE_SUBJECT_FQCN`; first artist submission would fault. Resolves at
start of Session 1.
- New permission `events.manage_program` and role `program_manager`
Session 2 prerequisite.
### Soft
- ART-01 (Artist Advancing portal) — provides the advance_sections data
that powers the popover aggregate. Without ART-01 the aggregate is always
`0/0`. Acceptable for v1: the aggregate displays with grey "—" when
total is 0.
- ARCH-10 (stage templates) — would speed up setup of recurring events but
is not blocking.
### None
- Form Builder, Accreditation Engine, Briefings — fully independent.

View File

@@ -0,0 +1,470 @@
# Timetable Module — Implementatie-spec
> **Doel**: een interactieve timetable-module in de Crewli-app waarin programmers acts kunnen plannen op stages, slepen tussen stages/wachtrij, conflicten en advancing-status zien, en stages beheren.
>
> **Stack**: Vue 3 (Composition API) + Pinia + TypeScript op de Vuexy/Vuetify-template, Laravel 11 als API-backend, MySQL/Postgres, Reverb voor real-time.
>
> **Referentie-PoC**: zie `_poc/Crewli Timetable.html` (interactieve mockup met alle gewenste UX-gedrag — gebruik dit als executable spec).
---
## 1. Domain model
### 1.1 Tabellen
```text
events
id ulid PK
name string # "Echt Zomer Feesten 2026"
slug string unique
start_hour tinyint # grid start, default 14 (14:00)
total_minutes smallint # grid lengte, default 720 (12u)
stages_label string # configureerbare term, default "Stages"
created_at, updated_at, deleted_at
event_days
id ulid PK
event_id ulid FK -> events
date date
label string # "Vrijdag 12 juli"
sort smallint
unique(event_id, date)
stages
id ulid PK
event_id ulid FK -> events
name string
color string(7) # hex
capacity int # voor draw-warnings
sort smallint # globale fallback-volgorde
created_at, updated_at, deleted_at # soft delete
stage_days # welke stages zijn op welke dag actief
id ulid PK
stage_id ulid FK -> stages
day_id ulid FK -> event_days
sort smallint # volgorde van stages binnen die dag
unique(stage_id, day_id)
artists
id ulid PK
event_id ulid FK -> events
name string
initials string(4)
genre string # enum-string, freeform mag
draw int # verwachte trekkracht
created_at, updated_at, deleted_at
advance_sections # configureerbaar per event
id ulid PK
event_id ulid FK -> events
key string # 'tour' | 'hosp' | 'travel' | ...
label string
sort smallint
unique(event_id, key)
artist_advance # pivot: welke secties zijn done
artist_id ulid FK
section_id ulid FK
done_at timestamp nullable
primary key (artist_id, section_id)
performances # GEPLAND op een stage
id ulid PK
event_id ulid FK
day_id ulid FK -> event_days
stage_id ulid FK -> stages
artist_id ulid FK -> artists
start_minute smallint # offset vanaf event.start_hour*60
end_minute smallint
lane tinyint # sub-swimlane within stage row
status enum('concept','requested','option','confirmed','cancelled')
notes text nullable
version int default 0 # optimistic locking
created_at, updated_at, deleted_at
index(day_id, stage_id, start_minute)
parked_performances # WACHTRIJ — niet gepland
id ulid PK
event_id ulid FK
day_id ulid FK
artist_id ulid FK
duration_minutes smallint # voorgenomen duur
status enum(...) # zelfde set als performances
notes text nullable
origin enum('manual','unscheduled','stage_deleted')
created_at, updated_at
pending_performances # nog niet bevestigde aanvragen
id ulid PK
event_id ulid FK
day_id ulid FK
artist_id ulid FK
duration_minutes smallint
notes text nullable
created_at, updated_at
audit_log # wie deed wat wanneer
id ulid PK
user_id ulid FK -> users
event_id ulid FK
subject_type string # 'performance' | 'stage' | ...
subject_id ulid
action string # 'move' | 'create' | 'park' | ...
before json nullable
after json nullable
created_at
index(event_id, subject_type, subject_id)
```
### 1.2 Invarianten
1. Een artiest mag op één moment maar één performance hebben (cross-stage conflict). Backend valideert; frontend toont waarschuwing maar blokkeert niet.
2. Op één stage mogen meerdere performances *op verschillende lanes* tegelijk lopen. Op dezelfde lane mag geen tijds-overlap.
3. `start_minute < end_minute`, beide binnen `[0, event.total_minutes]`.
4. `stage_id` moet een actieve `stage_day` hebben voor `day_id`.
5. Soft-delete van een stage cascadet alle bijbehorende `performances` naar `parked_performances` met `origin='stage_deleted'`.
### 1.3 Status-enum
```
concept # idee, niet aangevraagd
requested # aangevraagd bij agent/artiest
option # geboekt onder optie
confirmed # bevestigd
cancelled # geannuleerd; blijft zichtbaar met diagonaal patroon
```
Status-kleuren staan vast in de frontend theme (zie §6).
---
## 2. API-contract
Alle endpoints onder `/api/v1`, JSON, Sanctum auth. Resources gebruiken `EventResource`, `PerformanceResource`, etc.
### 2.1 Read
| Method | Path | Doel |
|---|---|---|
| `GET` | `/events/{event}` | Event-meta + days + stages + advance_sections |
| `GET` | `/events/{event}/timetable?day={dayId}` | Performances + parked + pending voor één dag, in één response |
| `GET` | `/events/{event}/artists` | Volledige artiestenlijst (voor add-modal autocomplete) |
**Response shape `/timetable`**:
```json
{
"day": { "id": "...", "label": "Vrijdag 12 juli" },
"stages": [{ "id": "...", "name": "...", "color": "...", "capacity": 2400, "sort": 0 }],
"performances": [{ "id": "...", "stage_id": "...", "artist_id": "...", "start": 480, "end": 540, "lane": 0, "status": "confirmed", "version": 3 }],
"parked": [...],
"pending": [...]
}
```
### 2.2 Write — performances
| Method | Path | Body | Effect |
|---|---|---|---|
| `POST` | `/performances` | `{ day_id, stage_id?, artist_id, start?, end?, status, lane? }` | Nieuw — als `stage_id` ontbreekt → wachtrij |
| `PATCH` | `/performances/{id}` | `{ start?, end?, stage_id?, lane?, status?, version }` | Move/resize/restage. Bij `version` mismatch → 409 |
| `DELETE` | `/performances/{id}` | — | Verwijder volledig |
| `POST` | `/performances/{id}/park` | — | Verplaatst naar `parked_performances` |
| `POST` | `/parked/{id}/schedule` | `{ stage_id, start, end, lane? }` | Wachtrij → timetable, met cascade-bump als lane bezet |
| `POST` | `/pending/{id}/schedule` | `{ stage_id, start, end, lane? }` | Pending → timetable |
| `DELETE` | `/parked/{id}` of `/pending/{id}` | — | Verwijder uit wachtrij |
**Cascade-logica** (lane-conflict op drop): backend ontvangt expliciete `lane`. Als die lane qua tijd bezet is op die stage, *bumpt het bestaande block* één lane omlaag, recursief. Backend retourneert *alle* gewijzigde performances in een `cascade[]` array zodat de frontend in één state-update kan patchen.
```json
// PATCH response
{
"performance": { ...updated... },
"cascade": [{ ...other... }, ...]
}
```
### 2.3 Write — stages
| Method | Path | Body | Effect |
|---|---|---|---|
| `POST` | `/stages` | `{ event_id, name, color, capacity, day_ids[] }` | Nieuwe stage |
| `PATCH` | `/stages/{id}` | `{ name?, color?, capacity?, day_ids? }` | Update + sync `stage_days` |
| `DELETE` | `/stages/{id}` | — | Soft delete + cascade performances → parked |
| `POST` | `/stages/reorder` | `{ day_id, stage_ids[] }` | Per-dag volgorde |
### 2.4 Errors
```
400 Bad Request # validatie (start >= end, etc.)
403 Forbidden # geen permissie
404 Not Found
409 Conflict # version mismatch (optimistic lock)
422 Unprocessable # business rule (artiest dubbel geboekt)
```
---
## 3. Frontend architectuur
### 3.1 Bestandsstructuur
```
resources/js/timetable/
├─ index.ts # public exports + route
├─ types.ts # Performance, Stage, Artist, Drag, ...
├─ lib/
│ ├─ lanes.ts # assignLanes() + sub-swimlane logic
│ ├─ conflicts.ts # findConflicts, findB2B
│ ├─ time.ts # fmtTime, snap
│ ├─ advance.ts # advanceCount
│ └─ __tests__/
├─ stores/
│ └─ timetable.ts # Pinia store
├─ composables/
│ ├─ useTimetableDrag.ts
│ ├─ useTimetableScroll.ts
│ └─ useRealtime.ts
├─ components/
│ ├─ Timetable.vue # canvas + axis + rows
│ ├─ TimetableHeader.vue # day tabs, conflict counter, +Performance
│ ├─ TimetableToolbar.vue # density, snap, percent toggle, +Performance
│ ├─ Block.vue # één performance-block
│ ├─ StageRow.vue
│ ├─ Wachtrij.vue
│ ├─ Popover.vue
│ ├─ Drawer.vue
│ ├─ modals/
│ │ ├─ PerformanceModal.vue
│ │ ├─ StageEditor.vue
│ │ └─ LineupMatrix.vue
│ └─ __tests__/
├─ scss/
│ └─ timetable.scss # gebruikt Vuexy SCSS-tokens
└─ pages/
└─ EventTimetable.vue # route entrypoint
```
### 3.2 Pinia store skelet
```ts
// stores/timetable.ts
export const useTimetableStore = defineStore('timetable', () => {
const event = ref<Event | null>(null)
const activeDayId = ref<string | null>(null)
const stages = ref<Stage[]>([])
const performances = ref<Map<string, Performance>>(new Map())
const parked = ref<ParkedPerformance[]>([])
const pending = ref<PendingPerformance[]>([])
const artists = ref<Artist[]>([])
// ── Computed ─────────────────────────────
const dayStages = computed(() => /* filter by stage_days for activeDayId */)
const dayPerformances = computed(() => /* filter by day_id */)
const lanesByStage = computed(() => /* assignLanes per stage */)
// ── Actions (alle optimistic + rollback) ─
async function loadDay(eventId: string, dayId: string) { ... }
async function movePerf(id: string, patch: PerfPatch) { ... }
async function createPerf(input: NewPerf) { ... }
async function parkPerf(id: string) { ... }
async function schedulePending(pendingId: string, target: Target) { ... }
async function scheduleParked(parkedId: string, target: Target) { ... }
async function deletePerf(id: string) { ... }
async function addStage(input: NewStage) { ... }
async function updateStage(id: string, patch: StagePatch) { ... }
async function deleteStage(id: string) { ... }
async function reorderStages(dayId: string, stageIds: string[]) { ... }
// ── Realtime patches (Reverb) ────────────
function applyRemotePatch(event: BroadcastEvent) { ... }
return { /* state + computed + actions */ }
})
```
**Optimistic update pattern** (gebruik overal):
```ts
async function movePerf(id, patch) {
const before = performances.value.get(id)
performances.value.set(id, { ...before, ...patch }) // 1. local apply
try {
const { data } = await api.patch(`/performances/${id}`, { ...patch, version: before.version })
performances.value.set(id, data.performance) // 2. authoritative replace
data.cascade.forEach(p => performances.value.set(p.id, p)) // 3. apply cascade
} catch (e) {
performances.value.set(id, before) // 4. rollback
if (e.status === 409) toast.warn('Iemand anders heeft dit zojuist aangepast — ververs')
else toast.error('Opslaan mislukt')
}
}
```
### 3.3 Drag-drop composable
Port `startDragMove`, `startDragResize`, `startCreateDrag`, `startDragFromParking` van de PoC (`timetable.jsx`) **letterlijk** naar één composable. Gebruik `ref` voor `drag`-state en window-scoped `mousemove`/`mouseup` listeners. Cleanup via `onScopeDispose`.
Belangrijke regels die de PoC al heeft uitgewerkt — port verbatim:
- **Lane-snap**: `Math.floor(yInRow / LANE_STEP)` voor create, `Math.round(...)` voor move
- **Cursor-anchor cross-stage move**: `newRow` afgeleid van cursor-Y, niet block-top
- **Click-after-drag suppressie**: zet `data-just-dragged` op het block bij mouseup-na-drag, fail-fast in click handler
- **Timestamp-only conflict check**: `o.start < newEnd && o.end > newStart` — nooit pixel-Y meenemen in conflict-bepaling
### 3.4 Pure-logic ports
`lib/lanes.ts`, `lib/conflicts.ts` etc. zijn **directe TypeScript-ports** van `helpers.js`. Begin hiermee — schrijf eerst de Vitest tests en check ze tegen de PoC-output. Dit is de hoeksteen; alles wat erop bouwt is daarna voorspelbaar.
---
## 4. Realtime (Reverb + Echo)
### 4.1 Channels
```
private-event.{eventId}.timetable
```
Geautoriseerd in `routes/channels.php` via `Gate::allows('view-event-timetable', $event)`.
### 4.2 Events
```php
PerformanceCreated { performance: PerformanceResource }
PerformanceUpdated { performance: ..., cascade: [...] }
PerformanceDeleted { id: ulid }
PerformanceParked { performance: ..., parked: ParkedResource }
ParkedScheduled { parked_id: ulid, performance: ... }
StageCreated/Updated/Deleted/Reordered
```
Elke event-class heeft `socket()` returning `request()->header('X-Socket-Id')` — clients filteren hun eigen broadcasts uit, zo voorkom je dubbele optimistic updates.
### 4.3 Frontend wiring
```ts
// composables/useRealtime.ts
export function useRealtime(eventId: string) {
const store = useTimetableStore()
const channel = window.Echo.private(`event.${eventId}.timetable`)
channel.listen('PerformanceUpdated', (e) => store.applyRemotePatch(e))
// ...
onScopeDispose(() => channel.stopListening())
}
```
---
## 5. Permissies (CASL)
Definieer in `resources/js/abilities.ts`:
```ts
can('view', 'Timetable', { event_id: ... })
can('edit', 'Performance', { event_id: ... })
can('manage', 'Stage', { event_id: ... })
can('book', 'Performance', { status: ['concept','requested','option'] })
```
UI: knoppen/handles disablen via `v-if="$can('edit', 'Performance')"`. Backend valideert *altijd* opnieuw via `PerformancePolicy`.
---
## 6. Styling
### 6.1 Tokens (Vuexy)
```scss
// scss/timetable.scss — gebruikt Vuexy core variables
@use '@core/scss/base/variables' as *;
@use '@core/scss/base/mixins' as *;
:root {
// Status-kleuren — toevoegen aan Vuetify theme onder customProperties:
--tt-status-concept-bg: #f4f5f8;
--tt-status-concept-bd: #c8ccd6;
--tt-status-requested-bg: #fff5e6;
--tt-status-requested-bd: #e89a3c;
--tt-status-option-bg: #e9efff;
--tt-status-option-bd: #5a8fcf;
--tt-status-confirmed-bg: #e8f7ee;
--tt-status-confirmed-bd: #3cc28a;
--tt-status-cancelled-bd: #d63d4b;
}
.cw-block {
border-radius: $border-radius-sm;
background: var(--v-theme-surface);
// ...
}
```
Voor dark-mode: definieer alle `--tt-status-*-*` ook in `[data-theme="dark"]`.
### 6.2 Vuetify-componenten waar nuttig
Header tabs → `<VTabs>`. Modals → `<VDialog>`. Density-toggle → `<VBtnToggle>`. Drawer → `<VNavigationDrawer location="right">`. Toasts → `<VSnackbar>` of `vue-toastification`.
**Niet** Vuetify gebruiken voor: het canvas, de blocks, de wachtrij-cards, de popover. Die zijn custom — past geen library-component bij.
---
## 7. Migratie-volgorde
Bouw in deze volgorde, één PR per stap:
1. **Migrations + seeders** met testdata (kopieer `data.js` uit PoC als seeder-script)
2. **Models + Resources + Policies + tests**
3. **API endpoints + Feature tests** (volledige roundtrip per endpoint)
4. **`lib/*.ts` ports + Vitest** — alleen pure logic
5. **Pinia store + mock-API tests**
6. **`Timetable.vue` + `Block.vue` + `StageRow.vue`** — read-only render
7. **Drag composable + drop wiring** (move, resize, restage)
8. **Wachtrij + create-drag + parked-drop**
9. **Modals (PerformanceModal, StageEditor, LineupMatrix)**
10. **Popover + Drawer**
11. **Realtime (Reverb)**
12. **Audit log + permissions**
13. **Print-view + lock-feature** (later)
Elke stap heeft een **definition of done**: PR groen (tests + Pint + ESLint), screenshot in PR-beschrijving, één code-review.
---
## 8. Testing strategy
| Laag | Tool | Coverage-doel |
|---|---|---|
| Pure logic (`lib/`) | Vitest | 100% — edge cases uit PoC |
| Pinia store | Vitest + mock fetch | Alle actions, optimistic + rollback |
| Components | Vitest + @vue/test-utils | Smoke + interaction op kritieke flows |
| API | Pest | Per endpoint: happy + 4 edge cases + permissie |
| E2E | Playwright | 3 critical flows: drag-drop, stage-delete-cascade, realtime-sync |
---
## 9. Open vragen (beslis vóór de eerste sprint)
1. **Multi-event per organisatie**: één gebruiker kan meerdere events tegelijk plannen → `event_id` overal scope, of subdomain per event?
2. **Wachtrij per dag of per event**: PoC toont wachtrij gefilterd op actieve dag. Confirm: dat is de spec.
3. **Lane storage**: bewaar je `lane` op de DB of bereken je het altijd opnieuw bij elk render? PoC bewaart het — dat is mijn aanbeveling (anders kan een gebruiker geen explicit lane-keuze maken).
4. **Conflict policy**: blokkeren of waarschuwen? PoC waarschuwt + toont rode rand. Confirm.
5. **Print-formaat**: A3 landscape per dag? Of één file met alle dagen?
6. **Audit retention**: hoe lang bewaren? GDPR-relevant.
---
## 10. Bronbestanden
PoC ligt in `_poc/Crewli Timetable.html` (zelf-bevattend). De relevante files:
- `app.jsx` — root, Pinia-equivalent state
- `timetable.jsx` — grid, blocks, drag, create-drag, ghost
- `popover.jsx` — popover + drawer + wachtrij
- `modals.jsx` — PerformanceModal, StageEditor, LineupMatrix
- `helpers.js` — pure logic (port als eerste!)
- `data.js` — demo data (seeder-template)
- `styles.css` — visuele referentie voor de SCSS-port
---
**Eind van spec — klaar om aan Claude Code te voeden, één hoofdstuk tegelijk.**

View File

@@ -0,0 +1,144 @@
// Shared helpers + status palette — matches Crewli light-mode look
window.CrewliHelpers = (function () {
// Status palette tuned to Crewli's light surface + teal accent.
// Each status: pill bg/fg for popover & inline switches; block bg/border for timetable rows.
const STATUS = {
concept: {
label: "Concept",
pillBg: "#eceae6", pillFg: "#5a574e",
blockBg: "#f1efe9", blockBorder: "#dcd9d1", blockFg: "#3a3830",
dot: "#a09c92",
},
requested: {
label: "Aangevraagd",
pillBg: "#fdf2dc", pillFg: "#8a6a1d",
blockBg: "#fff6e0", blockBorder: "#f0d99a", blockFg: "#5d4612",
dot: "#d9a93c",
},
option: {
label: "Optie",
pillBg: "#ece6f6", pillFg: "#5d4a8a",
blockBg: "#f3eefa", blockBorder: "#d9c9ed", blockFg: "#3f2f6a",
dot: "#9a82c7",
},
confirmed: {
label: "Bevestigd",
pillBg: "#dff5ec", pillFg: "#1f7a5e",
blockBg: "#e8f8f0", blockBorder: "#a9e0c8", blockFg: "#125541",
dot: "#3cc2a8",
},
contracted: {
label: "Getekend",
pillBg: "#dcecfa", pillFg: "#1f5a8a",
blockBg: "#e6f1fb", blockBorder: "#a8cfee", blockFg: "#143b5d",
dot: "#3a8acc",
},
cancelled: {
label: "Geannuleerd",
pillBg: "#f0eeea", pillFg: "#8a8780",
blockBg: "#f5f3ef", blockBorder: "#dedbd3", blockFg: "#7a7770",
dot: "#a8a59d",
},
};
// minute-of-grid -> "HH:MM" (handles past-midnight rollover)
function fmtTime(minOfGrid, startHour) {
const total = startHour * 60 + minOfGrid;
const h = Math.floor(total / 60) % 24;
const m = total % 60;
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
}
function snap(min, step) { return Math.round(min / step) * step; }
function findConflicts(performances) {
const conflicts = new Set();
const byStage = {};
for (const p of performances) {
if (p.status === "cancelled") continue;
(byStage[p.stage_id] = byStage[p.stage_id] || []).push(p);
}
for (const sid in byStage) {
const list = byStage[sid].slice().sort((a, b) => a.start - b.start);
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
if (list[j].start < list[i].end) {
conflicts.add(list[i].id);
conflicts.add(list[j].id);
}
}
}
}
return conflicts;
}
function findB2B(performances, gap = 5) {
const links = [];
const byStage = {};
for (const p of performances) {
if (p.status === "cancelled") continue;
(byStage[p.stage_id] = byStage[p.stage_id] || []).push(p);
}
for (const sid in byStage) {
const list = byStage[sid].slice().sort((a, b) => a.start - b.start);
for (let i = 0; i < list.length - 1; i++) {
const gapMin = list[i + 1].start - list[i].end;
if (gapMin >= 0 && gapMin <= gap) {
links.push({ leftId: list[i].id, rightId: list[i + 1].id, gap: gapMin });
}
}
}
return links;
}
function isCapacityWarn(artist, stage) {
if (!artist || !stage) return false;
return artist.draw > stage.capacity * 1.1;
}
// Lane-pack: assign each item a lane index so overlapping items stack vertically.
// Items with explicit `lane` (integer) are honored exactly — even if they overlap
// another item on the same lane (that's a real conflict and shows visually).
// Items without `lane` get placed in the lowest lane that has no time-overlap
// with already-placed items (explicit OR previously auto-placed).
// Returns { laneOf: {id: lane}, laneCount }
function assignLanes(items) {
const sorted = items.slice().sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
const laneOf = {};
let maxLane = 0;
const overlapsAt = (it, lane) => sorted.some(other =>
other.id !== it.id &&
laneOf[other.id] === lane &&
other.start < it.end && other.end > it.start
);
// Pass 1 — items with explicit lane (sorted by lane asc): try requested lane,
// bump down on conflict to avoid overlap.
const explicit = sorted.filter(i => Number.isInteger(i.lane))
.sort((a, b) => a.lane - b.lane || a.start - b.start);
for (const it of explicit) {
let lane = Math.max(0, it.lane);
while (overlapsAt(it, lane)) lane++;
laneOf[it.id] = lane;
if (lane > maxLane) maxLane = lane;
}
// Pass 2 — items without explicit lane: lowest free lane.
for (const it of sorted) {
if (it.id in laneOf) continue;
let lane = 0;
while (overlapsAt(it, lane)) lane++;
laneOf[it.id] = lane;
if (lane > maxLane) maxLane = lane;
}
return { laneOf, laneCount: maxLane + 1 };
}
function advanceCount(artist, sections) {
let done = 0;
for (const s of sections) if (artist.advance && artist.advance[s.key]) done++;
return { done, total: sections.length };
}
return { STATUS, fmtTime, snap, findConflicts, findB2B, isCapacityWarn, advanceCount, assignLanes };
})();

View File

@@ -0,0 +1,379 @@
// Modals: AddPerformance, EditPerformance, StageEditor, LineupMatrix
// Each follows the same Crewli light-mode chrome.
const M = (function () {
const ESC_KEY = 27;
function Backdrop({ children, onClose }) {
React.useEffect(() => {
const h = (e) => { if (e.keyCode === ESC_KEY) onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, []);
return (
<div className="cw-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
{children}
</div>
);
}
function Field({ label, hint, error, children }) {
return (
<label className={"cw-field" + (error ? " cw-field-error" : "")}>
<span className="cw-field-lbl">{label}</span>
{children}
{error
? <span className="cw-field-err" role="alert">
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<circle cx="6" cy="6" r="5.4" fill="#d63d4b"/>
<rect x="5.4" y="3" width="1.2" height="3.6" fill="white"/>
<rect x="5.4" y="7.4" width="1.2" height="1.2" fill="white"/>
</svg>
{error}
</span>
: hint && <span className="cw-field-hint">{hint}</span>}
</label>
);
}
// ─── Add / Edit Performance ─────────────────────────────────────────
// "Add" creates a brand-new act (artist + performance) on the fly.
// Fields: Naam Act, Genre, Status. Stage/tijd komen mee als de popup
// geopend is via een lege cel in de timetable — starttijd is dan
// alleen-lezen, eindtijd = start + 1u.
function PerformanceModal({ mode, perf, day, stages, artists, onSave, onClose, onDelete }) {
const D = window.CrewliData;
const H = window.CrewliHelpers;
const fromTimetable = mode === "add" && perf?.stage_id != null && perf?.start != null;
const start = fromTimetable ? perf.start : null;
const end = fromTimetable ? perf.start + 60 : null;
// Edit mode preloads the existing artist; add mode is a blank act form.
const existingArtist = mode === "edit" ? artists.find(a => a.id === perf?.artist_id) : null;
const [name, setName] = React.useState(existingArtist?.name || "");
const [genre, setGenre] = React.useState(existingArtist?.genre || D.GENRES[0]);
const [status, setStatus] = React.useState(perf?.status || (fromTimetable ? "concept" : "requested"));
// Duur (alleen relevant bij + Performance vanuit header — wachtrij heeft geen tijd)
const [duration, setDuration] = React.useState(60);
const stage = fromTimetable ? stages.find(s => s.id === perf.stage_id) : null;
const startStr = start != null ? H.fmtTime(start, D.TIME.startHour) : null;
const endStr = end != null ? H.fmtTime(end, D.TIME.startHour) : null;
const [touched, setTouched] = React.useState(false);
const trimmed = name.trim();
const nameError = trimmed.length === 0 ? "Naam Act is verplicht"
: trimmed.length > 60 ? "Maximaal 60 tekens"
: null;
const canSave = !nameError;
function save() {
setTouched(true);
if (!canSave) return;
if (mode === "edit") {
// Edit only updates status here (rename/genre niet binnen scope).
onSave({ artistUpdate: null, perf: { ...perf, status } });
return;
}
// ── Add: create artist + performance ──
// Initials: keep only Latin letters/digits to avoid weird glyphs in the avatar.
const safeChars = trimmed.replace(/[^A-Za-z0-9 ]/g, "").trim();
const parts = safeChars.split(/\s+/).filter(Boolean);
let initials = parts.length >= 2
? (parts[0][0] + parts[1][0])
: (parts[0] ? parts[0].slice(0, 2) : "");
initials = initials.toUpperCase() || "??";
const newArtist = {
id: "a_" + Math.random().toString(36).slice(2, 8),
name: name.trim(),
initials,
genre,
draw: 0,
advance: { tour: false, hosp: false, travel: false, flight: false, rider: false },
};
const basePerf = {
id: "p_" + Math.random().toString(36).slice(2, 8),
artist_id: newArtist.id,
day_id: day.id,
status,
};
if (fromTimetable) {
const lanePart = Number.isInteger(perf.lane) ? { lane: perf.lane } : {};
onSave({ newArtist, perf: { ...basePerf, stage_id: perf.stage_id, start, end, ...lanePart } });
} else {
// Geen timetable-context → wachtrij. Bewaar duur zodat block bij drop de juiste breedte krijgt.
onSave({ newArtist, perf: { ...basePerf, stage_id: null, dur: duration } });
}
}
return (
<Backdrop onClose={onClose}>
<div className="cw-modal" style={{ width: 480 }} onMouseDown={(e) => e.stopPropagation()}>
<div className="cw-modal-hd">
<div className="cw-modal-title">
{mode === "add" ? "Performance toevoegen" : "Performance bewerken"}
<span className="cw-modal-sub">{day.label} · {D.EVENT.name}</span>
</div>
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
</div>
<div className="cw-modal-body">
<Field label="Naam Act" error={touched && nameError ? nameError : null}>
<input className={"cw-input" + (touched && nameError ? " cw-input-error" : "")}
type="text" autoFocus
placeholder="bijv. Mau P"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => setTouched(true)}
disabled={mode === "edit"}
maxLength={80}
aria-invalid={touched && !!nameError}
aria-describedby={touched && nameError ? "cw-name-err" : undefined} />
</Field>
<div className="cw-row-2">
<Field label="Genre">
<select className="cw-input" value={genre}
onChange={(e) => setGenre(e.target.value)}
disabled={mode === "edit"}>
{D.GENRES.map(g => <option key={g} value={g}>{g}</option>)}
</select>
</Field>
<Field label="Status">
<select className="cw-input" value={status}
onChange={(e) => setStatus(e.target.value)}>
{Object.keys(H.STATUS).map(k =>
<option key={k} value={k}>{H.STATUS[k].label}</option>)}
</select>
</Field>
</div>
{fromTimetable && stage && (
<div className="cw-mctx">
<div className="cw-mctx-stage">
<span className="cw-mctx-stage-bar" style={{ background: stage.color }}></span>
<div className="cw-mctx-stage-body">
<span className="cw-mctx-eyebrow">Stage</span>
<span className="cw-mctx-stage-name">{stage.name}</span>
</div>
<span className="cw-mctx-readonly" title="Sleep het block in de timetable om te wijzigen">vast</span>
</div>
<div className="cw-mctx-time">
<div className="cw-mctx-time-col">
<span className="cw-mctx-eyebrow">Starttijd</span>
<span className="cw-mctx-time-val">{startStr}</span>
</div>
<span className="cw-mctx-arrow" aria-hidden="true"></span>
<div className="cw-mctx-time-col">
<span className="cw-mctx-eyebrow">Eindtijd</span>
<span className="cw-mctx-time-val cw-mctx-time-soft">{endStr}</span>
</div>
<div className="cw-mctx-time-spacer"></div>
<span className="cw-mctx-dur">60 min</span>
</div>
</div>
)}
{!fromTimetable && mode === "add" && (
<>
<Field label="Duur optreden" hint="Wordt gebruikt zodra je het block uit de wachtrij naar de timetable sleept">
<select className="cw-input" value={duration}
onChange={(e) => setDuration(+e.target.value)}>
<option value={30}>30 min</option>
<option value={45}>45 min</option>
<option value={60}>60 min (1 uur)</option>
<option value={75}>75 min</option>
<option value={90}>90 min (1u30)</option>
<option value={120}>120 min (2 uur)</option>
<option value={150}>150 min (2u30)</option>
<option value={180}>180 min (3 uur)</option>
</select>
</Field>
<div className="cw-modal-hint">
Geen tijdslot gekozen landt in de <b>wachtrij</b>.
</div>
</>
)}
</div>
<div className="cw-modal-ft">
{mode === "edit" && (
<button className="cw-btn cw-btn-danger" onClick={() => onDelete(perf.id)}>Verwijderen</button>
)}
<div style={{ flex: 1 }}></div>
<button className="cw-btn" onClick={onClose}>Annuleren</button>
<button className="cw-btn cw-btn-primary" onClick={save} disabled={!canSave}>
{mode === "add" ? "Toevoegen" : "Opslaan"}
</button>
</div>
</div>
</Backdrop>
);
}
// ─── Stage editor ────────────────────────────────────────────────────
function StageEditor({ stage, days, stageDays, onSave, onClose, onDelete, mode, defaultDayIds }) {
const isCreate = mode === "create";
const [name, setName] = React.useState(stage.name);
const [color, setColor] = React.useState(stage.color);
const [capacity, setCapacity] = React.useState(stage.capacity);
const [activeDays, setActiveDays] = React.useState(
isCreate
? (defaultDayIds || [])
: days.filter(d => stageDays.some(sd => sd.stage_id === stage.id && sd.day_id === d.id)).map(d => d.id)
);
const SWATCHES = ["#e85d75", "#7a8af0", "#f0a04b", "#5fc9a8", "#c89af0", "#e8d05f", "#3cc2a8", "#5a8fcf", "#cf5a8f", "#8fcf5a"];
function toggleDay(id) {
setActiveDays(activeDays.includes(id)
? activeDays.filter(x => x !== id)
: [...activeDays, id]);
}
function save() {
onSave({ ...stage, name, color, capacity: +capacity }, activeDays);
}
return (
<Backdrop onClose={onClose}>
<div className="cw-modal" style={{ width: 480 }} onMouseDown={(e) => e.stopPropagation()}>
<div className="cw-modal-hd">
<div className="cw-modal-title">
{isCreate ? "Stage aanmaken" : "Stage bewerken"}
<span className="cw-modal-sub">{isCreate ? "Nieuwe stage" : stage.name}</span>
</div>
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
</div>
<div className="cw-modal-body">
<Field label="Naam">
<input className="cw-input" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Capaciteit" hint="Voor capacity-warnings op artiesten met grote draw">
<input className="cw-input" type="number" value={capacity} onChange={(e) => setCapacity(e.target.value)} />
</Field>
<Field label="Kleur">
<div className="cw-swatch-row">
{SWATCHES.map(c => (
<button key={c}
className={"cw-swatch" + (c === color ? " is-active" : "")}
style={{ background: c }}
onClick={() => setColor(c)} />
))}
</div>
</Field>
<Field label="Actief op dagen" hint="Deze stage verschijnt alleen in de timetable van geselecteerde dagen">
<div className="cw-day-chips">
{days.map(d => (
<button key={d.id}
className={"cw-chip" + (activeDays.includes(d.id) ? " is-active" : "")}
onClick={() => toggleDay(d.id)}>
{d.label}
</button>
))}
</div>
</Field>
</div>
<div className="cw-modal-ft">
{!isCreate && (
<button className="cw-btn cw-btn-danger" onClick={() => onDelete(stage.id)}>Verwijder stage</button>
)}
<div style={{ flex: 1 }}></div>
<button className="cw-btn" onClick={onClose}>Annuleren</button>
<button className="cw-btn cw-btn-primary" onClick={save}>{isCreate ? "Aanmaken" : "Opslaan"}</button>
</div>
</div>
</Backdrop>
);
}
// ─── Lineup matrix: stages × days bulk editor ────────────────────────
function LineupMatrix({ stages, days, stageDays, onSave, onClose }) {
// Build state: { stageId: Set<dayId> }
const [matrix, setMatrix] = React.useState(() => {
const m = {};
stages.forEach(s => {
m[s.id] = new Set(stageDays.filter(sd => sd.stage_id === s.id).map(sd => sd.day_id));
});
return m;
});
function toggle(sid, did) {
setMatrix(prev => {
const next = { ...prev };
const s = new Set(next[sid]);
s.has(did) ? s.delete(did) : s.add(did);
next[sid] = s;
return next;
});
}
function save() {
const out = [];
for (const sid in matrix) {
for (const did of matrix[sid]) out.push({ stage_id: sid, day_id: did });
}
onSave(out);
}
return (
<Backdrop onClose={onClose}>
<div className="cw-modal" style={{ width: 640 }} onMouseDown={(e) => e.stopPropagation()}>
<div className="cw-modal-hd">
<div className="cw-modal-title">
Lineup per dag
<span className="cw-modal-sub">Welke stages draaien op welke dag</span>
</div>
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
</div>
<div className="cw-modal-body">
<table className="cw-matrix">
<thead>
<tr>
<th>Stage</th>
{days.map(d => <th key={d.id}>{d.label}</th>)}
</tr>
</thead>
<tbody>
{stages.map(s => (
<tr key={s.id}>
<td>
<span className="cw-stage-dot" style={{ background: s.color }}></span>
{s.name}
</td>
{days.map(d => (
<td key={d.id} className="cw-matrix-cell">
<button
className={"cw-check" + (matrix[s.id].has(d.id) ? " is-checked" : "")}
onClick={() => toggle(s.id, d.id)}
aria-label={`Toggle ${s.name} on ${d.label}`}>
{matrix[s.id].has(d.id) && (
<svg width="12" height="12" viewBox="0 0 12 12">
<path d="M2 6 L5 9 L10 3" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
<p className="cw-hint">
Tip: stages die op meerdere dagen draaien (zoals Silent Disco of Schirmbar)
kun je hier in één keer instellen.
</p>
</div>
<div className="cw-modal-ft">
<div style={{ flex: 1 }}></div>
<button className="cw-btn" onClick={onClose}>Annuleren</button>
<button className="cw-btn cw-btn-primary" onClick={save}>Opslaan</button>
</div>
</div>
</Backdrop>
);
}
return { PerformanceModal, StageEditor, LineupMatrix };
})();
Object.assign(window, { CrewliModals: M });

View File

@@ -0,0 +1,264 @@
// Unified popover for both timetable performances and queue items.
// Design language is the same; the body just adapts to whether a time/stage exists.
//
// - Header: avatar + name + meta line (genre · stage OR "In wachtrij" · genre)
// - Time: tijdvak (or "Geen tijdslot")
// - Status: dropdown (workflow-ready — disabled options can be added later)
// - Advancing: progress bar + checklist
// - Footer: Verwijderen (timetable only) + full-width "Open detailpagina"
//
// Off-screen safe: positioning prefers below, flips above when needed,
// clamps to viewport with margin.
const PV = (function () {
const POP_W = 340;
const POP_H = 460;
const MARGIN = 12;
function pickPos(anchorRect, preferSide) {
// Horizontal: preferred side first, then opposite, then clamp.
const right = anchorRect.right + MARGIN;
const left = anchorRect.left - POP_W - MARGIN;
let l, side;
const fitsRight = right + POP_W < window.innerWidth - MARGIN;
const fitsLeft = left > MARGIN;
if (preferSide === "left") {
if (fitsLeft) { l = left; side = "left"; }
else if (fitsRight) { l = right; side = "right"; }
else { l = Math.max(MARGIN, anchorRect.left); side = "right"; }
} else {
if (fitsRight) { l = right; side = "right"; }
else if (fitsLeft) { l = left; side = "left"; }
else { l = Math.max(MARGIN, anchorRect.left); side = "right"; }
}
// Vertical: anchor top-aligned. If overflows bottom, push up. Never go above MARGIN.
let t = anchorRect.top - 8;
if (t + POP_H > window.innerHeight - MARGIN) {
t = window.innerHeight - POP_H - MARGIN;
}
if (t < MARGIN) t = MARGIN;
return { left: l, top: t, side };
}
// ─── Status dropdown (workflow-ready, supports disabled) ───────────
function StatusDropdown({ value, onChange, disabledKeys }) {
const STATUS = window.CrewliHelpers.STATUS;
const ORDER = ["concept", "requested", "option", "confirmed", "contracted", "cancelled"];
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [open]);
const cur = STATUS[value];
const dis = disabledKeys || new Set();
return (
<div className="cw-pop-dd" ref={ref}>
<button type="button" className="cw-pop-dd-btn"
onClick={() => setOpen(o => !o)}
style={{ background: cur.pillBg, color: cur.pillFg, borderColor: cur.dot + "80" }}>
<span className="cw-status-dot" style={{ background: cur.dot }}></span>
<span className="cw-pop-dd-lbl">{cur.label}</span>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.4" style={{ opacity: .7, marginLeft: 4 }}>
<path d="M2.5 4 L5 6.5 L7.5 4"/>
</svg>
</button>
{open && (
<div className="cw-pop-dd-menu" role="listbox">
{ORDER.map(k => {
const s = STATUS[k];
const isCur = k === value;
const isDisabled = dis.has(k);
return (
<button key={k} type="button"
className={"cw-pop-dd-item" + (isCur ? " is-current" : "") + (isDisabled ? " is-disabled" : "")}
disabled={isDisabled}
onClick={() => { if (!isDisabled) { onChange(k); setOpen(false); } }}>
<span className="cw-status-dot" style={{ background: s.dot, opacity: isDisabled ? .35 : 1 }}></span>
<span className="cw-pop-dd-item-lbl">{s.label}</span>
{isCur && (
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: "auto" }}>
<path d="M2.5 5.5 L4.5 7.5 L8.5 3.5"/>
</svg>
)}
</button>
);
})}
</div>
)}
</div>
);
}
// ─── Shared body (time + status + advancing) ─────────────────────
function PopoverBody({ status, time, dur, advDone, advTotal, sections, artist, statusKey, onChangeStatus }) {
const advPct = Math.round(advDone / advTotal * 100);
return (
<>
<div className="cw-pop-row">
<div className="cw-pop-time">
<div className={"cw-pop-time-main" + (time ? "" : " cw-pop-time-empty")}>
{time || (dur != null ? `${Math.floor(dur/60)}u${dur%60 ? ` ${dur%60}m` : ""}` : "Geen tijdslot")}
</div>
<div className="cw-pop-time-sub">
{time
? `${Math.floor(dur/60)}u${dur%60 ? ` ${dur%60}m` : ""}`
: (dur != null ? "Verwachte duur" : "Sleep naar timetable om te plannen")}
</div>
</div>
</div>
<div className="cw-pop-section-lbl">Status</div>
<StatusDropdown value={statusKey} onChange={onChangeStatus} />
<div className="cw-pop-section-lbl">
Advancing
<span className="cw-pop-section-meta">{advPct}% · {advDone}/{advTotal}</span>
</div>
<div className="cw-progress" style={{ marginBottom: 8 }}>
<div className="cw-progress-fill" style={{ width: advPct + "%" }}></div>
</div>
<div className="cw-adv-list">
{sections.map(s => {
const done = !!(artist.advance && artist.advance[s.key]);
return (
<div key={s.key} className={"cw-adv-row" + (done ? " is-done" : "")}>
<span className="cw-adv-tick">
{done ? (
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5 L4 7 L8 3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
) : null}
</span>
<span className="cw-adv-label">{s.label}</span>
</div>
);
})}
</div>
</>
);
}
// ─── Performance popover (timetable block) ─────────────────────────
function Popover({ perf, artist, stage, anchorRect, sections, onChangeStatus, onOpenDetailPage, onClose }) {
const ref = React.useRef(null);
const [pos, setPos] = React.useState(() => pickPos(anchorRect, "right"));
React.useEffect(() => {
const update = () => setPos(pickPos(anchorRect, "right"));
update();
window.addEventListener("resize", update);
window.addEventListener("scroll", update, true);
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
}, [anchorRect]);
React.useEffect(() => {
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, []);
const adv = window.CrewliHelpers.advanceCount(artist, sections);
const time = window.CrewliHelpers.fmtTime(perf.start, window.CrewliData.TIME.startHour) +
" " + window.CrewliHelpers.fmtTime(perf.end, window.CrewliData.TIME.startHour);
const dur = perf.end - perf.start;
return (
<div ref={ref} className="cw-popover" style={{ left: pos.left, top: pos.top }}>
<div className="cw-pop-arrow" data-side={pos.side}></div>
<div className="cw-pop-hd">
<div className="cw-pop-avatar" style={{ background: stage.color + "1f", color: "#1a2530", border: "1px solid " + stage.color + "55" }}>
{artist.initials}
</div>
<div className="cw-pop-titles">
<div className="cw-pop-name">{artist.name}</div>
<div className="cw-pop-meta">
<span className="cw-pop-meta-genre">{artist.genre}</span>
<span className="cw-pop-meta-sep">·</span>
<span className="cw-stage-dot" style={{ background: stage.color }}></span>
{stage.name}
</div>
</div>
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
</div>
<PopoverBody
time={time} dur={dur}
advDone={adv.done} advTotal={adv.total}
sections={sections} artist={artist}
statusKey={perf.status} onChangeStatus={onChangeStatus}
/>
<div className="cw-pop-ft cw-pop-ft-stack">
<button className="cw-btn cw-btn-primary cw-btn-block"
onClick={() => onOpenDetailPage(artist, perf)}>
Open detailpagina
</button>
</div>
</div>
);
}
// ─── Queue popover (parking column) ─────────────────────────────────
function QueuePopover({ item, artist, anchorRect, sections, onChangeStatus, onOpenDetailPage, onClose }) {
const ref = React.useRef(null);
const [pos, setPos] = React.useState(() => pickPos(anchorRect, "left"));
React.useEffect(() => {
const update = () => setPos(pickPos(anchorRect, "left"));
update();
window.addEventListener("resize", update);
window.addEventListener("scroll", update, true);
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
}, [anchorRect]);
React.useEffect(() => {
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, []);
const adv = window.CrewliHelpers.advanceCount(artist, sections);
const dur = item.dur;
return (
<div ref={ref} className="cw-popover cw-popover-queue" style={{ left: pos.left, top: pos.top }}>
<div className="cw-pop-arrow" data-side={pos.side}></div>
<div className="cw-pop-hd">
<div className="cw-pop-avatar" style={{ background: "#e6efe9", color: "#1a2530", border: "1px solid var(--border)" }}>
{artist.initials}
</div>
<div className="cw-pop-titles">
<div className="cw-pop-name">{artist.name}</div>
<div className="cw-pop-meta">
<span className="cw-pop-meta-genre">{artist.genre}</span>
<span className="cw-pop-meta-sep">·</span>
<span className="cw-pop-queue-tag">In wachtrij</span>
</div>
</div>
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
</div>
<PopoverBody
time={null} dur={dur}
advDone={adv.done} advTotal={adv.total}
sections={sections} artist={artist}
statusKey={item.status} onChangeStatus={onChangeStatus}
/>
<div className="cw-pop-ft cw-pop-ft-stack">
<button className="cw-btn cw-btn-primary cw-btn-block"
onClick={() => onOpenDetailPage(artist)}>
Open detailpagina
</button>
</div>
</div>
);
}
return { Popover, QueuePopover };
})();
Object.assign(window, { CrewliPopover: PV });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,568 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
// toggle drives the deck's per-viewer _railVisible via window message;
// state is mirrored from the same localStorage key the deck reads so
// the control reflects reality across reloads. The mechanism is the
// message — authors who want custom placement can post it directly
// and pass noDeckControls to suppress this one.
const hasDeckStage = React.useMemo(
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
[],
);
// Hide the toggle until the host has actually enabled the rail (the
// __omelette_rail_enabled window message, posted only when the
// omelette_deck_rail_enabled flag is on for this user). The initial read
// covers TweaksPanel mounting after the message already arrived; the
// listener covers the common case of mounting first.
const [railEnabled, setRailEnabled] = React.useState(
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
);
React.useEffect(() => {
if (!hasDeckStage || railEnabled) return undefined;
const onMsg = (e) => {
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [hasDeckStage, railEnabled]);
const [railVisible, setRailVisible] = React.useState(() => {
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
});
const toggleRail = (on) => {
setRailVisible(on);
window.postMessage({ type: '__deck_rail_visible', on }, '*');
};
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
{hasDeckStage && railEnabled && !noDeckControls && (
<TweakSection label="Deck">
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
</TweakSection>
)}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB