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
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:
@@ -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!));
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user