{ticket.title}
{ticket.details || "Double-click this sticky to add more project detail."}
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 (
{ticket.details || "Double-click this sticky to add more project detail."}
{ticket.title}
{report.verdict}
Every stage change is stamped automatically when the ticket moves.
Create project tickets as sticky notes, move them across timeline stages, and let the board show which work is cooling smoothly or burning out in place.
New tickets always land in the left-most column, then the heat timer starts ticking.