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();
|
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", () => {
|
it("should render loading state", () => {
|
||||||
mockUseInsights.mockReturnValue({
|
mockUseInsights.mockReturnValue({
|
||||||
...mockUseInsights("test"),
|
...mockUseInsights("test"),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
import { useInsights, INSIGHT_CATEGORIES } from "../useInsights";
|
import { useInsights, INSIGHT_CATEGORIES } from "../useInsights";
|
||||||
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
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
|
// Mock the API module
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
@@ -73,6 +73,23 @@ const mockFetchInsightRun = vi.mocked(fetchInsightRun);
|
|||||||
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
|
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
|
||||||
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
|
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", () => {
|
describe("useInsights", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -197,6 +214,67 @@ describe("useInsights", () => {
|
|||||||
expect(cached).not.toHaveProperty("dismissStates");
|
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 () => {
|
it("caps persisted insight cache at 500", async () => {
|
||||||
const oversized = Array.from({ length: 610 }, (_, index) => ({
|
const oversized = Array.from({ length: 610 }, (_, index) => ({
|
||||||
id: `INS-${index}`,
|
id: `INS-${index}`,
|
||||||
@@ -750,6 +828,32 @@ describe("useInsights", () => {
|
|||||||
expect(result.current.archivedCount).toBe(0);
|
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 () => {
|
it("toggleShowArchived hides and shows archived insights", async () => {
|
||||||
const archivedInsight = {
|
const archivedInsight = {
|
||||||
id: "INS-A",
|
id: "INS-A",
|
||||||
|
|||||||
@@ -72,6 +72,30 @@ export const STATUS_LABELS: Record<InsightStatus, string> = {
|
|||||||
archived: "Archived",
|
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
|
// Section data structure
|
||||||
export interface InsightSection {
|
export interface InsightSection {
|
||||||
category: InsightCategory;
|
category: InsightCategory;
|
||||||
@@ -347,7 +371,7 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
|||||||
INSIGHT_CATEGORIES.map((category) => ({
|
INSIGHT_CATEGORIES.map((category) => ({
|
||||||
category,
|
category,
|
||||||
label: getCategoryLabel(category),
|
label: getCategoryLabel(category),
|
||||||
items: grouped.get(category) ?? [],
|
items: [...(grouped.get(category) ?? [])].sort(compareInsightsRecentFirst),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})),
|
})),
|
||||||
|
|||||||
Reference in New Issue
Block a user