Files
kandev/apps/web/lib/sidebar/task-tree-activity.ts
T
Carlos Florêncio 45cef11630
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
feat: sort sidebar task trees by newest descendant activity (#3935)
2026-09-25 18:48:32 +01:00

197 lines
6.6 KiB
TypeScript

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],
]),
);
}