///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
const columnOrder = [
"backlog",
"inProgress",
"review",
"done",
] as const;
type ColumnKey = (typeof columnOrder)[number];
interface KanbanTaskInput {
id?: string;
title?: string;
column?: string;
points?: number;
}
interface KanbanTask extends KanbanTaskInput {
id: string;
title: string;
column: ColumnKey;
points: number;
}
interface KanbanBoardArgs {
tasks: Default;
wipLimits: Default, typeof defaultWipLimits>;
}
interface MoveTaskEvent {
id?: string;
taskId?: string;
to?: string;
column?: string;
}
interface LimitUpdateEvent {
column?: string;
limit?: number;
value?: number;
}
interface ColumnSummary {
key: ColumnKey;
title: string;
limit: number;
count: number;
overloaded: boolean;
items: KanbanTask[];
}
interface MoveRecord {
taskId: string;
from: ColumnKey;
to: ColumnKey;
}
const defaultTasks: KanbanTask[] = [
{
id: "task-plan-roadmap",
title: "Plan roadmap",
column: "backlog",
points: 3,
},
{
id: "task-sketch-wireframes",
title: "Sketch wireframes",
column: "inProgress",
points: 5,
},
{
id: "task-review-copy",
title: "Review copy",
column: "review",
points: 2,
},
{
id: "task-release-update",
title: "Release update",
column: "done",
points: 1,
},
{
id: "task-write-tests",
title: "Write tests",
column: "inProgress",
points: 3,
},
];
const defaultWipLimits: Record = {
backlog: 4,
inProgress: 2,
review: 2,
done: 6,
};
const columnTitles: Record = {
backlog: "Backlog",
inProgress: "In Progress",
review: "Review",
done: "Done",
};
const columnSlugMap: Record = {
backlog: "backlog",
"in-progress": "inProgress",
inprogress: "inProgress",
review: "review",
done: "done",
};
const slugify = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim().toLowerCase();
if (trimmed.length === 0) return null;
const slug = trimmed.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : null;
};
const ensureUniqueId = (
desired: string | undefined,
fallback: string,
used: Set,
index: number,
): string => {
const base = slugify(desired) ?? slugify(fallback) ?? `task-${index + 1}`;
let candidate = base;
let suffix = 2;
while (used.has(candidate)) {
candidate = `${base}-${suffix}`;
suffix += 1;
}
return candidate;
};
const normalizeTitle = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim().replace(/\s+/g, " ");
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
const normalizePoints = (value: unknown, fallback: number): number => {
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : 1;
}
return fallback > 0 ? Math.trunc(fallback) : 1;
};
const resolveColumn = (value: unknown): ColumnKey | null => {
const slug = slugify(value);
if (!slug) return null;
return columnSlugMap[slug] ?? null;
};
const normalizeColumn = (
value: unknown,
fallback: ColumnKey,
): ColumnKey => resolveColumn(value) ?? fallback;
const normalizeLimit = (value: unknown, fallback: number): number => {
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = Math.trunc(value);
return normalized >= 0 ? normalized : 0;
}
return fallback >= 0 ? Math.trunc(fallback) : 0;
};
const sanitizeTaskList = (value: unknown): KanbanTask[] => {
const source = Array.isArray(value) && value.length > 0
? value
: defaultTasks;
const sanitized: KanbanTask[] = [];
const used = new Set();
for (let index = 0; index < source.length; index++) {
const raw = source[index] as KanbanTaskInput | undefined;
const fallback = defaultTasks[index % defaultTasks.length];
const id = ensureUniqueId(raw?.id, fallback.id, used, index);
used.add(id);
const title = normalizeTitle(raw?.title, fallback.title);
const column = normalizeColumn(raw?.column, fallback.column);
const points = normalizePoints(raw?.points, fallback.points);
sanitized.push({ id, title, column, points });
}
return sanitized;
};
const sanitizeWipLimits = (
value: unknown,
fallback: Record,
): Record => {
const sanitized: Record = { ...fallback };
if (!value || typeof value !== "object") {
return sanitized;
}
const raw = value as Record;
for (const column of columnOrder) {
const slugKey = column === "inProgress" ? "in-progress" : column;
const candidate = raw[column] ?? raw[slugKey];
sanitized[column] = normalizeLimit(candidate, fallback[column]);
}
return sanitized;
};
const buildColumnSummaries = (
input: {
tasks: KanbanTask[];
limits: Record;
},
): ColumnSummary[] => {
const groups: Record = {
backlog: [],
inProgress: [],
review: [],
done: [],
};
for (const task of input.tasks) {
const group = groups[task.column];
group.push(task);
}
return columnOrder.map((key) => {
const items = groups[key].map((task) => ({ ...task }));
const count = items.length;
const limit = input.limits[key];
return {
key,
title: columnTitles[key],
limit,
count,
overloaded: count > limit,
items,
};
});
};
const describeStatus = (summaries: ColumnSummary[]): string => {
const overloaded = summaries.filter((summary) => summary.overloaded);
if (overloaded.length === 0) {
return "All columns within limits";
}
const segments = overloaded.map((summary) =>
`${summary.title} ${summary.count}/${summary.limit}`
);
return `Over capacity: ${segments.join(", ")}`;
};
const moveTaskHandler = handler(
(
event: MoveTaskEvent | undefined,
context: {
tasks: Cell;
history: Cell;
},
) => {
const current = sanitizeTaskList(context.tasks.get());
const identifier = slugify(event?.id ?? event?.taskId);
if (!identifier) return;
const nextColumn = resolveColumn(event?.to ?? event?.column);
if (!nextColumn) return;
const index = current.findIndex((task) => task.id === identifier);
if (index < 0) return;
const existing = current[index];
if (existing.column === nextColumn) return;
const updated = current.slice();
updated[index] = { ...existing, column: nextColumn };
context.tasks.set(updated);
const history = context.history.get();
const entries = Array.isArray(history) ? history.slice() : [];
entries.push({
taskId: existing.id,
from: existing.column,
to: nextColumn,
});
context.history.set(entries);
},
);
const updateLimitHandler = handler(
(
event: LimitUpdateEvent | undefined,
context: { wipLimits: Cell> },
) => {
const column = resolveColumn(event?.column);
if (!column) return;
const current = sanitizeWipLimits(
context.wipLimits.get(),
defaultWipLimits,
);
const desired = event?.limit ?? event?.value;
const nextLimit = normalizeLimit(desired, current[column]);
if (nextLimit === current[column]) return;
const updated = { ...current, [column]: nextLimit };
context.wipLimits.set(updated);
},
);
export const kanbanBoardGrouping = recipe(
"Kanban Board Grouping",
({ tasks, wipLimits }) => {
const moveHistory = cell([]);
const normalizedTasks = lift(sanitizeTaskList)(tasks);
const limitView = lift(
(value: Record | undefined) =>
sanitizeWipLimits(value, defaultWipLimits),
)(wipLimits);
const limitSnapshot = lift((limits: Record) => ({
backlog: limits.backlog,
inProgress: limits.inProgress,
review: limits.review,
done: limits.done,
}))(limitView);
const columnSummaries = lift(buildColumnSummaries)({
tasks: normalizedTasks,
limits: limitView,
});
const overloadedColumns = lift((summaries: ColumnSummary[]) =>
summaries
.filter((summary) => summary.overloaded)
.map((summary) => summary.key)
)(columnSummaries);
const statusText = lift(describeStatus)(columnSummaries);
const status = str`${statusText}`;
const historyView = lift((entries: MoveRecord[] | undefined) =>
Array.isArray(entries) ? entries : []
)(moveHistory);
return {
tasks: normalizedTasks,
columns: columnSummaries,
limits: limitSnapshot,
overloadedColumns,
status,
history: historyView,
moveTask: moveTaskHandler({ tasks, history: moveHistory }),
setLimit: updateLimitHandler({ wipLimits }),
};
},
);