diff --git a/.changeset/insights-newest-first.md b/.changeset/insights-newest-first.md new file mode 100644 index 0000000000..48c649fc3d --- /dev/null +++ b/.changeset/insights-newest-first.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Insights now list newest first instead of oldest first. +category: fix +dev: Ordering is applied in `useInsights` section grouping; `InsightStore.listInsights` keeps its `createdAt ASC, id ASC` contract. diff --git a/packages/dashboard/app/components/__tests__/InsightsView.test.tsx b/packages/dashboard/app/components/__tests__/InsightsView.test.tsx index 118821dec0..843111d756 100644 --- a/packages/dashboard/app/components/__tests__/InsightsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/InsightsView.test.tsx @@ -271,6 +271,47 @@ describe("InsightsView", () => { expect(screen.getByTestId("insights-section-features")).toBeInTheDocument(); }); + it("renders active-section insight titles newest-first in document order", () => { + mockUseInsights.mockReturnValue({ + sections: [ + { + ...mockSections[0], + items: [ + { id: "INS-NEW", projectId: "test", title: "Newest insight", content: "", category: "features", status: "generated", fingerprint: "fp-new", provenance: { trigger: "manual" }, lastRunId: null, createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" }, + { id: "INS-MID", projectId: "test", title: "Middle insight", content: "", category: "features", status: "generated", fingerprint: "fp-mid", provenance: { trigger: "manual" }, lastRunId: null, createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" }, + { id: "INS-OLD", projectId: "test", title: "Oldest insight", content: "", category: "features", status: "generated", fingerprint: "fp-old", provenance: { trigger: "manual" }, lastRunId: null, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, + ], + }, + ...mockSections.slice(1), + ], + loading: false, + error: null, + latestRun: null, + isRunInFlight: false, + runError: null, + refresh: vi.fn(), + runInsights: vi.fn(), + dismiss: vi.fn(), + createTask: vi.fn(), + archive: vi.fn(), + unarchive: vi.fn(), + toggleShowArchived: vi.fn(), + dismissStates: new Map(), + createTaskStates: new Map(), + archiveStates: new Map(), + unarchiveStates: new Map(), + totalCount: 3, + dismissedCount: 0, + archivedCount: 0, + showArchived: false, + }); + + const { container } = render(); + + expect([...container.querySelectorAll(".insight-item-title")].map((node) => node.textContent)) + .toEqual(["Newest insight", "Middle insight", "Oldest insight"]); + }); + it("should render loading state", () => { mockUseInsights.mockReturnValue({ ...mockUseInsights("test"), diff --git a/packages/dashboard/app/hooks/__tests__/useInsights.test.ts b/packages/dashboard/app/hooks/__tests__/useInsights.test.ts index b2a0304f87..7eaebf2e53 100644 --- a/packages/dashboard/app/hooks/__tests__/useInsights.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useInsights.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useInsights, INSIGHT_CATEGORIES } from "../useInsights"; import { SWR_CACHE_KEYS } from "../../utils/swrCache"; -import type { InsightCategory, InsightStatus } from "@fusion/core"; +import type { Insight, InsightCategory, InsightStatus } from "@fusion/core"; // Mock the API module vi.mock("../../api", () => ({ @@ -73,6 +73,23 @@ const mockFetchInsightRun = vi.mocked(fetchInsightRun); const mockFetchInsightRuns = vi.mocked(fetchInsightRuns); const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData); +function makeInsight(overrides: Partial = {}): Insight { + return { + id: "INS-1", + projectId: "project-1", + title: "Insight", + content: "Content", + category: "features", + status: "generated", + fingerprint: "fp-1", + provenance: { trigger: "manual" }, + lastRunId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + describe("useInsights", () => { beforeEach(() => { vi.clearAllMocks(); @@ -197,6 +214,67 @@ describe("useInsights", () => { expect(cached).not.toHaveProperty("dismissStates"); }); + it("sorts API insights newest-first and breaks equal timestamps by descending id", async () => { + mockFetchInsights.mockResolvedValue({ + insights: [ + makeInsight({ id: "INS-OLD", createdAt: "2026-01-01T00:00:00Z" }), + makeInsight({ id: "INS-A", createdAt: "2026-02-01T00:00:00Z" }), + makeInsight({ id: "INS-Z", createdAt: "2026-02-01T00:00:00Z" }), + makeInsight({ id: "INS-NEW", createdAt: "2026-03-01T00:00:00Z" }), + ], + count: 4, + }); + mockFetchInsightRuns.mockResolvedValue({ runs: [] }); + + const { result } = renderHook(() => useInsights("project-1")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.sections.find((section) => section.category === "features")?.items.map((item) => item.id)) + .toEqual(["INS-NEW", "INS-Z", "INS-A", "INS-OLD"]); + }); + + it("sorts cached insights newest-first before the network refresh resolves", async () => { + localStorage.setItem( + `${SWR_CACHE_KEYS.INSIGHTS_PREFIX}project-1`, + JSON.stringify({ + savedAt: Date.now(), + data: [ + makeInsight({ id: "INS-CACHED-OLD", createdAt: "2026-01-01T00:00:00Z" }), + makeInsight({ id: "INS-CACHED-NEW", createdAt: "2026-03-01T00:00:00Z" }), + ], + }), + ); + mockFetchInsights.mockImplementation(() => new Promise(() => {})); + mockFetchInsightRuns.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useInsights("project-1")); + + await waitFor(() => { + expect(result.current.sections.find((section) => section.category === "features")?.items.map((item) => item.id)) + .toEqual(["INS-CACHED-NEW", "INS-CACHED-OLD"]); + }); + }); + + it("sorts malformed createdAt values last without throwing", async () => { + mockFetchInsights.mockResolvedValue({ + insights: [ + makeInsight({ id: "INS-INVALID-FIRST", createdAt: "not-a-date" }), + makeInsight({ id: "INS-VALID", createdAt: "2026-03-01T00:00:00Z" }), + makeInsight({ id: "INS-MISSING", createdAt: undefined as unknown as string }), + ], + count: 3, + }); + mockFetchInsightRuns.mockResolvedValue({ runs: [] }); + + const { result } = renderHook(() => useInsights("project-1")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.sections.find((section) => section.category === "features")?.items.map((item) => item.id)) + .toEqual(["INS-VALID", "INS-INVALID-FIRST", "INS-MISSING"]); + }); + it("caps persisted insight cache at 500", async () => { const oversized = Array.from({ length: 610 }, (_, index) => ({ id: `INS-${index}`, @@ -750,6 +828,32 @@ describe("useInsights", () => { expect(result.current.archivedCount).toBe(0); }); + it("preserves newest-first order after dismissing and showing archived insights", async () => { + const insights = [ + makeInsight({ id: "INS-OLD", createdAt: "2026-01-01T00:00:00Z" }), + makeInsight({ id: "INS-ARCHIVED", status: "archived", createdAt: "2026-02-01T00:00:00Z" }), + makeInsight({ id: "INS-NEW", createdAt: "2026-03-01T00:00:00Z" }), + ]; + mockFetchInsights.mockResolvedValue({ insights, count: insights.length }); + mockFetchInsightRuns.mockResolvedValue({ runs: [] }); + mockDismissInsight.mockResolvedValue({ ...insights[0], status: "dismissed" }); + + const { result } = renderHook(() => useInsights("project-1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.dismiss("INS-OLD"); + }); + expect(result.current.sections.find((section) => section.category === "features")?.items.map((item) => item.id)) + .toEqual(["INS-NEW"]); + + act(() => { + result.current.toggleShowArchived(); + }); + expect(result.current.sections.find((section) => section.category === "features")?.items.map((item) => item.id)) + .toEqual(["INS-NEW", "INS-ARCHIVED"]); + }); + it("toggleShowArchived hides and shows archived insights", async () => { const archivedInsight = { id: "INS-A", diff --git a/packages/dashboard/app/hooks/useInsights.ts b/packages/dashboard/app/hooks/useInsights.ts index 11ad3b1edb..cd144c39db 100644 --- a/packages/dashboard/app/hooks/useInsights.ts +++ b/packages/dashboard/app/hooks/useInsights.ts @@ -72,6 +72,30 @@ export const STATUS_LABELS: Record = { archived: "Archived", }; +/* + * FNXC:InsightsView 2026-08-13-21:58: + * The Insights list must read newest-first. InsightStore.listInsights is contractually + * createdAt ASC for deterministic pagination and dedupe, so presentation order is inverted + * at this single section-derivation seam rather than in the store. Tie-break valid equal + * timestamps by id DESC; malformed timestamps sort last without changing their source order. + */ +function compareInsightsRecentFirst(a: Insight, b: Insight): number { + const aCreatedAt = Date.parse(a.createdAt); + const bCreatedAt = Date.parse(b.createdAt); + const aHasValidCreatedAt = Number.isFinite(aCreatedAt); + const bHasValidCreatedAt = Number.isFinite(bCreatedAt); + + if (!aHasValidCreatedAt || !bHasValidCreatedAt) { + if (aHasValidCreatedAt) return -1; + if (bHasValidCreatedAt) return 1; + return 0; + } + + if (aCreatedAt !== bCreatedAt) return bCreatedAt - aCreatedAt; + if (a.id === b.id) return 0; + return a.id < b.id ? 1 : -1; +} + // Section data structure export interface InsightSection { category: InsightCategory; @@ -347,7 +371,7 @@ export function useInsights(projectId?: string): UseInsightsResult { INSIGHT_CATEGORIES.map((category) => ({ category, label: getCategoryLabel(category), - items: grouped.get(category) ?? [], + items: [...(grouped.get(category) ?? [])].sort(compareInsightsRecentFirst), isLoading: false, error: null, })),