const { useEffect, useRef, useState } = React; const STORAGE_KEY = "project-kitchen-board-state-v1"; const MS_PER_DAY = 24 * 60 * 60 * 1000; function uid(prefix) { return `${prefix}-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function safeNumber(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function nowIso() { return new Date().toISOString(); } function daysAgo(days) { return new Date(Date.now() - days * MS_PER_DAY).toISOString(); } function createDefaultColumns() { return [ { id: "col-intake", label: "Sticky Pot", targetDays: 2 }, { id: "col-scope", label: "Prep Counter", targetDays: 3 }, { id: "col-build", label: "Main Burner", targetDays: 4 }, { id: "col-review", label: "Taste Test", targetDays: 2 } ]; } function createDefaultRows() { return [ { id: "row-client", label: "Client Work" }, { id: "row-internal", label: "Internal Ops" }, { id: "row-field", label: "Field Projects" } ]; } function createDefaultTickets() { return [ { id: uid("ticket"), code: "PT-101", title: "Northside permit chase", owner: "Avery", rowId: "row-client", columnId: "col-scope", projectTargetDays: 12, details: "Waiting on permit comments and utility redlines before the install package can close.", createdAt: daysAgo(3.5), enteredColumnAt: daysAgo(2.2), updatedAt: daysAgo(0.6), moveHistory: [ { columnId: "col-intake", enteredAt: daysAgo(3.5), leftAt: daysAgo(2.2) }, { columnId: "col-scope", enteredAt: daysAgo(2.2), leftAt: null } ] }, { id: uid("ticket"), code: "PT-102", title: "Warehouse lighting rollout", owner: "Jordan", rowId: "row-field", columnId: "col-build", projectTargetDays: 14, details: "Materials landed, but trench coordination is dragging and the schedule is heating up.", createdAt: daysAgo(8.5), enteredColumnAt: daysAgo(4.8), updatedAt: daysAgo(0.2), moveHistory: [ { columnId: "col-intake", enteredAt: daysAgo(8.5), leftAt: daysAgo(7.9) }, { columnId: "col-scope", enteredAt: daysAgo(7.9), leftAt: daysAgo(4.8) }, { columnId: "col-build", enteredAt: daysAgo(4.8), leftAt: null } ] }, { id: uid("ticket"), code: "PT-103", title: "Reporting dashboard refresh", owner: "Casey", rowId: "row-internal", columnId: "col-review", projectTargetDays: 9, details: "The analytics refresh is moving quickly and only needs a final sign-off pass.", createdAt: daysAgo(4.3), enteredColumnAt: daysAgo(0.8), updatedAt: daysAgo(0.1), moveHistory: [ { columnId: "col-intake", enteredAt: daysAgo(4.3), leftAt: daysAgo(3.4) }, { columnId: "col-scope", enteredAt: daysAgo(3.4), leftAt: daysAgo(1.4) }, { columnId: "col-build", enteredAt: daysAgo(1.4), leftAt: daysAgo(0.8) }, { columnId: "col-review", enteredAt: daysAgo(0.8), leftAt: null } ] } ]; } function createDefaultState() { return { boardName: "Project Kitchen Board", kitchenTargetDays: 18, lastTicketNumber: 104, columns: createDefaultColumns(), rows: createDefaultRows(), tickets: createDefaultTickets() }; } function inferNextTicketNumber(tickets, fallback) { const highest = tickets.reduce((maxValue, ticket) => { const match = String(ticket && ticket.code ? ticket.code : "").match(/(\d+)(?!.*\d)/); const numeric = match ? Number(match[1]) : NaN; return Number.isFinite(numeric) ? Math.max(maxValue, numeric) : maxValue; }, 0); return Math.max(safeNumber(fallback, 0), highest + 1, 1); } function normalizeColumns(rawColumns) { if (!Array.isArray(rawColumns) || !rawColumns.length) { return createDefaultColumns(); } return rawColumns.map((column, index) => ({ id: String(column && column.id ? column.id : uid(`column-${index + 1}`)), label: String(column && column.label ? column.label : `Stage ${index + 1}`), targetDays: clamp(safeNumber(column && column.targetDays, 3), 0.5, 90) })); } function normalizeRows(rawRows) { if (!Array.isArray(rawRows) || !rawRows.length) { return createDefaultRows(); } return rawRows.map((row, index) => ({ id: String(row && row.id ? row.id : uid(`row-${index + 1}`)), label: String(row && row.label ? row.label : `Project Type ${index + 1}`) })); } function normalizeMoveHistory(rawHistory, fallbackColumnId, fallbackCreatedAt, validColumnIds) { const history = Array.isArray(rawHistory) ? rawHistory : []; const safeHistory = history .map((entry) => { const columnId = validColumnIds.has(entry && entry.columnId) ? String(entry.columnId) : fallbackColumnId; const enteredAt = String((entry && entry.enteredAt) || fallbackCreatedAt || nowIso()); const leftAt = entry && entry.leftAt ? String(entry.leftAt) : null; return { columnId, enteredAt, leftAt }; }) .filter((entry) => entry.columnId); if (!safeHistory.length) { return [{ columnId: fallbackColumnId, enteredAt: fallbackCreatedAt || nowIso(), leftAt: null }]; } safeHistory[safeHistory.length - 1] = { ...safeHistory[safeHistory.length - 1], columnId: fallbackColumnId, leftAt: null }; return safeHistory; } function normalizeTickets(rawTickets, rows, columns) { const fallbackTickets = createDefaultTickets(); const sourceTickets = Array.isArray(rawTickets) && rawTickets.length ? rawTickets : fallbackTickets; const rowIds = new Set(rows.map((row) => row.id)); const columnIds = new Set(columns.map((column) => column.id)); const firstRowId = rows[0].id; const firstColumnId = columns[0].id; return sourceTickets.map((ticket, index) => { const rowId = rowIds.has(ticket && ticket.rowId) ? String(ticket.rowId) : firstRowId; const columnId = columnIds.has(ticket && ticket.columnId) ? String(ticket.columnId) : firstColumnId; const createdAt = String((ticket && ticket.createdAt) || nowIso()); const history = normalizeMoveHistory(ticket && ticket.moveHistory, columnId, createdAt, columnIds); return { id: String(ticket && ticket.id ? ticket.id : uid(`ticket-${index + 1}`)), code: String(ticket && ticket.code ? ticket.code : `PT-${index + 1}`), title: String(ticket && ticket.title ? ticket.title : `Untitled Ticket ${index + 1}`), owner: String(ticket && ticket.owner ? ticket.owner : "Unassigned"), rowId, columnId, projectTargetDays: clamp(safeNumber(ticket && ticket.projectTargetDays, 12), 1, 365), details: String(ticket && ticket.details ? ticket.details : ""), createdAt, enteredColumnAt: String((ticket && ticket.enteredColumnAt) || history[history.length - 1].enteredAt), updatedAt: String((ticket && ticket.updatedAt) || createdAt), moveHistory: history }; }); } function normalizeState(rawState) { const fallback = createDefaultState(); const source = rawState && typeof rawState === "object" ? rawState : fallback; const columns = normalizeColumns(source.columns); const rows = normalizeRows(source.rows); const tickets = normalizeTickets(source.tickets, rows, columns); return { boardName: String(source.boardName || fallback.boardName), kitchenTargetDays: clamp(safeNumber(source.kitchenTargetDays, fallback.kitchenTargetDays), 1, 365), lastTicketNumber: inferNextTicketNumber(tickets, source.lastTicketNumber || fallback.lastTicketNumber), columns, rows, tickets }; } function loadInitialState() { try { const stored = window.localStorage.getItem(STORAGE_KEY); if (!stored) { return createDefaultState(); } return normalizeState(JSON.parse(stored)); } catch (error) { return createDefaultState(); } } function createTicketDraft(rowId, projectTargetDays) { return { title: "", owner: "", rowId, projectTargetDays: clamp(safeNumber(projectTargetDays, 12), 1, 365), details: "" }; } function downloadJson(filename, payload) { const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function readJsonFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { try { resolve(JSON.parse(String(reader.result || ""))); } catch (error) { reject(error); } }; reader.onerror = () => reject(reader.error || new Error("Could not read the selected file.")); reader.readAsText(file); }); } function formatDateTime(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) { return "Unknown"; } return date.toLocaleString([], { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } function formatDuration(ms) { if (!Number.isFinite(ms) || ms <= 0) { return "0h"; } const days = ms / MS_PER_DAY; if (days >= 1) { return days >= 10 ? `${Math.round(days)}d` : `${days.toFixed(1)}d`; } const hours = ms / (60 * 60 * 1000); return `${hours.toFixed(1)}h`; } function getHistoryDurationMs(entry, nowMs) { const start = Date.parse(entry && entry.enteredAt ? entry.enteredAt : ""); const finish = entry && entry.leftAt ? Date.parse(entry.leftAt) : nowMs; if (!Number.isFinite(start) || !Number.isFinite(finish)) { return 0; } return Math.max(0, finish - start); } function getColumnActualMs(ticket, columnId, nowMs) { return ticket.moveHistory.reduce((total, entry) => { if (entry.columnId !== columnId) { return total; } return total + getHistoryDurationMs(entry, nowMs); }, 0); } function getTargetMs(days) { return clamp(safeNumber(days, 1), 0.5, 365) * MS_PER_DAY; } function getHeatRatio(actualMs, targetDays) { const targetMs = getTargetMs(targetDays); if (!targetMs) { return 0; } return actualMs / targetMs; } function getHeatLabel(ratio) { if (ratio < 0.55) { return "Cool"; } if (ratio < 0.95) { return "Steady"; } if (ratio < 1.2) { return "Warming"; } if (ratio < 1.5) { return "Hot"; } return "Burnt"; } function getHeatColor(ratio) { const normalized = clamp(ratio / 1.6, 0, 1); const hue = 208 - normalized * 200; const saturation = 85; const lightness = 84 - normalized * 24; return `hsl(${hue}deg ${saturation}% ${lightness}%)`; } function getHeatGlow(ratio) { const normalized = clamp(ratio / 1.7, 0, 1); const hue = 210 - normalized * 205; return `hsla(${hue}deg 95% 62% / 0.34)`; } function getGradeFromRatio(ratio, hasHistory) { if (!hasHistory) { return "--"; } if (ratio <= 0.85) { return "A"; } if (ratio <= 1.05) { return "B"; } if (ratio <= 1.25) { return "C"; } if (ratio <= 1.5) { return "D"; } return "F"; } function getOverallVerdict(ratio) { if (ratio <= 0.85) { return "Cooling ahead of plan"; } if (ratio <= 1.05) { return "Properly cooked"; } if (ratio <= 1.25) { return "Needs a quick stir"; } if (ratio <= 1.5) { return "Overcooking"; } return "Burnt and blocking"; } function getTiltFromId(id) { return ((String(id || "") .split("") .reduce((total, char) => total + char.charCodeAt(0), 0) % 7) - 3) * 0.5; } function buildTicketReport(ticket, columns, nowMs) { const stageReports = columns.map((column) => { const actualMs = getColumnActualMs(ticket, column.id, nowMs); const ratio = getHeatRatio(actualMs, column.targetDays); const hasHistory = ticket.moveHistory.some((entry) => entry.columnId === column.id); return { columnId: column.id, label: column.label, targetDays: column.targetDays, actualMs, actualText: formatDuration(actualMs), ratio, heatLabel: getHeatLabel(ratio), heatColor: getHeatColor(ratio), heatGlow: getHeatGlow(ratio), grade: getGradeFromRatio(ratio, hasHistory) }; }); const totalActualMs = stageReports.reduce((total, stage) => total + stage.actualMs, 0); const totalTargetMs = getTargetMs(ticket.projectTargetDays); const overallRatio = totalTargetMs ? totalActualMs / totalTargetMs : 0; const currentStage = stageReports.find((stage) => stage.columnId === ticket.columnId) || stageReports[0]; return { currentStage, stageReports, totalActualMs, totalActualText: formatDuration(totalActualMs), totalTargetMs, totalTargetText: formatDuration(totalTargetMs), overallRatio, overallGrade: getGradeFromRatio(overallRatio, true), verdict: getOverallVerdict(overallRatio) }; } function getColumnSnapshot(tickets, column, nowMs) { const liveTickets = tickets.filter((ticket) => ticket.columnId === column.id); const hottestRatio = liveTickets.reduce((highest, ticket) => { const actualMs = getColumnActualMs(ticket, column.id, nowMs); return Math.max(highest, getHeatRatio(actualMs, column.targetDays)); }, 0); const burntCount = liveTickets.filter((ticket) => { const actualMs = getColumnActualMs(ticket, column.id, nowMs); return getHeatRatio(actualMs, column.targetDays) >= 1.5; }).length; return { liveTickets, hottestRatio, burntCount, heatLabel: liveTickets.length ? getHeatLabel(hottestRatio) : "Idle", heatColor: liveTickets.length ? getHeatColor(hottestRatio) : "hsl(200deg 22% 88%)" }; } function TicketCard({ ticket, column, columnIndex, columns, nowMs, onMoveByOffset, onOpen, onDragStart, onDragEnd }) { const report = buildTicketReport(ticket, columns, nowMs); const leftDisabled = columnIndex === 0; const rightDisabled = columnIndex === columns.length - 1; return (
onDragStart(event, ticket.id)} onDragEnd={onDragEnd} onDoubleClick={() => onOpen(ticket.id)} title="Double-click to open ticket details and history." style={{ "--note-glow": report.currentStage.heatGlow, "--note-paper": report.currentStage.heatColor, "--note-tilt": `${getTiltFromId(ticket.id)}deg` }} >
{ticket.code}

{ticket.title}

{report.currentStage.heatLabel}
{ticket.owner || "Unassigned"} Target {ticket.projectTargetDays}d

{ticket.details || "Double-click this sticky to add more project detail."}

{report.currentStage.actualText} in {column.label} {Math.round(report.currentStage.ratio * 100)}%
); } function ReportCard({ ticket, columns, nowMs, onOpen }) { const report = buildTicketReport(ticket, columns, nowMs); return ( ); } function TicketModal({ ticket, columns, rows, nowMs, onClose, onUpdateTicketField, onMoveTicketToColumn }) { if (!ticket) { return null; } const report = buildTicketReport(ticket, columns, nowMs); return (
event.stopPropagation()} role="dialog" aria-modal="true" >
{ticket.code}

{ticket.title}

{report.verdict}