FN-8800: update board badges promptly

Accept authoritative equal-clock move events so board badges reflect lifecycle transitions without waiting for refresh.

- Treat canonical task:moved SSE destinations as authoritative when lifecycle clocks tie.
- Preserve rejection of delayed older move events.
- Cover prompt badge updates and stale-event protection in useTasks tests.

Files changed:
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 49 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useTasks.ts           | 13 +++++-
 2 files changed, 61 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-8800

Fusion-Task-Lineage: 2982c053-bdfb-40d6-9e92-29a429a2a7e6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 22:36:19 -07:00
parent 35810666be
commit 6f939a08a0
2 changed files with 61 additions and 1 deletions

View File

@@ -1410,6 +1410,55 @@ describe("useTasks", () => {
expect(result.current.tasks[0].status).toBe("executing");
});
it("updates the badge state immediately for an equal-clock canonical move and rejects a delayed older move", async () => {
const initialTask = createMockTask({
id: "FN-BADGE",
column: "todo" as Column,
status: "needs-replan",
columnMovedAt: "2026-01-02T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => expect(result.current.tasks[0]?.status).toBe("needs-replan"));
// This is the production ordering: hydration has the same operation clock, then SSE names
// the committed destination. Before FN-8800 the strict-clock merge dropped this transition.
act(() => {
MockEventSource.instances[0]._emit("task:moved", {
task: createMockTask({
id: "FN-BADGE",
column: "todo" as Column,
status: "planning",
columnMovedAt: initialTask.columnMovedAt,
updatedAt: initialTask.updatedAt,
}),
from: "todo" as Column,
to: "in-progress" as Column,
});
});
expect(result.current.tasks[0]).toMatchObject({ column: "in-progress", status: "planning" });
// A reconnect-delayed prior move has an older lifecycle clock and must not revert the badge.
act(() => {
MockEventSource.instances[0]._emit("task:moved", {
task: createMockTask({
id: "FN-BADGE",
column: "todo" as Column,
status: "needs-replan",
columnMovedAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
}),
from: "in-progress" as Column,
to: "todo" as Column,
});
});
expect(result.current.tasks[0]).toMatchObject({ column: "in-progress", status: "planning" });
});
it("preserves current column when incoming has no columnMovedAt (legacy data)", async () => {
const initialTask = createMockTask({
id: "FN-001",

View File

@@ -262,6 +262,8 @@ one provider cannot regress a modal, main panel, split detail, dock, or popup in
export interface TaskSnapshotMergeOptions {
/** A complete board/detail fetch can resolve an otherwise ambiguous legacy column clock. */
fullSnapshot?: boolean;
/** A canonical task:moved SSE payload names its destination, even when its clock ties the visible row. */
authoritativeMove?: boolean;
}
export function mergeTaskSnapshot<T extends Task>(
@@ -294,9 +296,18 @@ export function mergeTaskSnapshot<T extends Task>(
const columnMovedAtCompare = compareTimestamps(incoming.columnMovedAt, current.columnMovedAt);
/*
FNXC:BoardBadgeFreshness 2026-08-05-05:26:
`task:moved` is the post-commit lifecycle authority and includes its explicit destination. A board
fetch can observe the task immediately before the move event, leaving identical clocks when one
transition shares the engine's operation timestamp. Accept that equal-clock canonical move so cards,
list rows, and open details change promptly; older clocks remain rejected, so delayed stale events
cannot roll a newer badge backward.
*/
const incomingMovesColumn = incoming.column !== undefined
&& (current.column === undefined
|| columnMovedAtCompare > 0
|| (options.authoritativeMove === true && columnMovedAtCompare === 0)
// A full server snapshot is more complete than an SSE patch, so its newer task clock can
// resolve a legacy equal move clock without letting a sparse event move the card.
|| (options.fullSnapshot === true && columnMovedAtCompare === 0 && updatedAtCompare > 0)
@@ -948,7 +959,7 @@ export function useTasks(options?: UseTasksOptions) {
return [...prev, movedTask];
}
const current = prev[existingIndex]!;
const merged = mergeIncomingTask(current, movedTask);
const merged = mergeIncomingTask(current, movedTask, { authoritativeMove: true });
if (merged === current) return prev;
const next = [...prev];
next[existingIndex] = merged;