diff --git a/AGENTS.md b/AGENTS.md index c790995c1..cf931abc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,12 +75,13 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). -Period leftover pairs (ADR 0017 / 0018) are computed in +Period leftover pairs (ADR 0017 / 0018 / 0031) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Missing cells stay out of the Gabriel factorization. Closest and farthest post–criterion pairs persist to `report_leftover_pair` and sit above the member list so -a click opens that post. +a click opens that post. A home post row that is a leftover pair +also names the leftover criterion next to visibility. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a83ceb1..605e76be1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -122,7 +122,8 @@ flowchart LR `rankweave_client.py`'s default transport raises `RankWeaveNotAvailable`. `GET /api/rankings` then returns `rankweave_not_available` and an empty ranking list. Hidden posts - are omitted from every channel. See ADR 0024. + are omitted from every channel. See ADR 0024. A home post row + that is also a leftover pair names that criterion (ADR 0031). ## Standards and citations diff --git a/CHANGELOG.d/0.90.0-leftover-home-post.md b/CHANGELOG.d/0.90.0-leftover-home-post.md new file mode 100644 index 000000000..d06bacb00 --- /dev/null +++ b/CHANGELOG.d/0.90.0-leftover-home-post.md @@ -0,0 +1,8 @@ +# 0.90.0 — Leftover badge on the matching home post row + +## Added + +- Home post rows that are leftover pairs name the leftover + criterion (ADR 0031). After `make seed`, the leftover row reads + **Closest leftover · sales-lead** next to visibility; click + opens that post. A row that is not a leftover pair stays unmarked. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfcaa28f..3dbc297fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.90.0] - 2026-08-18 + +### Added + +- Home post rows that are leftover pairs name the leftover + criterion (ADR 0031). After `make seed`, the leftover row reads + **Closest leftover · sales-lead** next to visibility; click + opens that post. A row that is not a leftover pair stays unmarked. + Never invent a fused score or a theta. + ## [0.75.0] - 2026-08-17 ### Added diff --git a/docs/adr/0031-leftover-pair-on-home-post.md b/docs/adr/0031-leftover-pair-on-home-post.md new file mode 100644 index 000000000..68ed13e10 --- /dev/null +++ b/docs/adr/0031-leftover-pair-on-home-post.md @@ -0,0 +1,52 @@ +# ADR 0031 — Name leftover on the matching home post row + +**Decision status:** Accepted +**Date:** 2026-08-18 + +## Context + +ADR 0018 puts leftover pairs above the period-report member list. +The home post list is the last surface a buyer scans after Rankings, +Calendar, Period reports, and Event Lineage. The leftover post is +already in that list; the row just does not name the leftover +criterion. + +Do not invent a second leftover store. Do not invent a fused score +or a theta. Do not change the existing `View post: {title}` +accessible name — leftover is a visible badge on the already-named +control. + +## Decision + +When an authorized leftover pair names a home post row, that button +shows `Closest leftover · {criterion}` or +`Farthest leftover · {criterion}` next to the visibility badge. + +A row that is not a leftover pair stays unmarked. A leftover pair +for a hidden post never reaches the home list (ADR 0017 ABAC). A +report fetch error clears leftover badges and leaves the post list +intact — never an invented pair. + +After `make seed`, the leftover home row reads **Closest leftover · +sales-lead** next to visibility; click still opens that post. + +Leftover evidence is the same authorized `leftover_pairs` already +on the period-report payload. + +## Consequences + +Leftover buttons above the member list stay (ADR 0018). Calendar +leftover stays on #253 / ADR 0030. Rankings leftover stays on #252 +/ ADR 0029. This slice only labels the already-visible home row. + +## Related + +Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md) and +[ADR 0018](0018-leftover-pair-report-ui.md). + +## References + +Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping +unobserved item–respondent interactions: A latent space item response +model with interaction map. *Psychometrika, 86*(2), 378–403. +https://doi.org/10.1007/s11336-021-09762-5 diff --git a/frontend/package.json b/frontend/package.json index 575b7c586..8f06e6d45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.75.0", + "version": "0.90.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a32a26403..eaad486ae 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -56,6 +56,15 @@ describe("App, authenticated", () => { function stubBackend(options?: { admin?: boolean; calendarCommitments?: unknown[]; + homePosts?: { + post_id: string; + post_title: string; + voc_type_code: string; + voc_type_label?: string; + visibility_code: string; + visibility_label?: string; + created_at: string; + }[]; rankings?: { status?: "accepted" | "unavailable"; status_reason?: string | null; @@ -427,17 +436,19 @@ describe("App, authenticated", () => { } if (url.endsWith("/api/posts")) { return Promise.resolve( - jsonResponse([ - { - post_id: "post-1", - post_title: "Public post", - voc_type_code: "voc", - voc_type_label: "Voice of Customer", - visibility_code: "public", - visibility_label: "Public", - created_at: "2026-01-01T00:00:00Z", - }, - ]), + jsonResponse( + options?.homePosts ?? [ + { + post_id: "post-1", + post_title: "Public post", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2026-01-01T00:00:00Z", + }, + ], + ), ); } if (url.endsWith("/api/posts/post-1")) { @@ -830,6 +841,43 @@ describe("App, authenticated", () => { expect(screen.queryByText("Not yet evaluated.")).not.toBeInTheDocument(); }); + it("names leftover criterion on the matching home post row", async () => { + stubBackend({ + homePosts: [ + { + post_id: "post-1", + post_title: "Public post", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2026-01-01T00:00:00Z", + }, + { + post_id: "post-9", + post_title: "Riverbend calendar commitment", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2026-01-03T00:00:00Z", + }, + ], + }); + render(); + + const leftoverRow = await screen.findByRole("button", { name: "View post: Public post" }); + await waitFor(() => expect(leftoverRow).toHaveTextContent("Closest leftover · sales-lead")); + expect(leftoverRow).toHaveAccessibleName("View post: Public post"); + const unmarked = screen.getByRole("button", { name: "View post: Riverbend calendar commitment" }); + expect(unmarked).toHaveTextContent("Voice of Customer"); + expect(unmarked).not.toHaveTextContent("leftover"); + + await userEvent.click(leftoverRow); + + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + }); + it("rebuilds lineage when the account has post_admin", async () => { const fetchMock = stubBackend({ admin: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6056e5eb4..5b9dcd51c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { type PostSummary, type RankingList, type RelatedNode, + type LeftoverPair, type VocEvidence, } from "./api"; import { LineageDag } from "./LineageDag"; @@ -82,6 +83,10 @@ function criterionShortLabel(itemCode: string): string { return CRITERION_SHORT_LABEL[itemCode] ?? itemCode; } +function leftoverRowLabel(pairKind: string): string { + return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover"; +} + // This popup's layout follows the textual product brief (Korean summary, // key events, R&R, Event Lineage, Keyman, in-popup chat with a sliding // evidence panel) rather than the referenced Figma frame's actual pixel @@ -1425,10 +1430,12 @@ function ReportsPanel({ accessToken, canRebuild, onSelectPost, + onLeftoverPairsChange, }: { accessToken: string; canRebuild: boolean; onSelectPost: (postId: string) => void; + onLeftoverPairsChange: (pairs: LeftoverPair[]) => void; }) { const [grouping, setGrouping] = useState("process_unit"); const [period, setPeriod] = useState("2026-W02"); @@ -1455,9 +1462,13 @@ function ReportsPanel({ setPayload(reports); setIndex(periods); setComparison(compared); + onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? [])); }) - .catch((err) => setError(String(err))); - }, [accessToken, grouping, period]); + .catch((err) => { + setError(String(err)); + onLeftoverPairsChange([]); + }); + }, [accessToken, grouping, period, onLeftoverPairsChange]); async function handleRebuild() { setRebuilding(true); @@ -1472,8 +1483,10 @@ function ReportsPanel({ setPayload(reports); setIndex(periods); setComparison(compared); + onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? [])); } catch (err) { setError(String(err)); + onLeftoverPairsChange([]); } finally { setRebuilding(false); } @@ -1658,6 +1671,7 @@ function PostList({ accessToken }: { accessToken: string }) { const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [selectedPostId, setSelectedPostId] = useState(null); + const [leftoverPairs, setLeftoverPairs] = useState([]); const [canRebuild, setCanRebuild] = useState(false); const [rebuilding, setRebuilding] = useState(false); const [rebuildError, setRebuildError] = useState(null); @@ -1691,7 +1705,12 @@ function PostList({ accessToken }: { accessToken: string }) { <> - +

Event Lineage

@@ -1706,7 +1725,9 @@ function PostList({ accessToken }: { accessToken: string }) { {graph && }
    - {posts.map((post) => ( + {posts.map((post) => { + const leftoverForPost = leftoverPairs.filter((pair) => pair.post_id === post.post_id); + return (
  • - ))} + ); + })}
{selectedPostId && (