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