FN-9027: sort insights newest first

Present Insights in newest-first order without changing store ordering.

- Sort each visible category by creation time descending with deterministic ID tie-breaking
- Keep malformed timestamps last and preserve their source order
- Cover network, cached, archive, and rendered ordering behavior
- Add a patch changeset for the published CLI package

Files changed:
 .changeset/insights-newest-first.md                |   7 ++
 .../app/components/__tests__/InsightsView.test.tsx |  41 ++++++++
 .../app/hooks/__tests__/useInsights.test.ts        | 106 ++++++++++++++++++++-
 packages/dashboard/app/hooks/useInsights.ts        |  26 ++++-
 4 files changed, 178 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-9027

Fusion-Task-Lineage: b9fecbeb-b8b8-49b1-b7db-c4dfda81fcbe

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-13 15:12:08 -07:00
parent 8b00bab0b0
commit 958b08e237
4 changed files with 178 additions and 2 deletions

View File

@@ -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.

View File

@@ -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(<InsightsView {...defaultProps} />);
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"),

View File

@@ -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> = {}): 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",

View File

@@ -72,6 +72,30 @@ export const STATUS_LABELS: Record<InsightStatus, string> = {
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,
})),