Files
fusion/packages/dashboard/app/hooks/useTaskRecommendations.ts
gsxdsm f7bf3f91d1 FN-9022: add task recommendations to Insights
Expose actionable task recommendations in the Insights view.

- Add recommendation list API, client hook, and Insights rendering states.
- Localize recommendation content and document the Insights workflow.
- Cover recommendation retrieval, routes, hooks, API, and view behavior.

Files changed:
 .changeset/fn-9022-insights-recommendations.md     |   7 ++
 docs/architecture.md                               |   1 +
 docs/dashboard-guide.md                            |   3 +-
 .../__tests__/task-recommendations-list.test.ts    | 136 +++++++++++++++++++++
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/store.ts                         |   5 +-
 packages/core/src/task-store/reads.ts              |  48 +++++++-
 packages/core/src/types.ts                         |   4 +
 packages/core/src/types/task/task-core.ts          |  19 +++
 packages/dashboard/app/__tests__/api-tasks.test.ts |  24 ++++
 packages/dashboard/app/api/legacy.ts               |   3 +
 packages/dashboard/app/api/tasks/tasks.ts          |  22 ++++
 packages/dashboard/app/components/InsightsView.css |  27 ++++
 packages/dashboard/app/components/InsightsView.tsx |  47 +++++--
 .../app/components/__tests__/InsightsView.test.tsx | 117 ++++++++++++++++++
 .../hooks/__tests__/useTaskRecommendations.test.ts | 130 ++++++++++++++++++++
 .../dashboard/app/hooks/useTaskRecommendations.ts  | 116 ++++++++++++++++++
 .../__tests__/task-recommendation-routes.test.ts   |  26 ++++
 .../src/routes/register-task-workflow-routes.ts    |  33 +++++
 packages/i18n/locales/en/app.json                  |  11 +-
 packages/i18n/locales/es/app.json                  |  11 +-
 packages/i18n/locales/fr/app.json                  |  11 +-
 packages/i18n/locales/ko/app.json                  |  11 +-
 packages/i18n/locales/pt-BR/app.json               |  11 +-
 packages/i18n/locales/zh-CN/app.json               |  11 +-
 packages/i18n/locales/zh-TW/app.json               |  11 +-
 packages/i18n/src/resources.d.ts                   |   9 ++
 27 files changed, 835 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-9022

Fusion-Task-Lineage: b83f1074-37af-4f87-9ec0-2bd9bb9bf64d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-12 22:18:18 -07:00

117 lines
5.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { TaskRecommendationListItem } from "@fusion/core";
import { createTaskFromRecommendation, fetchTaskRecommendations } from "../api";
export const MAX_RECOMMENDATION_PAGES = 20;
const PAGE_SIZE = 50;
export interface RecommendationActionState { running: boolean; error: string | null; }
/**
* FNXC:TaskRecommendations 2026-08-13-04:41:
* Insights owns user-driven row-page accumulation so project-wide advisory triage stays bounded
* while Load more remains reachable. Offset movement uses returned source rows, not flattened item
* count; concurrent completions may shift this advisory list, but linking a recommendation preserves
* its parent row and never invalidates this surface's own offset.
*/
export function useTaskRecommendations(projectId?: string) {
const [items, setItems] = useState<TaskRecommendationListItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [totalRowCount, setTotalRowCount] = useState(0);
const [truncated, setTruncated] = useState(false);
const [createStates, setCreateStates] = useState<Map<string, RecommendationActionState>>(new Map());
const offsetRef = useRef(0);
const pagesRef = useRef(0);
const fetchingRef = useRef(false);
const creatingRef = useRef(new Set<string>());
const epochRef = useRef(0);
const projectRef = useRef(projectId);
const fetchPage = useCallback(async (reset: boolean) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
const epoch = epochRef.current;
const offset = reset ? 0 : offsetRef.current;
if (reset) { setLoading(true); } else { setLoadingMore(true); }
setError(null);
try {
const page = await fetchTaskRecommendations(projectId, { limit: PAGE_SIZE, offset });
if (epoch !== epochRef.current) return;
const merge = (current: TaskRecommendationListItem[]) => {
const next = reset ? [] : current;
const seen = new Set(next.map((item) => `${item.taskId}:${item.recommendation.id}`));
return [...next, ...page.items.filter((item) => {
const key = `${item.taskId}:${item.recommendation.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})];
};
setItems(merge);
offsetRef.current = page.rowOffset + page.returnedRowCount;
pagesRef.current = reset ? 1 : pagesRef.current + 1;
setTotalRowCount(page.totalRowCount);
const capped = pagesRef.current >= MAX_RECOMMENDATION_PAGES && page.hasMore;
setTruncated(capped);
setHasMore(capped ? false : page.hasMore);
} catch (cause) {
if (epoch === epochRef.current) setError(cause instanceof Error ? cause.message : "Failed to fetch task recommendations");
} finally {
if (epoch === epochRef.current) {
setLoading(false);
setLoadingMore(false);
}
fetchingRef.current = false;
}
}, [projectId]);
const refresh = useCallback(async () => {
offsetRef.current = 0;
pagesRef.current = 0;
setTruncated(false);
await fetchPage(true);
}, [fetchPage]);
useEffect(() => {
projectRef.current = projectId;
epochRef.current += 1;
fetchingRef.current = false;
creatingRef.current.clear();
offsetRef.current = 0;
pagesRef.current = 0;
setItems([]); setHasMore(false); setTotalRowCount(0); setTruncated(false); setCreateStates(new Map());
void fetchPage(true);
}, [fetchPage, projectId]);
const loadMore = useCallback(async () => {
if (!hasMore || truncated || fetchingRef.current) return;
await fetchPage(false);
}, [fetchPage, hasMore, truncated]);
const createTask = useCallback(async (taskId: string, recommendationId: string) => {
const key = `${taskId}:${recommendationId}`;
if (creatingRef.current.has(key)) return;
creatingRef.current.add(key);
const epoch = epochRef.current;
setCreateStates((current) => new Map(current).set(key, { running: true, error: null }));
try {
const response = await createTaskFromRecommendation(taskId, recommendationId, projectRef.current);
if (epoch !== epochRef.current) return;
setItems((current) => current.map((item) => item.taskId === taskId && item.recommendation.id === recommendationId
? { ...item, recommendation: { ...item.recommendation, createdTaskId: response.task.id } }
: item));
setCreateStates((current) => new Map(current).set(key, { running: false, error: null }));
} catch (cause) {
if (epoch === epochRef.current) setCreateStates((current) => new Map(current).set(key, { running: false, error: cause instanceof Error ? cause.message : "Could not create task" }));
} finally {
// FNXC:TaskRecommendations 2026-08-13-04:41: An old project's completion must not
// clear the same composite key after a project switch has started a new create request.
if (epoch === epochRef.current) creatingRef.current.delete(key);
}
}, []);
return { items, loading, loadingMore, error, hasMore, totalRowCount, truncated, refresh, loadMore, createTask, createStates };
}