feat: sort sidebar task trees by newest descendant activity (#3935)
Architecture Lint / Architecture boundaries do not regress (push) Has been cancelled
Backend Tests / Plan external runner allocation (push) Has been cancelled
Backend Tests / Detect relevant changes (push) Has been cancelled
E2E Tests / Plan external runner allocation (push) Has been cancelled
Frontend Tests / Plan external runner allocation (push) Has been cancelled
Lint Action Pinning / All action refs are SHA-pinned (push) Has been cancelled
Lint Harness Files / Harness files pass lint (push) Has been cancelled
Backend Tests / Backend Static Checks (push) Has been cancelled
Backend Tests / Backend Tests (1/2) (push) Has been cancelled
Backend Tests / Backend Tests (2/2) (push) Has been cancelled
Backend Tests / Backend (ambient integration env) (push) Has been cancelled
Backend Tests / Run Backend Tests (push) Has been cancelled
Backend Tests / Backend Postgres (push) Has been cancelled
Backend Tests / Backend Postgres 18 (push) Has been cancelled
Backend Tests / Backend (windows) (push) Has been cancelled
E2E Tests / Detect relevant changes (push) Has been cancelled
E2E Tests / Build (push) Has been cancelled
E2E Tests / E2E Shard ${{ matrix.shard }}/14 (push) Has been cancelled
E2E Tests / Resolve Playwright runtime image (push) Has been cancelled
E2E Tests / E2E Containers Shard 1/6 (push) Has been cancelled
E2E Tests / E2E Containers Shard 2/6 (push) Has been cancelled
E2E Tests / E2E Containers Shard 3/6 (push) Has been cancelled
E2E Tests / E2E Containers Shard 4/6 (push) Has been cancelled
E2E Tests / E2E Containers Shard 5/6 (push) Has been cancelled
E2E Tests / E2E Containers Shard 6/6 (push) Has been cancelled
E2E Tests / Kubernetes Compatibility (v1.34.8) (push) Has been cancelled
E2E Tests / Kubernetes Compatibility (v1.36.1) (push) Has been cancelled
E2E Tests / Desktop E2E Smoke (push) Has been cancelled
E2E Tests / Merge E2E Reports (push) Has been cancelled
E2E Tests / E2E Tests Passed (push) Has been cancelled
Frontend Tests / Detect relevant changes (push) Has been cancelled
Frontend Tests / Run Frontend Lint, Tests, and Build (push) Has been cancelled
Frontend Tests / Frontend Tests Passed (push) Has been cancelled

This commit is contained in:
Carlos Florêncio
2026-09-25 18:48:32 +01:00
committed by GitHub
parent 59176b6616
commit 45cef11630
13 changed files with 1027 additions and 10 deletions
@@ -74,6 +74,40 @@ describe("toSheetItem repository projection", () => {
});
});
describe("toSheetItem activity projection", () => {
it("preserves task activity and its task-local fallbacks on the phone row", () => {
const item = toSheetItem(
task({
updatedAt: "task-updated",
createdAt: "task-created",
statusSummary: {
revision: 3,
updated_at: "summary-refreshed",
last_activity_at: "task-activity",
},
}),
emptyCtx(),
);
expect(item.lastActivityAt).toBe("task-activity");
expect(item.updatedAt).toBe("summary-refreshed");
expect(item.createdAt).toBe("task-created");
});
it("falls back to the task update time instead of summary freshness", () => {
const item = toSheetItem(
task({
updatedAt: "task-updated",
createdAt: "task-created",
statusSummary: { revision: 3, updated_at: "summary-refreshed" },
}),
emptyCtx(),
);
expect(item.lastActivityAt).toBe("task-updated");
});
});
describe("toSheetItem remote executor projection", () => {
it("carries the exact remote executor scope onto the mobile sheet row", () => {
const item = toSheetItem(
@@ -58,6 +58,10 @@ function sheetLaunchQueue(task: KanbanState["tasks"][number]) {
return task.statusSummary?.launch_queue;
}
function sheetLastActivity(task: KanbanState["tasks"][number]) {
return task.statusSummary?.last_activity_at ?? task.updatedAt ?? task.createdAt;
}
function sheetStatus(task: KanbanState["tasks"][number], ctx: SheetItemCtx) {
const summary = task.statusSummary;
const hasSummary = summary != null;
@@ -104,6 +108,8 @@ export function toSheetItem(
title: task.title,
autopilot: task.autopilot,
priority: task.priority,
createdAt: task.createdAt,
lastActivityAt: sheetLastActivity(task),
parentTaskId: task.parentTaskId ?? undefined,
workspaceMode: task.workspaceMode,
state: task.state as TaskState | undefined,
@@ -0,0 +1,50 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { seedTaskTreeActivityScenario } from "./sidebar-task-tree-activity-sort-helpers";
// @covers AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1, .2, .5, .6
test("phone task drawer ranks a parent by its active child and navigates to that child", async ({
testPage,
apiClient,
seedData,
}) => {
const { parent, child, peer, navigationTaskId } = await seedTaskTreeActivityScenario(
apiClient,
seedData,
"Phone tree sort",
);
await testPage.goto(`/t/${navigationTaskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(sheet).toBeVisible();
const treeAndPeerIds = [parent.id, peer.id];
await expect
.poll(async () => {
const rows = await sheet
.locator("[data-task-row-id]")
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-task-row-id")),
);
return rows.filter((id): id is string => id !== null && treeAndPeerIds.includes(id));
})
.toEqual([parent.id, peer.id]);
const parentTime = await sheet
.locator(`[data-task-row-id="${parent.id}"]`)
.getByTestId("sidebar-task-time")
.getAttribute("data-time-value");
const childTime = await sheet
.locator(`[data-task-row-id="${child.id}"]`)
.getByTestId("sidebar-task-time")
.getAttribute("data-time-value");
expect(parentTime).toBeTruthy();
expect(childTime).toBeTruthy();
expect(Date.parse(childTime!)).toBeGreaterThan(Date.parse(parentTime!));
await sheet.locator(`[data-task-row-id="${child.id}"]`).tap();
await expect(testPage).toHaveURL((url) => url.pathname === `/t/${child.id}`);
});
@@ -0,0 +1,48 @@
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
export async function seedTaskTreeActivityScenario(
apiClient: ApiClient,
seedData: SeedData,
prefix: string,
) {
const taskOptions = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parent = await apiClient.createTask(seedData.workspaceId, `${prefix} Parent`, taskOptions);
const child = await apiClient.createTask(seedData.workspaceId, `${prefix} Child`, {
...taskOptions,
parent_id: parent.id,
});
const peer = await apiClient.createTask(seedData.workspaceId, `${prefix} Peer`, taskOptions);
await apiClient.updateTaskTitle(child.id, `${prefix} Child active`);
const navigationTask = await apiClient.seedTask(seedData.workspaceId, `${prefix} Navigation`, {
...taskOptions,
});
const viewId = `tree-activity-${prefix.toLowerCase().replaceAll(" ", "-")}`;
await apiClient.saveUserSettings({
sidebar_view_state: {
workspace_id: seedData.workspaceId,
views: [
{
id: viewId,
name: `${prefix} activity view`,
filters: [],
sort: { key: "lastActivityAt", direction: "desc" },
group: "none",
collapsed_groups: [],
},
],
active_view_id: viewId,
},
});
return {
parent,
child,
peer,
navigationTaskId: navigationTask.task_id,
};
}
@@ -0,0 +1,43 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { seedTaskTreeActivityScenario } from "./sidebar-task-tree-activity-sort-helpers";
// @covers AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1, .2, .5, .6
test("desktop sidebar ranks a parent by its active child and keeps row-local times", async ({
testPage,
apiClient,
seedData,
}) => {
const { parent, child, peer, navigationTaskId } = await seedTaskTreeActivityScenario(
apiClient,
seedData,
"Desktop tree sort",
);
await testPage.goto(`/t/${navigationTaskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const treeAndPeerIds = [parent.id, peer.id];
await expect
.poll(async () => {
const rows = await session.sidebar
.locator("[data-task-row-id]")
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-task-row-id")),
);
return rows.filter((id): id is string => id !== null && treeAndPeerIds.includes(id));
})
.toEqual([parent.id, peer.id]);
const parentTime = await session.sidebar
.locator(`[data-task-row-id="${parent.id}"]`)
.getByTestId("sidebar-task-time")
.getAttribute("data-time-value");
const childTime = await session.sidebar
.locator(`[data-task-row-id="${child.id}"]`)
.getByTestId("sidebar-task-time")
.getAttribute("data-time-value");
expect(parentTime).toBeTruthy();
expect(childTime).toBeTruthy();
expect(Date.parse(childTime!)).toBeGreaterThan(Date.parse(parentTime!));
});
+52 -4
View File
@@ -24,6 +24,8 @@ function task(overrides: Partial<TaskSwitcherItem>): TaskSwitcherItem {
}
const C = (c: Omit<FilterClause, "id">): FilterClause => ({ id: "c1", ...c });
const LAST_ACTIVITY_SORT_KEY = "lastActivityAt";
const TEST_DATE_EARLY = "2026-04-01";
describe("applyFilters — basics", () => {
it("returns all when no clauses", () => {
@@ -238,7 +240,7 @@ describe("applyFilters — titleMatch + combos", () => {
});
describe("applySort", () => {
const early = "2026-04-01";
const early = TEST_DATE_EARLY;
const middle = "2026-04-03";
const late = "2026-04-05";
const a = task({ id: "a", state: "REVIEW", updatedAt: early, title: "Zeta" });
@@ -272,7 +274,7 @@ describe("applySort", () => {
createdAt: early,
}),
],
{ key: "lastActivityAt", direction: "desc" },
{ key: LAST_ACTIVITY_SORT_KEY, direction: "desc" },
);
expect(out.map((t) => t.id)).toEqual(["activity", "updated-fallback", "created-fallback"]);
});
@@ -280,7 +282,7 @@ describe("applySort", () => {
it("keeps lastActivityAt stable for equal timestamps", () => {
const out = applySort(
[task({ id: "first", lastActivityAt: late }), task({ id: "second", lastActivityAt: late })],
{ key: "lastActivityAt", direction: "asc" },
{ key: LAST_ACTIVITY_SORT_KEY, direction: "asc" },
);
expect(out.map((t) => t.id)).toEqual(["first", "second"]);
});
@@ -708,7 +710,7 @@ describe("applyView — custom sort", () => {
it("places tasks not in orderedTaskIds after listed ones, newest createdAt first", () => {
const tasks = [
task({ id: "a", title: "Alpha", createdAt: "2026-01-01" }),
task({ id: "b", title: "Beta", createdAt: "2026-04-01" }),
task({ id: "b", title: "Beta", createdAt: TEST_DATE_EARLY }),
task({ id: "c", title: "Gamma", createdAt: "2026-02-01" }),
];
const out = applyView(tasks, customView, { pinnedTaskIds: [], orderedTaskIds: ["c"] });
@@ -849,6 +851,52 @@ describe("applyView — subtaskOrderByParentId", () => {
});
});
describe("applyView — Last activity tree overrides", () => {
it("keeps pin and manual subtask order ahead of tree sorting without changing Updated", () => {
const lastActivityView: SidebarView = {
id: "activity",
name: "Activity",
filters: [],
sort: { key: LAST_ACTIVITY_SORT_KEY, direction: "desc" },
group: "none",
collapsedGroups: [],
};
const tasks = [
task({ id: "parent", lastActivityAt: TEST_DATE_EARLY, updatedAt: TEST_DATE_EARLY }),
task({
id: "child",
parentTaskId: "parent",
lastActivityAt: "2026-04-10",
updatedAt: "2026-04-03",
}),
task({
id: "sibling",
parentTaskId: "parent",
lastActivityAt: "2026-04-02",
updatedAt: "2026-04-08",
}),
task({ id: "peer", lastActivityAt: "2026-04-04", updatedAt: "2026-04-05" }),
];
const activity = applyView(tasks, lastActivityView, {
pinnedTaskIds: ["peer"],
orderedTaskIds: [],
subtaskOrderByParentId: { parent: ["sibling", "child"] },
});
expect(activity.groups[0].tasks.map((item) => item.id)).toEqual(["peer", "parent"]);
expect(activity.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([
"sibling",
"child",
]);
const updated = applyView(tasks, {
...lastActivityView,
sort: { key: "updatedAt", direction: "desc" },
});
expect(updated.groups[0].tasks.map((item) => item.id)).toEqual(["peer", "parent"]);
});
});
describe("mergeGroupOrder", () => {
it("appends when no group items are in current", () => {
expect(mergeGroupOrder([], ["a", "b"])).toEqual(["a", "b"]);
+14 -5
View File
@@ -19,6 +19,11 @@ import {
STATE_GROUP_ORDER,
type EffectiveTaskTreeState,
} from "./effective-task-tree-state";
import {
compareActivityTimestamps,
resolveTaskTreeActivity,
taskActivitySortValue,
} from "./task-tree-activity";
export type SidebarGroup = {
key: string;
@@ -122,10 +127,6 @@ export function applyFilters(
type SortComparator = (a: TaskSwitcherItem, b: TaskSwitcherItem) => number;
function lastActivitySortValue(task: TaskSwitcherItem): string {
return task.lastActivityAt ?? task.updatedAt ?? task.createdAt ?? "";
}
const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = {
state: (a, b) => {
const bucket = STATE_BUCKET_ORDER[getStateBucket(a)] - STATE_BUCKET_ORDER[getStateBucket(b)];
@@ -134,7 +135,8 @@ const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = {
return (b.createdAt ?? "").localeCompare(a.createdAt ?? "");
},
updatedAt: (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""),
lastActivityAt: (a, b) => lastActivitySortValue(a).localeCompare(lastActivitySortValue(b)),
lastActivityAt: (a, b) =>
compareActivityTimestamps(taskActivitySortValue(a), taskActivitySortValue(b)),
createdAt: (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""),
title: (a, b) => (a.title ?? "").localeCompare(b.title ?? ""),
};
@@ -176,6 +178,13 @@ export function applySort(
if (bucket !== 0) return bucket;
return (b.createdAt ?? "").localeCompare(a.createdAt ?? "");
};
} else if (spec.key === "lastActivityAt" && subTasksByParentId) {
const latestActivityByTaskId = resolveTaskTreeActivity(tasks, subTasksByParentId);
cmp = (a, b) =>
compareActivityTimestamps(
latestActivityByTaskId.get(a.id) ?? taskActivitySortValue(a),
latestActivityByTaskId.get(b.id) ?? taskActivitySortValue(b),
);
} else {
cmp = spec.key === "custom" ? customComparator(orderedTaskIds) : SORT_COMPARATORS[spec.key];
}
@@ -0,0 +1,296 @@
import { describe, expect, it } from "vitest";
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
import { applySort, applyView, flattenVisibleTaskIds } from "./apply-view";
const ACTIVITY = {
early: "2026-04-01",
second: "2026-04-02",
third: "2026-04-03",
fourth: "2026-04-04",
latest: "2026-04-05",
farFuture: "2026-04-10",
fractional120: "2026-04-01T10:00:00.12Z",
fractional121: "2026-04-01T10:00:00.121Z",
fractional120Equivalent: "2026-04-01T11:00:00.120+01:00",
fractional123: "2026-04-01T10:00:00.123Z",
fractional125: "2026-04-01T10:00:00.125Z",
normalizedInvalid: "2026-02-30T10:00:00Z",
createdParent: "2026-03-01",
createdChild: "2026-03-02",
} as const;
const SHORT_FRACTION_TASK_ID = "short-fraction";
function task(overrides: Partial<TaskSwitcherItem>): TaskSwitcherItem {
return {
id: overrides.id ?? "task",
title: overrides.title ?? "Task",
...overrides,
};
}
function lastActivityView(
direction: "asc" | "desc" = "desc",
filters: SidebarView["filters"] = [],
): SidebarView {
return {
id: "last-activity",
name: "Last activity",
filters,
sort: { key: "lastActivityAt", direction },
group: "none",
collapsedGroups: [],
};
}
// @covers AC-UI-SIDEBAR-LAST-ACTIVITY-SORT-002.1, .2, .4, .5
describe("task tree activity root and child order", () => {
it("ranks roots and child subtrees by their newest included descendant in either direction", () => {
const tasks = [
task({ id: "peer", lastActivityAt: ACTIVITY.fourth }),
task({ id: "parent", lastActivityAt: ACTIVITY.early }),
task({ id: "sibling", parentTaskId: "parent", lastActivityAt: ACTIVITY.third }),
task({ id: "child", parentTaskId: "parent", lastActivityAt: ACTIVITY.second }),
task({ id: "grandchild", parentTaskId: "child", lastActivityAt: ACTIVITY.latest }),
];
const descending = applyView(tasks, lastActivityView("desc"));
expect(descending.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]);
expect(descending.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([
"child",
"sibling",
]);
const ascending = applyView(tasks, lastActivityView("asc"));
expect(ascending.groups[0].tasks.map((item) => item.id)).toEqual(["peer", "parent"]);
expect(ascending.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([
"sibling",
"child",
]);
expect(tasks.find((item) => item.id === "parent")?.lastActivityAt).toBe(ACTIVITY.early);
});
it("ignores filtered-out descendants and promotes a child when its parent is filtered out", () => {
const parent = task({
id: "parent",
title: "Visible parent",
lastActivityAt: ACTIVITY.early,
});
const includedChild = task({
id: "included-child",
title: "Visible child",
parentTaskId: "parent",
lastActivityAt: ACTIVITY.latest,
});
const excludedChild = task({
id: "excluded-child",
title: "Hidden child",
parentTaskId: "parent",
isArchived: true,
lastActivityAt: ACTIVITY.farFuture,
});
const peer = task({
id: "peer",
title: "Visible peer",
lastActivityAt: ACTIVITY.fourth,
});
const visible = applyView(
[parent, includedChild, excludedChild, peer],
lastActivityView("desc", [
{ id: "active-only", dimension: "archived", op: "is", value: false },
]),
);
expect(visible.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]);
expect(visible.subTasksByParentId.get("parent")?.map((item) => item.id)).toEqual([
"included-child",
]);
const promoted = applyView(
[parent, includedChild, peer],
lastActivityView("desc", [
{ id: "visible-child", dimension: "titleMatch", op: "matches", value: "child" },
]),
);
expect(promoted.groups[0].tasks.map((item) => item.id)).toEqual(["included-child"]);
expect(promoted.subTasksByParentId.has("parent")).toBe(false);
});
it("keeps equal tree keys stable and collapse does not change root rank", () => {
const tasks = [
task({ id: "parent", lastActivityAt: ACTIVITY.early }),
task({ id: "child", parentTaskId: "parent", lastActivityAt: ACTIVITY.latest }),
task({ id: "peer", lastActivityAt: ACTIVITY.latest }),
];
const grouped = applyView(tasks, lastActivityView());
expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]);
expect(flattenVisibleTaskIds(grouped, [], ["parent"])).toEqual(["parent", "peer"]);
expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual(["parent", "peer"]);
});
});
describe("task activity fallback timestamps", () => {
it("uses task update and creation timestamps when Last activity is unavailable", () => {
const tasks = [
task({
id: "parent",
updatedAt: ACTIVITY.early,
createdAt: ACTIVITY.createdParent,
}),
task({
id: "child",
parentTaskId: "parent",
updatedAt: ACTIVITY.latest,
createdAt: ACTIVITY.createdChild,
}),
task({ id: "peer", lastActivityAt: ACTIVITY.fourth }),
task({ id: "created-only", createdAt: ACTIVITY.third }),
];
const grouped = applyView(tasks, lastActivityView());
expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual([
"parent",
"peer",
"created-only",
]);
});
});
describe("fractional activity timestamp precision", () => {
it("orders two root tasks by their chronological instant in both directions", () => {
const tasks = [
task({ id: SHORT_FRACTION_TASK_ID, lastActivityAt: ACTIVITY.fractional120 }),
task({ id: "long-fraction", lastActivityAt: ACTIVITY.fractional123 }),
];
expect(
applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id),
).toEqual(["long-fraction", SHORT_FRACTION_TASK_ID]);
expect(
applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id),
).toEqual([SHORT_FRACTION_TASK_ID, "long-fraction"]);
});
it("aggregates a child timestamp precisely when ranking the parent tree in both directions", () => {
const tasks = [
task({ id: "parent", lastActivityAt: ACTIVITY.fractional120 }),
task({
id: "child",
parentTaskId: "parent",
lastActivityAt: ACTIVITY.fractional123,
}),
task({ id: "peer", lastActivityAt: ACTIVITY.fractional121 }),
];
expect(
applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id),
).toEqual(["parent", "peer"]);
expect(
applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id),
).toEqual(["peer", "parent"]);
});
it("keeps equivalent fractional timestamps stable and missing timestamps in their prior position", () => {
const tiedTasks = [
task({ id: SHORT_FRACTION_TASK_ID, lastActivityAt: ACTIVITY.fractional120 }),
task({ id: "padded-fraction", lastActivityAt: ACTIVITY.fractional120Equivalent }),
];
const missingTasks = [
task({ id: "first-missing" }),
task({ id: "dated", lastActivityAt: ACTIVITY.fractional120 }),
task({ id: "second-missing" }),
];
for (const direction of ["asc", "desc"] as const) {
expect(
applyView(tiedTasks, lastActivityView(direction)).groups[0].tasks.map((item) => item.id),
).toEqual([SHORT_FRACTION_TASK_ID, "padded-fraction"]);
}
expect(
applyView(missingTasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id),
).toEqual(["dated", "first-missing", "second-missing"]);
expect(
applyView(missingTasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id),
).toEqual(["first-missing", "second-missing", "dated"]);
});
it("uses the existing lexical fallback for malformed or normalized wire timestamps", () => {
const tasks = [
task({ id: "normalized-invalid", lastActivityAt: ACTIVITY.normalizedInvalid }),
task({ id: "valid", lastActivityAt: "2026-03-01T12:00:00Z" }),
];
expect(
applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id),
).toEqual(["valid", "normalized-invalid"]);
expect(
applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id),
).toEqual(["normalized-invalid", "valid"]);
const zeroTasks = [
task({ id: "zero", lastActivityAt: "0" }),
task({ id: "older", lastActivityAt: "1999-12-31T23:59:59Z" }),
];
expect(
applyView(zeroTasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id),
).toEqual(["older", "zero"]);
expect(
applyView(zeroTasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id),
).toEqual(["zero", "older"]);
});
it("does not let a normalized-invalid child move its parent tree", () => {
const tasks = [
task({ id: "parent", lastActivityAt: "2026-03-01T12:00:00Z" }),
task({
id: "child",
parentTaskId: "parent",
lastActivityAt: ACTIVITY.normalizedInvalid,
}),
task({ id: "peer", lastActivityAt: "2026-03-02T09:00:00Z" }),
];
expect(
applyView(tasks, lastActivityView("desc")).groups[0].tasks.map((item) => item.id),
).toEqual(["peer", "parent"]);
expect(
applyView(tasks, lastActivityView("asc")).groups[0].tasks.map((item) => item.id),
).toEqual(["parent", "peer"]);
});
});
describe("large and malformed task trees", () => {
it("resolves activity on a deep descendant without recursive traversal", () => {
const tasks: TaskSwitcherItem[] = [task({ id: "root", lastActivityAt: ACTIVITY.early })];
let parentTaskId = "root";
for (let index = 0; index < 6_000; index += 1) {
const id = "child-" + index;
tasks.push(
task({
id,
parentTaskId,
lastActivityAt: index === 5_999 ? ACTIVITY.farFuture : ACTIVITY.second,
}),
);
parentTaskId = id;
}
tasks.push(task({ id: "peer", lastActivityAt: ACTIVITY.latest }));
const grouped = applyView(tasks, lastActivityView());
expect(grouped.groups[0].tasks.map((item) => item.id)).toEqual(["root", "peer"]);
});
it("terminates and returns a key for every task in a malformed parent cycle", () => {
const a = task({ id: "a", lastActivityAt: ACTIVITY.early });
const b = task({ id: "b", lastActivityAt: ACTIVITY.second });
const children = new Map<string, TaskSwitcherItem[]>([
["a", [b]],
["b", [a]],
]);
const sorted = applySort([a, b], { key: "lastActivityAt", direction: "desc" }, [], children);
expect(sorted.map((item) => item.id)).toEqual(["a", "b"]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
type IncludedTaskGraph = {
tasksById: Map<string, TaskSwitcherItem>;
taskIds: Set<string>;
childrenById: Map<string, string[]>;
parentsById: Map<string, string[]>;
};
type ComponentGraph = {
children: Set<number>[];
parents: Set<number>[];
};
export function taskActivitySortValue(task: TaskSwitcherItem): string {
return task.lastActivityAt ?? task.updatedAt ?? task.createdAt ?? "";
}
function parseActivityTimestamp(value: string): bigint | null {
return parseStrictRfc3339Timestamp(value);
}
/** Compare valid timestamps by instant, preserving lexical fallback for missing or invalid values. */
export function compareActivityTimestamps(a: string, b: string): number {
if (a === b) return 0;
const parsedA = parseActivityTimestamp(a);
const parsedB = parseActivityTimestamp(b);
if (parsedA !== null && parsedB !== null) {
if (parsedA === parsedB) return 0;
return parsedA < parsedB ? -1 : 1;
}
return a.localeCompare(b);
}
function laterActivity(a: string, b: string): string {
return compareActivityTimestamps(a, b) >= 0 ? a : b;
}
function buildIncludedTaskGraph(
tasks: TaskSwitcherItem[],
subTasksByParentId: Map<string, TaskSwitcherItem[]>,
): IncludedTaskGraph {
const tasksById = new Map(tasks.map((task) => [task.id, task]));
const taskIds = new Set(tasksById.keys());
const childrenById = new Map<string, string[]>();
const parentsById = new Map<string, string[]>();
for (const taskId of taskIds) {
childrenById.set(taskId, []);
parentsById.set(taskId, []);
}
for (const [parentId, children] of subTasksByParentId) {
if (!taskIds.has(parentId)) continue;
const includedChildren = childrenById.get(parentId)!;
const seenChildIds = new Set<string>();
for (const child of children) {
if (!taskIds.has(child.id) || seenChildIds.has(child.id)) continue;
seenChildIds.add(child.id);
includedChildren.push(child.id);
parentsById.get(child.id)!.push(parentId);
}
}
return { tasksById, taskIds, childrenById, parentsById };
}
function buildFinishOrder(taskIds: Set<string>, childrenById: Map<string, string[]>): string[] {
const visited = new Set<string>();
const finishOrder: string[] = [];
for (const startId of taskIds) {
if (visited.has(startId)) continue;
visited.add(startId);
const stack: Array<{ taskId: string; nextChildIndex: number }> = [
{ taskId: startId, nextChildIndex: 0 },
];
while (stack.length > 0) {
const frame = stack[stack.length - 1];
const children = childrenById.get(frame.taskId)!;
if (frame.nextChildIndex === children.length) {
finishOrder.push(frame.taskId);
stack.pop();
continue;
}
const childId = children[frame.nextChildIndex++];
if (visited.has(childId)) continue;
visited.add(childId);
stack.push({ taskId: childId, nextChildIndex: 0 });
}
}
return finishOrder;
}
function buildStrongComponents(
finishOrder: string[],
parentsById: Map<string, string[]>,
): { components: string[][]; componentByTaskId: Map<string, number> } {
const componentByTaskId = new Map<string, number>();
const components: string[][] = [];
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
const startId = finishOrder[index];
if (componentByTaskId.has(startId)) continue;
const componentId = components.length;
const members: string[] = [];
const stack = [startId];
componentByTaskId.set(startId, componentId);
while (stack.length > 0) {
const taskId = stack.pop()!;
members.push(taskId);
for (const parentId of parentsById.get(taskId)!) {
if (componentByTaskId.has(parentId)) continue;
componentByTaskId.set(parentId, componentId);
stack.push(parentId);
}
}
components.push(members);
}
return { components, componentByTaskId };
}
function buildComponentGraph(
components: string[][],
componentByTaskId: Map<string, number>,
childrenById: Map<string, string[]>,
): ComponentGraph {
const children = components.map(() => new Set<number>());
const parents = components.map(() => new Set<number>());
for (const [parentId, childIds] of childrenById) {
const parentComponent = componentByTaskId.get(parentId)!;
for (const childId of childIds) {
const childComponent = componentByTaskId.get(childId)!;
if (parentComponent === childComponent) continue;
children[parentComponent].add(childComponent);
parents[childComponent].add(parentComponent);
}
}
return { children, parents };
}
function resolveComponentActivity(
components: string[][],
graph: ComponentGraph,
tasksById: Map<string, TaskSwitcherItem>,
): string[] {
const latestByComponent = components.map((members) =>
members.reduce(
(latest, taskId) => laterActivity(latest, taskActivitySortValue(tasksById.get(taskId)!)),
"",
),
);
const remainingChildren = graph.children.map((children) => children.size);
const ready: number[] = [];
for (let componentId = 0; componentId < remainingChildren.length; componentId += 1) {
if (remainingChildren[componentId] === 0) ready.push(componentId);
}
for (let head = 0; head < ready.length; head += 1) {
const childComponent = ready[head];
for (const parentComponent of graph.parents[childComponent]) {
latestByComponent[parentComponent] = laterActivity(
latestByComponent[parentComponent],
latestByComponent[childComponent],
);
remainingChildren[parentComponent] -= 1;
if (remainingChildren[parentComponent] === 0) ready.push(parentComponent);
}
}
return latestByComponent;
}
/** Resolve each included task to the newest activity in its reachable subtree. */
export function resolveTaskTreeActivity(
tasks: TaskSwitcherItem[],
subTasksByParentId: Map<string, TaskSwitcherItem[]>,
): Map<string, string> {
const taskGraph = buildIncludedTaskGraph(tasks, subTasksByParentId);
const finishOrder = buildFinishOrder(taskGraph.taskIds, taskGraph.childrenById);
const { components, componentByTaskId } = buildStrongComponents(
finishOrder,
taskGraph.parentsById,
);
const componentGraph = buildComponentGraph(components, componentByTaskId, taskGraph.childrenById);
const latestByComponent = resolveComponentActivity(
components,
componentGraph,
taskGraph.tasksById,
);
return new Map(
Array.from(componentByTaskId, ([taskId, componentId]) => [
taskId,
latestByComponent[componentId],
]),
);
}