feat: add task duration tracking
This commit is contained in:
+177
-8
@@ -6,12 +6,14 @@ import type {
|
||||
TaskSnapshot,
|
||||
TaskStats,
|
||||
TaskStatus,
|
||||
TaskTimingSnapshot,
|
||||
TaskToolDetails,
|
||||
} from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
|
||||
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]);
|
||||
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||
const MAX_DATE_MS = 8_640_000_000_000_000;
|
||||
|
||||
interface TaskRecord {
|
||||
id: number;
|
||||
@@ -19,6 +21,10 @@ interface TaskRecord {
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
startedAt?: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||
@@ -33,13 +39,74 @@ function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[]): Task[] {
|
||||
function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||
return records.map((task) => ({
|
||||
...task,
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
...(task.description !== undefined ? { description: task.description } : {}),
|
||||
status: task.status,
|
||||
parentId: task.parentId,
|
||||
childrenIds: childIds(records, task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function durationMs(task: TaskRecord, now: number): number {
|
||||
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||
return task.accumulatedMs + activeMs;
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||
const byId = indexRecords(records);
|
||||
return snapshotTasks(records).map((task) => {
|
||||
const record = byId.get(task.id);
|
||||
if (!record || record.startedAt === undefined) return task;
|
||||
return {
|
||||
...task,
|
||||
startedAt: new Date(record.startedAt).toISOString(),
|
||||
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
|
||||
durationMs: durationMs(record, now),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
|
||||
const timings: TaskTimingSnapshot[] = [];
|
||||
for (const task of records) {
|
||||
if (task.startedAt === undefined) continue;
|
||||
timings.push({
|
||||
taskId: task.id,
|
||||
startedAt: task.startedAt,
|
||||
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
|
||||
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
|
||||
accumulatedMs: task.accumulatedMs,
|
||||
});
|
||||
}
|
||||
return timings;
|
||||
}
|
||||
|
||||
function startTimer(task: TaskRecord, now: number): void {
|
||||
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
|
||||
task.startedAt ??= startedAt;
|
||||
task.activeStartedAt = startedAt;
|
||||
task.stoppedAt = undefined;
|
||||
}
|
||||
|
||||
function stopTimer(task: TaskRecord, now: number): void {
|
||||
if (task.activeStartedAt === undefined) return;
|
||||
const stoppedAt = Math.max(now, task.activeStartedAt);
|
||||
task.accumulatedMs += stoppedAt - task.activeStartedAt;
|
||||
task.activeStartedAt = undefined;
|
||||
task.stoppedAt = stoppedAt;
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is number {
|
||||
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
|
||||
}
|
||||
|
||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||
const result: number[] = [];
|
||||
const visit = (id: number) => {
|
||||
@@ -67,6 +134,38 @@ function validateGraph(records: TaskRecord[]): string[] {
|
||||
errors.push(`task ${task.id}: description must not be empty`);
|
||||
}
|
||||
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
||||
if (!isNonNegativeSafeInteger(task.accumulatedMs)) {
|
||||
errors.push(`task ${task.id}: accumulatedMs must be a non-negative safe integer`);
|
||||
}
|
||||
if (task.startedAt === undefined) {
|
||||
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
|
||||
errors.push(`task ${task.id}: timer fields require startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
|
||||
} else {
|
||||
if (!isValidTimestamp(task.startedAt)) {
|
||||
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
|
||||
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") {
|
||||
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
|
||||
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
|
||||
} else {
|
||||
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
|
||||
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
|
||||
}
|
||||
}
|
||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
||||
}
|
||||
@@ -153,8 +252,17 @@ export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
private currentTime(): number {
|
||||
const now = Math.floor(this.now());
|
||||
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
|
||||
return now;
|
||||
}
|
||||
|
||||
list(): Task[] {
|
||||
return publicTasks(this.records);
|
||||
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
|
||||
return publicTasks(this.records, now);
|
||||
}
|
||||
|
||||
get(ids: number[]): Task[] {
|
||||
@@ -163,7 +271,12 @@ export class TaskState {
|
||||
}
|
||||
|
||||
snapshot(): TaskSnapshot {
|
||||
return { tasks: this.list(), nextId: this.nextId };
|
||||
const timings = timingSnapshots(this.records);
|
||||
return {
|
||||
tasks: snapshotTasks(this.records),
|
||||
nextId: this.nextId,
|
||||
...(timings.length > 0 ? { timings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
stats(): TaskStats {
|
||||
@@ -183,6 +296,7 @@ export class TaskState {
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const existingIds = new Set(working.map((task) => task.id));
|
||||
const now = this.currentTime();
|
||||
let nextId = this.nextId;
|
||||
const addedIds: number[] = [];
|
||||
|
||||
@@ -212,13 +326,16 @@ export class TaskState {
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
const status = draft.status ?? "pending";
|
||||
addedIds.push(id);
|
||||
working.push({
|
||||
id,
|
||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
|
||||
status: draft.status ?? "pending",
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
|
||||
});
|
||||
|
||||
for (const [index, child] of (draft.children ?? []).entries()) {
|
||||
@@ -252,7 +369,7 @@ export class TaskState {
|
||||
});
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
|
||||
const replacement = new TaskState();
|
||||
const replacement = new TaskState(this.now);
|
||||
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
|
||||
this.restore(replacement.snapshot());
|
||||
return affected;
|
||||
@@ -268,6 +385,7 @@ export class TaskState {
|
||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const now = this.currentTime();
|
||||
for (const [index, patch] of patches.entries()) {
|
||||
const path = `tasks[${index}]`;
|
||||
const task = working.find((candidate) => candidate.id === patch.id);
|
||||
@@ -296,7 +414,11 @@ export class TaskState {
|
||||
|
||||
if (patch.name !== undefined) task.name = patch.name.trim();
|
||||
if (patch.description !== undefined) task.description = patch.description.trim();
|
||||
if (patch.status !== undefined) task.status = patch.status;
|
||||
if (patch.status !== undefined && patch.status !== task.status) {
|
||||
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
|
||||
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
|
||||
task.status = patch.status;
|
||||
}
|
||||
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
|
||||
task.parentId = patch.parentId;
|
||||
moveToSiblingEnd(working, task.id);
|
||||
@@ -325,7 +447,11 @@ export class TaskState {
|
||||
removedIds.add(id);
|
||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||
}
|
||||
const removed = this.list().filter((task) => removedIds.has(task.id));
|
||||
const now = this.currentTime();
|
||||
for (const task of this.records) {
|
||||
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
|
||||
}
|
||||
const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id));
|
||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||
return removed;
|
||||
}
|
||||
@@ -334,13 +460,56 @@ export class TaskState {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
|
||||
throw new TaskValidationError(["invalid task snapshot timings"]);
|
||||
}
|
||||
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
}));
|
||||
const byId = indexRecords(records);
|
||||
const timingErrors: string[] = [];
|
||||
const timedIds = new Set<number>();
|
||||
|
||||
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
|
||||
const path = `timings[${index}]`;
|
||||
if (!timing || typeof timing !== "object") {
|
||||
timingErrors.push(`${path} must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive integer`);
|
||||
continue;
|
||||
}
|
||||
if (timedIds.has(timing.taskId)) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
|
||||
continue;
|
||||
}
|
||||
timedIds.add(timing.taskId);
|
||||
const task = byId.get(timing.taskId);
|
||||
if (!task) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
|
||||
continue;
|
||||
}
|
||||
task.startedAt = timing.startedAt;
|
||||
task.activeStartedAt = timing.activeStartedAt;
|
||||
task.stoppedAt = timing.stoppedAt;
|
||||
task.accumulatedMs = timing.accumulatedMs;
|
||||
}
|
||||
|
||||
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
|
||||
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
|
||||
const now = this.currentTime();
|
||||
for (const task of records) {
|
||||
if (task.status === "in-progress") startTimer(task, now);
|
||||
}
|
||||
}
|
||||
|
||||
const errors = validateGraph(records);
|
||||
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
||||
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
||||
|
||||
@@ -7,11 +7,23 @@ export interface Task {
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
childrenIds: number[];
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TaskTimingSnapshot {
|
||||
taskId: number;
|
||||
startedAt: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
export interface TaskSnapshot {
|
||||
tasks: Task[];
|
||||
nextId: number;
|
||||
timings?: TaskTimingSnapshot[];
|
||||
}
|
||||
|
||||
export interface AddTaskDraft {
|
||||
|
||||
@@ -49,6 +49,16 @@ function styleStatusIcon(task: Task, theme: Theme): string {
|
||||
return theme.fg("dim", icon);
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||||
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
const line =
|
||||
theme.fg("dim", prefix) +
|
||||
@@ -57,7 +67,13 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width:
|
||||
theme.fg("accent", `${task.id}.`) +
|
||||
" " +
|
||||
styleTaskName(task, theme);
|
||||
return truncateToWidth(line, Math.max(1, width));
|
||||
if (task.durationMs === undefined) return truncateToWidth(line, Math.max(1, width));
|
||||
|
||||
const duration = theme.fg("dim", ` · ${formatDuration(task.durationMs)}`);
|
||||
const durationWidth = visibleWidth(duration);
|
||||
if (width <= durationWidth + 6) return truncateToWidth(line, Math.max(1, width));
|
||||
const taskWidth = Math.max(1, width - durationWidth);
|
||||
return truncateToWidth(line, taskWidth) + duration;
|
||||
}
|
||||
|
||||
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
@@ -182,7 +198,7 @@ export function renderToolResult(
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
|
||||
if (!expanded || details.affected.length === 0) return lines;
|
||||
if (details.action === "list") return [...lines, ...renderTree(details.snapshot.tasks, theme, width)];
|
||||
if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)];
|
||||
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
||||
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
||||
});
|
||||
|
||||
+25
-8
@@ -3,23 +3,40 @@ import type { TaskState } from "./state.ts";
|
||||
import { renderProgressHeader, renderTree } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
const TIMER_REFRESH_MS = 1000;
|
||||
|
||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
}
|
||||
|
||||
export function updateTaskWidget(state: TaskState, ctx: ExtensionContext, visible: boolean): void {
|
||||
export function updateTaskWidget(
|
||||
state: TaskState,
|
||||
ctx: ExtensionContext,
|
||||
visible: boolean,
|
||||
refreshMs = TIMER_REFRESH_MS,
|
||||
): void {
|
||||
const tasks = state.list();
|
||||
if (!visible || tasks.length === 0) {
|
||||
clearTaskWidget(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = state.stats();
|
||||
ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({
|
||||
render(width: number): string[] {
|
||||
return [renderProgressHeader(stats, theme, width), ...renderTree(tasks, theme, width)];
|
||||
},
|
||||
invalidate(): void {},
|
||||
}));
|
||||
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||
const timer = hasActiveTimer
|
||||
? setInterval(() => tui.requestRender(), Math.max(1, refreshMs))
|
||||
: undefined;
|
||||
timer?.unref();
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const currentTasks = state.list();
|
||||
return [renderProgressHeader(state.stats(), theme, width), ...renderTree(currentTasks, theme, width)];
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user