fix(FN-XXX): show planning-created tasks without refresh
This commit is contained in:
5
.changeset/show-planning-tasks-immediately.md
Normal file
5
.changeset/show-planning-tasks-immediately.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Show tasks created from planning sessions on the board immediately without requiring a refresh.
|
||||||
@@ -209,7 +209,7 @@ function AppInner() {
|
|||||||
// Tasks hook with project context and search query
|
// Tasks hook with project context and search query
|
||||||
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
|
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
|
||||||
const taskSseEnabled = taskView === "board" || taskView === "list";
|
const taskSseEnabled = taskView === "board" || taskView === "list";
|
||||||
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
|
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
|
||||||
{
|
{
|
||||||
...(currentProject ? { projectId: currentProject.id } : {}),
|
...(currentProject ? { projectId: currentProject.id } : {}),
|
||||||
searchQuery: searchQuery || undefined,
|
searchQuery: searchQuery || undefined,
|
||||||
@@ -464,6 +464,7 @@ function AppInner() {
|
|||||||
handleGitHubImport,
|
handleGitHubImport,
|
||||||
} = useTaskHandlers({
|
} = useTaskHandlers({
|
||||||
createTask,
|
createTask,
|
||||||
|
ingestCreatedTasks,
|
||||||
onPlanningTaskCreated: modalManager.onPlanningTaskCreated,
|
onPlanningTaskCreated: modalManager.onPlanningTaskCreated,
|
||||||
onPlanningTasksCreated: modalManager.onPlanningTasksCreated,
|
onPlanningTasksCreated: modalManager.onPlanningTasksCreated,
|
||||||
onSubtaskTasksCreated: modalManager.onSubtaskTasksCreated,
|
onSubtaskTasksCreated: modalManager.onSubtaskTasksCreated,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const CREATED_TASK: Task = {
|
|||||||
function createOptions(overrides: Partial<Parameters<typeof useTaskHandlers>[0]> = {}): Parameters<typeof useTaskHandlers>[0] {
|
function createOptions(overrides: Partial<Parameters<typeof useTaskHandlers>[0]> = {}): Parameters<typeof useTaskHandlers>[0] {
|
||||||
return {
|
return {
|
||||||
createTask: vi.fn().mockResolvedValue(CREATED_TASK),
|
createTask: vi.fn().mockResolvedValue(CREATED_TASK),
|
||||||
|
ingestCreatedTasks: vi.fn(),
|
||||||
onPlanningTaskCreated: vi.fn(),
|
onPlanningTaskCreated: vi.fn(),
|
||||||
onPlanningTasksCreated: vi.fn(),
|
onPlanningTasksCreated: vi.fn(),
|
||||||
onSubtaskTasksCreated: vi.fn(),
|
onSubtaskTasksCreated: vi.fn(),
|
||||||
@@ -71,6 +72,7 @@ describe("useTaskHandlers", () => {
|
|||||||
result.current.handlePlanningTaskCreated(CREATED_TASK);
|
result.current.handlePlanningTaskCreated(CREATED_TASK);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(options.ingestCreatedTasks).toHaveBeenCalledWith([CREATED_TASK]);
|
||||||
expect(options.onPlanningTaskCreated).toHaveBeenCalledWith(CREATED_TASK, options.addToast);
|
expect(options.onPlanningTaskCreated).toHaveBeenCalledWith(CREATED_TASK, options.addToast);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ describe("useTaskHandlers", () => {
|
|||||||
result.current.handlePlanningTasksCreated([CREATED_TASK]);
|
result.current.handlePlanningTasksCreated([CREATED_TASK]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(options.ingestCreatedTasks).toHaveBeenCalledWith([CREATED_TASK]);
|
||||||
expect(options.onPlanningTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
expect(options.onPlanningTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ describe("useTaskHandlers", () => {
|
|||||||
result.current.handleSubtaskTasksCreated([CREATED_TASK]);
|
result.current.handleSubtaskTasksCreated([CREATED_TASK]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(options.ingestCreatedTasks).toHaveBeenCalledWith([CREATED_TASK]);
|
||||||
expect(options.onSubtaskTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
expect(options.onSubtaskTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -996,6 +996,60 @@ describe("useTasks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ingestCreatedTasks", () => {
|
||||||
|
it("adds planning-created tasks to local state immediately", async () => {
|
||||||
|
mockFetchTasks.mockResolvedValueOnce([]);
|
||||||
|
const createdTask = createMockTask({ id: "FN-020", column: "triage" });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.ingestCreatedTasks([createdTask]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tasks).toHaveLength(1);
|
||||||
|
expect(result.current.tasks[0]?.id).toBe("FN-020");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite fresher task data when SSE already updated the task", async () => {
|
||||||
|
mockFetchTasks.mockResolvedValueOnce([]);
|
||||||
|
const createdTask = createMockTask({
|
||||||
|
id: "FN-021",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
});
|
||||||
|
const refreshedTask = createMockTask({
|
||||||
|
id: "FN-021",
|
||||||
|
updatedAt: "2026-01-02T00:00:00Z",
|
||||||
|
size: "L",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTasks());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockEventSource.instances[0]._emit("task:created", refreshedTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.ingestCreatedTasks([createdTask]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.tasks).toHaveLength(1);
|
||||||
|
expect(result.current.tasks[0]).toMatchObject({
|
||||||
|
id: "FN-021",
|
||||||
|
updatedAt: "2026-01-02T00:00:00Z",
|
||||||
|
size: "L",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("duplicateTask optimistic insertion", () => {
|
describe("duplicateTask optimistic insertion", () => {
|
||||||
it("adds task to state immediately", async () => {
|
it("adds task to state immediately", async () => {
|
||||||
const original = createMockTask({ id: "FN-001", column: "todo" as Column });
|
const original = createMockTask({ id: "FN-001", column: "todo" as Column });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ToastType } from "./useToast";
|
|||||||
|
|
||||||
interface UseTaskHandlersOptions {
|
interface UseTaskHandlersOptions {
|
||||||
createTask: (input: TaskCreateInput) => Promise<Task>;
|
createTask: (input: TaskCreateInput) => Promise<Task>;
|
||||||
|
ingestCreatedTasks: (tasks: Task[]) => void;
|
||||||
onPlanningTaskCreated: (task: Task, addToast: (msg: string, type?: ToastType) => void) => void;
|
onPlanningTaskCreated: (task: Task, addToast: (msg: string, type?: ToastType) => void) => void;
|
||||||
onPlanningTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
onPlanningTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
||||||
onSubtaskTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
onSubtaskTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
||||||
@@ -22,6 +23,7 @@ export interface UseTaskHandlersResult {
|
|||||||
export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandlersResult {
|
export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandlersResult {
|
||||||
const {
|
const {
|
||||||
createTask,
|
createTask,
|
||||||
|
ingestCreatedTasks,
|
||||||
onPlanningTaskCreated,
|
onPlanningTaskCreated,
|
||||||
onPlanningTasksCreated,
|
onPlanningTasksCreated,
|
||||||
onSubtaskTasksCreated,
|
onSubtaskTasksCreated,
|
||||||
@@ -44,16 +46,19 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
||||||
|
ingestCreatedTasks([task]);
|
||||||
onPlanningTaskCreated(task, addToast);
|
onPlanningTaskCreated(task, addToast);
|
||||||
}, [onPlanningTaskCreated, addToast]);
|
}, [addToast, ingestCreatedTasks, onPlanningTaskCreated]);
|
||||||
|
|
||||||
const handlePlanningTasksCreated = useCallback((tasks: Task[]) => {
|
const handlePlanningTasksCreated = useCallback((tasks: Task[]) => {
|
||||||
|
ingestCreatedTasks(tasks);
|
||||||
onPlanningTasksCreated(tasks, addToast);
|
onPlanningTasksCreated(tasks, addToast);
|
||||||
}, [onPlanningTasksCreated, addToast]);
|
}, [addToast, ingestCreatedTasks, onPlanningTasksCreated]);
|
||||||
|
|
||||||
const handleSubtaskTasksCreated = useCallback((tasks: Task[]) => {
|
const handleSubtaskTasksCreated = useCallback((tasks: Task[]) => {
|
||||||
|
ingestCreatedTasks(tasks);
|
||||||
onSubtaskTasksCreated(tasks, addToast);
|
onSubtaskTasksCreated(tasks, addToast);
|
||||||
}, [onSubtaskTasksCreated, addToast]);
|
}, [addToast, ingestCreatedTasks, onSubtaskTasksCreated]);
|
||||||
|
|
||||||
const handleGitHubImport = useCallback((task: Task) => {
|
const handleGitHubImport = useCallback((task: Task) => {
|
||||||
addToast(`Imported ${task.id} from GitHub`, "success");
|
addToast(`Imported ${task.id} from GitHub`, "success");
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
|
|||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeIncomingTask(current: Task, incoming: Task): Task {
|
||||||
|
const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt);
|
||||||
|
if (updatedAtCompare < 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.column === incoming.column) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnTimestampCompare = compareTimestamps(current.columnMovedAt, incoming.columnMovedAt);
|
||||||
|
if (current.columnMovedAt && !incoming.columnMovedAt) {
|
||||||
|
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columnTimestampCompare > 0) {
|
||||||
|
return { ...incoming, column: current.column, columnMovedAt: current.columnMovedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseTasksOptions {
|
export interface UseTasksOptions {
|
||||||
/**
|
/**
|
||||||
* When provided, fetches tasks only for this project.
|
* When provided, fetches tasks only for this project.
|
||||||
@@ -183,9 +205,22 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
if (prev.some((t) => t.id === task.id)) return prev;
|
const existingIndex = prev.findIndex((candidate) => candidate.id === task.id);
|
||||||
return [...prev, task];
|
if (existingIndex === -1) {
|
||||||
|
return [...prev, task];
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = prev[existingIndex]!;
|
||||||
|
const merged = mergeIncomingTask(current, task);
|
||||||
|
if (merged === current) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...prev];
|
||||||
|
next[existingIndex] = merged;
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
lastFetchTimeMs.current = Date.now();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoved = (e: MessageEvent) => {
|
const handleMoved = (e: MessageEvent) => {
|
||||||
@@ -214,26 +249,7 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((t) => {
|
prev.map((t) => {
|
||||||
if (t.id !== incoming.id) return t;
|
if (t.id !== incoming.id) return t;
|
||||||
|
return mergeIncomingTask(t, incoming);
|
||||||
const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt);
|
|
||||||
if (updatedAtCompare < 0) {
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.column === incoming.column) {
|
|
||||||
return incoming;
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt);
|
|
||||||
if (t.columnMovedAt && !incoming.columnMovedAt) {
|
|
||||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnTimestampCompare > 0) {
|
|
||||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
return incoming;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
lastFetchTimeMs.current = Date.now();
|
lastFetchTimeMs.current = Date.now();
|
||||||
@@ -383,5 +399,46 @@ export function useTasks(options?: UseTasksOptions) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current };
|
const ingestCreatedTasks = useCallback((incomingTasks: Task[]): void => {
|
||||||
|
if (incomingTasks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQueryRef.current) {
|
||||||
|
void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTasks = incomingTasks.map(normalizeTask);
|
||||||
|
setTasks((prev) => {
|
||||||
|
let next = prev;
|
||||||
|
|
||||||
|
for (const task of normalizedTasks) {
|
||||||
|
const existingIndex = next.findIndex((candidate) => candidate.id === task.id);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
if (next === prev) {
|
||||||
|
next = [...prev];
|
||||||
|
}
|
||||||
|
next.push(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = next[existingIndex]!;
|
||||||
|
const merged = mergeIncomingTask(current, task);
|
||||||
|
if (merged === current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === prev) {
|
||||||
|
next = [...prev];
|
||||||
|
}
|
||||||
|
next[existingIndex] = merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
lastFetchTimeMs.current = Date.now();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user