Feature/product platform foundation v0.62 #1

Merged
robbond merged 683 commits from feature/product-platform-foundation-v0.62 into feature/emergent-unknowns-v0.5 2026-09-09 07:58:20 +01:00
3 changed files with 473 additions and 91 deletions
Showing only changes of commit df142ca76c - Show all commits
+82 -91
View File
@@ -1,30 +1,19 @@
"use client";
import React from "react";
import { loadInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage";
import { listInvestigations, clearInvestigation } from "@/lib/storage/investigation-storage";
import Link from "next/link";
import { useRouter } from "next/navigation";
const INVESTIGATION_ID = "case-1";
function Portfolio() {
const router = useRouter();
const [existing, setExisting] = React.useState(null);
const [summaries, setSummaries] = React.useState([]);
const [showRestartConfirm, setShowRestartConfirm] = React.useState(false);
React.useEffect(() => {
setExisting(loadInvestigation());
setSummaries(listInvestigations());
}, []);
const hasReport = Boolean(
existing?.investigationReport && existing.investigationReport.understanding
);
const reportIsCurrent =
hasReport &&
existing.investigationReport.generatedFromRevision ===
(existing.investigationRevision ?? 0);
return (
<main className="mx-auto max-w-[640px] px-6 py-16">
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
@@ -32,100 +21,102 @@ function Portfolio() {
Investigator&apos;s notebook index of persisted investigations.
</p>
{/* Existing investigation */}
{existing && (
{/* Investigation collection */}
{summaries.length > 0 && (
<section className="mb-10">
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
Investigations
</h2>
<div className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-6 shadow-sm">
<p className="text-sm text-gray-700">
{existing.scenario || "Untitled investigation"}
</p>
{summaries.map((summary) => (
<div key={summary.id} className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-6 shadow-sm">
<p className="text-sm text-gray-700">
{summary.scenario || "Untitled investigation"}
</p>
<div className="mt-4 flex items-start gap-3 text-sm">
{hasReport ? (
<div className="flex flex-col gap-1">
<Link
href={`/investigations/${INVESTIGATION_ID}/report`}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
View report
</Link>
<div className="mt-4 flex items-start gap-3 text-sm">
{summary.reportExists ? (
<div className="flex flex-col gap-1">
<Link
href={`/investigations/${summary.id}/report`}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
View report
</Link>
{reportIsCurrent ? (
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">
Current
</span>
) : (
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">
Update available
</span>
)}
</div>
) : null}
{summary.reportGeneratedFromRevision === summary.investigationRevision ? (
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">
Current
</span>
) : (
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">
Update available
</span>
)}
</div>
) : null}
<Link
href={`/investigations/${INVESTIGATION_ID}`}
className="self-start rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
Continue investigation
</Link>
<button
onClick={() => setShowRestartConfirm(true)}
className="self-start rounded-lg border border-red-400 bg-white px-4 py-2 font-medium text-red-700 hover:bg-red-50 transition"
>
Restart investigation
</button>
{showRestartConfirm && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="restart-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setShowRestartConfirm(false)}
<Link
href={`/investigations/${summary.id}`}
className="self-start rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
>
Continue investigation
</Link>
<button
onClick={() => setShowRestartConfirm(summary.id)}
className="self-start rounded-lg border border-red-400 bg-white px-4 py-2 font-medium text-red-700 hover:bg-red-50 transition"
>
Restart investigation
</button>
{showRestartConfirm === summary.id && (
<div
className="w-[420px] rounded-xl border border-gray-200 bg-white p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={`restart-title-${summary.id}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setShowRestartConfirm(null)}
>
<h2 id="restart-title" className="mb-3 text-lg font-semibold">
Restart this investigation?
</h2>
<p className="mb-5 text-sm text-gray-600">
Your current investigation, findings, clarified questions, and report will be lost. Are you sure you want to continue?
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowRestartConfirm(false)}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
Cancel
</button>
<button
onClick={() => {
setShowRestartConfirm(false);
try { clearInvestigation(); } catch (_) { /* storage must not crash caller */ }
setExisting(null);
}}
className="rounded-lg border border-red-400 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 transition"
>
Restart investigation
</button>
<div
className="w-[420px] rounded-xl border border-gray-200 bg-white p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h2 id={`restart-title-${summary.id}`} className="mb-3 text-lg font-semibold">
Restart this investigation?
</h2>
<p className="mb-5 text-sm text-gray-600">
Your current investigation, findings, clarified questions, and report will be lost. Are you sure you want to continue?
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowRestartConfirm(null)}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
Cancel
</button>
<button
onClick={() => {
setShowRestartConfirm(null);
try { clearInvestigation(summary.id); } catch (_) { /* storage must not crash caller */ }
setSummaries(listInvestigations());
}}
className="rounded-lg border border-red-400 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 transition"
>
Restart investigation
</button>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
))}
</section>
)}
{/* No existing investigation */}
{!existing && (
{/* No investigations */}
{summaries.length === 0 && (
<section className="mb-10">
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
Investigations
+33
View File
@@ -516,6 +516,39 @@ v0.60g2: Migrate Portfolio to consume `listInvestigations()` for collection rend
Smallest next increment: implement the four-operation storage contract in `lib/storage/investigation-storage.js` as a re-export of a provider-backed interface whose signatures accept/return domain Investigation objects keyed by durable ID — without committing to any specific localStorage or database representation. This means defining the exported function signatures and the Investigation shape that flows through them, while deferring key scheme, row schema, and collection structure to a later implementation decision.
## v0.60g2 — Render Investigation Portfolio
**Purpose:** Migrate Portfolio to consume `listInvestigations()` for collection rendering — replace the hardcoded singleton card with a rendered list of Investigation summaries.
### What was implemented
| File | Change |
|---|---|
| `app/page.jsx` | Portfolio consumes `listInvestigations()` instead of legacy singleton; renders investigation cards from the persisted list; Create New allocates durable ID via `crypto.randomUUID()` + navigates to `/investigations/{id}` |
| `tests/ui/v060g2-portfolio-collection.test.jsx` | Dedicated UI test covering: two distinct Investigations persist independently → Portfolio lists both → cards show correct IDs/scenarios/freshness → Create New allocates ID and navigates |
### Deterministic evidence
- Test file: `tests/ui/v060g2-portfolio-collection.test.jsx`
- First run result: **24/24 PASS** (no reruns)
- Build: **PASS**
### Live verification
Rob manually verified: created a second genuine durable-ID Investigation → returned to Portfolio → two distinct persisted Investigation cards visible → Create New visible → no case-1 card presentation.
### Known defect discovered during live verification
A separate pre-existing Report identity defect was discovered during live verification of v0.60g2. The Report route does not read its `[id]` parameter — it calls `loadInvestigation()` without an ID and therefore reads the legacy singleton path, which returns `null` for durable-ID Investigations. This is classified as:
**PRIMARY CLASSIFICATION: A — REPORT IDENTITY DEFECT**
This defect will be addressed in a separate increment (v0.60h). It is NOT caused by empty-Done semantics.
### Next restart point
v0.60h: Migrate Report route to use route `[id]` for all identity operations (initial load, generation, update). Preserve existing v0.58/v0.59 Report lifecycle and freshness semantics. This is an identity migration only — no Report redesign.
## Next restart point
Consult `docs/design-evolution/README.md` for progressive loading of product reasoning and provenance chronology; load the relevant chapter only when a specific historical question requires it.
@@ -0,0 +1,358 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import React from "react";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
// ---------------------------------------------------------------------------
// Mock next/navigation — static file-level mock
// ---------------------------------------------------------------------------
let pushRef = { push: () => {} };
vi.mock("next/navigation", () => ({
useRouter: () => pushRef,
}));
// ---------------------------------------------------------------------------
// Mock investigation-storage — application-facing contract only
// No localStorage. No provider imports. No module-cache manipulation.
// ---------------------------------------------------------------------------
let mockList = [];
let mockClearTarget = null;
vi.mock("@/lib/storage/investigation-storage", () => ({
listInvestigations: () => [...mockList],
clearInvestigation: (id) => {
mockClearTarget = id;
},
}));
function setMockSummaries(snapshots) {
mockList = snapshots ?? [];
}
// ---------------------------------------------------------------------------
// Test fixture data — plain InvestigationSummary arrays
// ---------------------------------------------------------------------------
function summaryA() {
return {
id: "inv-a",
scenario: "Scenario A",
updatedAt: "2026-09-03T10:00:00.000Z",
investigationRevision: 2,
reportExists: true,
reportGeneratedFromRevision: 2,
};
}
function summaryB() {
return {
id: "inv-b",
scenario: "Scenario B",
updatedAt: "2026-09-02T10:00:00.000Z",
investigationRevision: 3,
reportExists: true,
reportGeneratedFromRevision: 2,
};
}
function summaryNoReport() {
return {
id: "inv-no-report",
scenario: "Scenario No Report",
updatedAt: "2026-09-01T10:00:00.000Z",
investigationRevision: 1,
reportExists: false,
reportGeneratedFromRevision: null,
};
}
// ---------------------------------------------------------------------------
// A — zero summaries
// ---------------------------------------------------------------------------
describe("v0.60g2 — zero summaries", () => {
let Portfolio;
beforeEach(async () => {
pushRef.push = vi.fn();
setMockSummaries([]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it('shows "No investigations yet."', async () => {
render(React.createElement(Portfolio));
expect(await screen.findByText(/No investigations yet\./i)).toBeInTheDocument();
});
it("does not show any Continue links", async () => {
render(React.createElement(Portfolio));
expect(screen.queryByText(/Continue investigation/i)).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// B — one summary
// ---------------------------------------------------------------------------
describe("v0.60g2 — one summary", () => {
let Portfolio;
beforeEach(async () => {
pushRef.push = vi.fn();
setMockSummaries([summaryA()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it("renders exactly one Investigation card", async () => {
render(React.createElement(Portfolio));
const section = await screen.findByRole("heading", { name: /investigations/i, level: 2 });
// Card renders as a div with border within the section — verify via scenario text
expect(screen.getByText(/Scenario A/)).toBeInTheDocument();
expect(screen.queryByText(/Scenario B/)).not.toBeInTheDocument();
});
it("renders Continue investigation link for the card", async () => {
render(React.createElement(Portfolio));
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
});
it("Continue links to /investigations/{own-id}", async () => {
pushRef.push = vi.fn();
render(React.createElement(Portfolio));
const continueLink = await screen.findByRole("link", { name: /Continue investigation/i });
expect(continueLink).toHaveAttribute("href", "/investigations/inv-a");
});
it("renders View report link when reportExists is true", async () => {
render(React.createElement(Portfolio));
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
});
it("View report links to /investigations/{own-id}/report", async () => {
pushRef.push = vi.fn();
render(React.createElement(Portfolio));
const viewLink = await screen.findByRole("link", { name: /View report/i });
expect(viewLink).toHaveAttribute("href", "/investigations/inv-a/report");
});
it('shows "Current" when revisions match', async () => {
render(React.createElement(Portfolio));
expect(await screen.findByText(/Current/i)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// C — two summaries
// ---------------------------------------------------------------------------
describe("v0.60g2 — two summaries", () => {
let Portfolio;
beforeEach(async () => {
pushRef.push = vi.fn();
setMockSummaries([summaryA(), summaryB()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it("renders both scenarios", async () => {
render(React.createElement(Portfolio));
expect(screen.getByText(/Scenario A/)).toBeInTheDocument();
expect(screen.getByText(/Scenario B/)).toBeInTheDocument();
});
it("renders two distinct Continue investigation links", async () => {
render(React.createElement(Portfolio));
const continueLinks = screen.getAllByText(/Continue investigation/i);
expect(continueLinks).toHaveLength(2);
});
it("first Continue href contains inv-a", async () => {
render(React.createElement(Portfolio));
const links = document.querySelectorAll('a[href]');
const continueHrefs = Array.from(links)
.filter((l) => l.textContent.includes("Continue investigation"))
.map((l) => l.getAttribute("href"));
expect(continueHrefs).toContain("/investigations/inv-a");
});
it("second Continue href contains inv-b", async () => {
render(React.createElement(Portfolio));
const links = document.querySelectorAll('a[href]');
const continueHrefs = Array.from(links)
.filter((l) => l.textContent.includes("Continue investigation"))
.map((l) => l.getAttribute("href"));
expect(continueHrefs).toContain("/investigations/inv-b");
});
it("preserves listing order (A before B)", async () => {
render(React.createElement(Portfolio));
const cards = document.querySelectorAll('[class*="bg-gradient-to-b"]');
expect(cards).toHaveLength(2);
expect(cards[0]).toContainElement(screen.getByText(/Scenario A/));
expect(cards[1]).toContainElement(screen.getByText(/Scenario B/));
});
});
// ---------------------------------------------------------------------------
// D — per-card Report link
// ---------------------------------------------------------------------------
describe("v0.60g2 — per-card Report visibility", () => {
let Portfolio;
beforeEach(async () => {
pushRef.push = vi.fn();
setMockSummaries([summaryNoReport()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it("does not show View report when reportExists is false", async () => {
render(React.createElement(Portfolio));
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
});
it('still shows Continue investigation when no report', async () => {
render(React.createElement(Portfolio));
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
});
it("does not show freshness label when no report", async () => {
render(React.createElement(Portfolio));
expect(screen.queryByText(/Current/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Update available/i)).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// E — per-card freshness
// ---------------------------------------------------------------------------
describe("v0.60g2 — per-card freshness", () => {
let Portfolio;
it('shows "Current" when revisions match', async () => {
setMockSummaries([summaryA()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
render(React.createElement(Portfolio));
expect(await screen.findByText(/Current/i)).toBeInTheDocument();
});
it('shows "Update available" when revisions differ', async () => {
setMockSummaries([summaryB()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
render(React.createElement(Portfolio));
expect(await screen.findByText(/Update available/i)).toBeInTheDocument();
});
it("shows neither when reportExists is false", async () => {
setMockSummaries([summaryNoReport()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
render(React.createElement(Portfolio));
expect(screen.queryByText(/Current/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Update available/i)).not.toBeInTheDocument();
});
it("per-card labels are independent", async () => {
setMockSummaries([summaryA(), summaryB()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
render(React.createElement(Portfolio));
expect(screen.getByText(/Current/i)).toBeInTheDocument();
expect(screen.getByText(/Update available/i)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// F — no singleton routing (case-1 must never appear in persisted card hrefs)
// ---------------------------------------------------------------------------
describe("v0.60g2 — no singleton routing", () => {
let Portfolio;
beforeEach(async () => {
pushRef.push = vi.fn();
setMockSummaries([summaryA(), summaryB()]);
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it("no persisted card href contains case-1", async () => {
render(React.createElement(Portfolio));
const links = document.querySelectorAll('a[href]');
const allHrefs = Array.from(links).map((l) => l.getAttribute("href"));
for (const href of allHrefs) {
expect(href).not.toContain("case-1");
}
});
});
// ---------------------------------------------------------------------------
// G — Create New regression
// ---------------------------------------------------------------------------
describe("v0.60g2 — Create New regression", () => {
let Portfolio;
let cryptoRandomUUID;
beforeEach(async () => {
cryptoRandomUUID = vi.fn();
pushRef.push = vi.fn();
setMockSummaries([]);
Object.defineProperty(global, "crypto", {
value: { randomUUID: cryptoRandomUUID },
writable: true,
});
const mod = await import("@/app/page.jsx");
Portfolio = mod.default;
});
it("Create New remains visible even with summaries", async () => {
setMockSummaries([summaryA()]);
const mod2 = await import("@/app/page.jsx");
Portfolio = mod2.default;
render(React.createElement(Portfolio));
expect(await screen.findByText(/Create new investigation/i)).toBeInTheDocument();
});
it("Create New allocates fixed UUID and navigates", async () => {
setMockSummaries([]);
cryptoRandomUUID.mockReturnValue("11111111-2222-4333-8444-555555555555");
const mod2 = await import("@/app/page.jsx");
Portfolio = mod2.default;
render(React.createElement(Portfolio));
const createBtn = await screen.findByRole("button", { name: /Create new investigation/i });
fireEvent.click(createBtn);
expect(cryptoRandomUUID).toHaveBeenCalledTimes(1);
expect(pushRef.push).toHaveBeenCalledWith("/investigations/11111111-2222-4333-8444-555555555555");
});
it("Create New does not invoke any storage save", async () => {
setMockSummaries([]);
cryptoRandomUUID.mockReturnValue("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
const mod2 = await import("@/app/page.jsx");
Portfolio = mod2.default;
// Replace clearInvestigation mock to also detect any listInvestigations call count change
let originalListLength = mockList.length;
render(React.createElement(Portfolio));
const createBtn = await screen.findByRole("button", { name: /Create new investigation/i });
fireEvent.click(createBtn);
// The mock list should not have changed (no save happens)
expect(mockList).toHaveLength(originalListLength);
});
});