///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
interface BurndownEntry {
day: number;
remaining: number;
}
interface BurndownCurvePoint {
day: number;
actual: number | null;
projected: number;
ideal: number;
}
interface SprintSnapshotEvent {
day?: number;
completed?: number;
remaining?: number;
note?: string;
}
interface SprintBurndownArgs {
totalScope: Default;
sprintLength: Default;
snapshots: Default;
}
interface LogMessageInput {
day: number;
burnedToday: number;
totalBurned: number;
remaining: number;
note?: string;
}
const roundToTwo = (value: number): number => {
return Math.round(value * 100) / 100;
};
const sanitizeScope = (value: number | undefined): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return 40;
}
return Math.max(0, roundToTwo(value));
};
const sanitizeSprintLength = (value: number | undefined): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return 10;
}
const normalized = Math.max(1, Math.floor(value));
return Math.min(normalized, 35);
};
const sanitizeCompleted = (value: unknown): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, roundToTwo(value));
};
const sanitizeRemaining = (value: unknown, scope: number): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return scope;
}
const normalized = roundToTwo(value);
if (normalized <= 0) return 0;
if (normalized >= scope) return scope;
return normalized;
};
const sanitizeDay = (
value: unknown,
sprintLength: number,
): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
const normalized = Math.max(0, Math.floor(value));
if (normalized > sprintLength) {
return sprintLength;
}
return normalized;
};
const sanitizeSnapshots = (
value: unknown,
scope: number,
sprintLength: number,
): BurndownEntry[] => {
if (!Array.isArray(value)) {
return [{ day: 0, remaining: scope }];
}
const map = new Map();
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const record = entry as { day?: unknown; remaining?: unknown };
const day = sanitizeDay(record.day, sprintLength);
if (day === null) continue;
const remaining = sanitizeRemaining(record.remaining, scope);
map.set(day, { day, remaining });
}
map.set(0, { day: 0, remaining: scope });
const result = Array.from(map.values());
result.sort((left, right) => left.day - right.day);
return result;
};
const upsertSnapshot = (
history: readonly BurndownEntry[],
entry: BurndownEntry,
): BurndownEntry[] => {
const updated: BurndownEntry[] = [];
let replaced = false;
for (const item of history) {
if (item.day === entry.day) {
if (!replaced) {
updated.push(entry);
replaced = true;
}
continue;
}
updated.push(item);
}
if (!replaced) {
updated.push(entry);
}
updated.sort((left, right) => left.day - right.day);
return updated;
};
const findPreviousRemaining = (
history: readonly BurndownEntry[],
day: number,
scope: number,
): number => {
let previous = scope;
for (const entry of history) {
if (entry.day > day) break;
previous = entry.remaining;
}
return previous;
};
const buildIdealLine = (
scope: number,
sprintLength: number,
): BurndownEntry[] => {
if (sprintLength <= 0) {
return [{ day: 0, remaining: scope }];
}
const length = Math.max(1, sprintLength);
const result: BurndownEntry[] = [];
for (let day = 0; day <= length; day++) {
const ratio = day / length;
const remaining = Math.max(0, roundToTwo(scope * (1 - ratio)));
result.push({ day, remaining });
}
return result;
};
const buildBurndownCurve = (
history: readonly BurndownEntry[],
scope: number,
sprintLength: number,
): BurndownCurvePoint[] => {
const ideal = buildIdealLine(scope, sprintLength);
const idealByDay = new Map(
ideal.map((entry) => [entry.day, entry.remaining] as const),
);
const dayToRemaining = new Map();
for (const entry of history) {
dayToRemaining.set(entry.day, entry.remaining);
}
if (!dayToRemaining.has(0)) {
dayToRemaining.set(0, scope);
}
const days = Array.from(dayToRemaining.keys()).sort((a, b) => a - b);
const lastDay = days[days.length - 1] ?? 0;
const lastRemaining = dayToRemaining.get(lastDay) ?? scope;
const averageBurn = lastDay === 0
? 0
: Math.max(0, roundToTwo((scope - lastRemaining) / lastDay));
const points: BurndownCurvePoint[] = [];
let projected = scope;
for (let day = 0; day <= sprintLength; day++) {
const actual = dayToRemaining.has(day) ? dayToRemaining.get(day)! : null;
if (day === 0) {
projected = scope;
} else if (day <= lastDay) {
if (actual !== null) {
projected = actual;
} else if (averageBurn > 0) {
projected = Math.max(0, roundToTwo(projected - averageBurn));
}
} else if (averageBurn > 0) {
projected = Math.max(0, roundToTwo(projected - averageBurn));
}
const idealRemaining = idealByDay.get(day) ??
ideal[ideal.length - 1].remaining;
points.push({ day, actual, projected, ideal: idealRemaining });
}
return points;
};
const appendLogEntry = (log: Cell, entry: string) => {
const previous = log.get();
const list = Array.isArray(previous) ? previous.slice() : [];
list.push(entry);
const trimmed = list.length > 8 ? list.slice(-8) : list;
log.set(trimmed);
};
const buildLogMessage = (input: LogMessageInput): string => {
const summary =
`Day ${input.day}: burned ${input.burnedToday} (total ${input.totalBurned})`;
const remaining = ` remaining ${input.remaining}`;
if (input.note && input.note.trim().length > 0) {
return `${summary}${remaining} — ${input.note.trim()}`;
}
return `${summary}${remaining}`;
};
const logSprintProgress = handler(
(
event: SprintSnapshotEvent | undefined,
context: {
totalScope: Cell;
sprintLength: Cell;
snapshots: Cell;
log: Cell;
},
) => {
const scope = sanitizeScope(context.totalScope.get());
const length = sanitizeSprintLength(context.sprintLength.get());
const history = sanitizeSnapshots(context.snapshots.get(), scope, length);
const providedDay = sanitizeDay(event?.day, length);
const lastRecorded = history[history.length - 1] ??
{ day: 0, remaining: scope };
const candidateDay = providedDay ?? Math.min(lastRecorded.day + 1, length);
const completed = sanitizeCompleted(event?.completed);
const hasRemainingField = event ? Object.hasOwn(event, "remaining") : false;
const explicitRemaining = hasRemainingField
? sanitizeRemaining(event?.remaining, scope)
: null;
const base = findPreviousRemaining(history, candidateDay, scope);
const nextRemaining = explicitRemaining ?? Math.max(
0,
Math.min(scope, roundToTwo(base - completed)),
);
const updatedHistory = upsertSnapshot(history, {
day: candidateDay,
remaining: nextRemaining,
});
context.totalScope.set(scope);
context.sprintLength.set(length);
context.snapshots.set(updatedHistory);
const burnedToday = roundToTwo(Math.max(0, base - nextRemaining));
const totalBurned = roundToTwo(Math.max(0, scope - nextRemaining));
const message = buildLogMessage({
day: candidateDay,
burnedToday,
totalBurned,
remaining: nextRemaining,
note: typeof event?.note === "string" ? event?.note : undefined,
});
appendLogEntry(context.log, message);
},
);
export const sprintBurndown = recipe(
"Sprint Burndown Tracker",
({ totalScope, sprintLength, snapshots }) => {
const activityLog = cell([]);
const scopeView = lift((value: number | undefined) => sanitizeScope(value))(
totalScope,
);
const lengthView = lift((value: number | undefined) =>
sanitizeSprintLength(value)
)(sprintLength);
const historyView = lift((input: {
list: BurndownEntry[];
scope: number;
length: number;
}) => sanitizeSnapshots(input.list, input.scope, input.length))({
list: snapshots,
scope: scopeView,
length: lengthView,
});
const lastDayView = lift((entries: BurndownEntry[]) => {
const last = entries[entries.length - 1];
return last ? last.day : 0;
})(historyView);
const remainingView = lift(
(input: { history: BurndownEntry[]; scope: number }) => {
const last = input.history[input.history.length - 1];
return last ? last.remaining : input.scope;
},
)({ history: historyView, scope: scopeView });
const burnedView = lift((input: { scope: number; remaining: number }) => {
return roundToTwo(Math.max(0, input.scope - input.remaining));
})({ scope: scopeView, remaining: remainingView });
const completionView = lift((input: { burned: number; scope: number }) => {
if (input.scope === 0) return 100;
const ratio = (input.burned / input.scope) * 100;
return Math.min(100, Math.max(0, Math.round(ratio)));
})({ burned: burnedView, scope: scopeView });
const idealLine = lift((input: { scope: number; length: number }) =>
buildIdealLine(input.scope, input.length)
)({ scope: scopeView, length: lengthView });
const burndownCurve = lift((input: {
history: BurndownEntry[];
scope: number;
length: number;
}) => buildBurndownCurve(input.history, input.scope, input.length))({
history: historyView,
scope: scopeView,
length: lengthView,
});
const activityLogView = lift((entries: string[] | undefined) =>
Array.isArray(entries) ? entries : []
)(activityLog);
const statusLabel =
str`Day ${lastDayView}/${lengthView} — burned ${burnedView} (${completionView}%)`;
return {
totalScope: scopeView,
sprintLength: lengthView,
history: historyView,
remaining: remainingView,
burned: burnedView,
completion: completionView,
idealLine,
burndownCurve,
activityLog: activityLogView,
statusLabel,
logDay: logSprintProgress({
totalScope,
sprintLength,
snapshots,
log: activityLog,
}),
};
},
);