diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 8c8c327831..c51dd29abb 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -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", diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index ac5d864c77..e415e7aeae 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -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( @@ -294,9 +296,18 @@ export function mergeTaskSnapshot( 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;