feat: add task duration tracking

This commit is contained in:
lesetong
2026-07-28 17:39:45 +08:00
parent 57c3354211
commit 2ff0a314ed
10 changed files with 482 additions and 27 deletions
+138
View File
@@ -68,6 +68,144 @@ test("enforces done descendants against the final atomic update state", () => {
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
});
test("preserves and accumulates timing across repeated updates and status changes", () => {
const startedAt = Date.UTC(2026, 0, 1);
let now = startedAt;
const state = new TaskState(() => now);
const [started] = state.add([{ name: "Timed", status: "in-progress" }]);
assert.equal(started?.startedAt, new Date(startedAt).toISOString());
assert.equal(started?.stoppedAt, undefined);
assert.equal(started?.durationMs, 0);
now += 5_000;
const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]);
assert.equal(repeated?.startedAt, started?.startedAt);
assert.equal(repeated?.stoppedAt, undefined);
assert.equal(repeated?.durationMs, 5_000);
now += 2_000;
const [paused] = state.update([{ id: 1, status: "blocked" }]);
assert.equal(paused?.startedAt, started?.startedAt);
assert.equal(paused?.stoppedAt, new Date(now).toISOString());
assert.equal(paused?.durationMs, 7_000);
now += 10_000;
assert.equal(state.get([1])[0]?.durationMs, 7_000);
const [resumed] = state.update([{ id: 1, status: "in-progress" }]);
assert.equal(resumed?.startedAt, started?.startedAt);
assert.equal(resumed?.stoppedAt, undefined);
assert.equal(resumed?.durationMs, 7_000);
now += 3_000;
const [done] = state.update([{ id: 1, status: "done" }]);
assert.equal(done?.startedAt, started?.startedAt);
assert.equal(done?.stoppedAt, new Date(now).toISOString());
assert.equal(done?.durationMs, 10_000);
assert.deepEqual(state.snapshot().timings, [
{ taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 },
]);
});
test("does not stop a timer when an atomic update fails", () => {
const startedAt = Date.UTC(2026, 0, 1);
let now = startedAt;
const state = new TaskState(() => now);
state.add([
{
name: "Parent",
status: "in-progress",
children: [{ name: "Unfinished child" }],
},
]);
now += 5_000;
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
const afterFailure = state.get([1])[0];
assert.equal(afterFailure?.stoppedAt, undefined);
assert.equal(afterFailure?.durationMs, 5_000);
now += 2_000;
assert.equal(state.get([1])[0]?.durationMs, 7_000);
});
test("restores timers and starts legacy in-progress snapshots at load time", () => {
const startedAt = Date.UTC(2026, 0, 1);
let now = startedAt;
const source = new TaskState(() => now);
source.add([{ name: "Persisted", status: "in-progress" }]);
now += 2_500;
source.update([{ id: 1, status: "pending" }]);
now += 20_000;
const restored = new TaskState(() => now);
restored.restore(source.snapshot());
assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString());
assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString());
assert.equal(restored.get([1])[0]?.durationMs, 2_500);
const legacy = new TaskState(() => now);
legacy.restore({
tasks: [
{
id: 1,
name: "Legacy active",
status: "in-progress",
parentId: null,
childrenIds: [],
},
],
nextId: 2,
});
assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString());
assert.equal(legacy.get([1])[0]?.durationMs, 0);
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
});
test("rejects out-of-range persisted timestamps without replacing state", () => {
const state = new TaskState(() => 0);
state.add([{ name: "Keep" }]);
const before = state.snapshot();
assert.throws(
() =>
state.restore({
tasks: [
{
id: 1,
name: "Corrupt timer",
status: "in-progress",
parentId: null,
childrenIds: [],
},
],
nextId: 2,
timings: [
{
taskId: 1,
startedAt: Number.MAX_SAFE_INTEGER,
activeStartedAt: Number.MAX_SAFE_INTEGER,
accumulatedMs: 0,
},
],
}),
/startedAt must be a valid non-negative timestamp/,
);
assert.deepEqual(state.snapshot(), before);
});
test("finalizes an active timer in removed task details", () => {
let now = Date.UTC(2026, 0, 1);
const state = new TaskState(() => now);
state.add([{ name: "Canceled", status: "in-progress" }]);
now += 4_000;
const [removed] = state.remove([1]);
assert.equal(removed?.stoppedAt, new Date(now).toISOString());
assert.equal(removed?.durationMs, 4_000);
assert.deepEqual(state.list(), []);
});
test("rejects cycles and duplicate patches without partial changes", () => {
const state = new TaskState();
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);