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:
7
.changeset/insights-newest-first.md
Normal file
7
.changeset/insights-newest-first.md
Normal 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.
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user