From 4d43a873acaff444feb6d10d40e4849cde4540a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:16:50 +0000 Subject: [PATCH 01/23] feat: open Customer master related posts onto Event Lineage (v2.15.0) Customer master names authorized customer entities as current and to open a related post to read Event Lineage. That open focuses the popup Event Lineage heading and names Keyman and evaluation next. Home-list opens do not. No TEPP theta or customer is invented. --- AGENTS.md | 2 + ARCHITECTURE.md | 5 +- ...15.0-customer-master-open-event-lineage.md | 5 ++ CHANGELOG.md | 10 +++ CLAUDE.md | 7 ++ ...tomer-master-open-focuses-event-lineage.md | 35 ++++++++++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 68 +++++++++++++++++++ frontend/src/App.tsx | 31 ++++++++- frontend/src/i18n.test.ts | 1 + frontend/src/i18n.ts | 8 +++ pyproject.toml | 2 +- 12 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md create mode 100644 docs/adr/0095-customer-master-open-focuses-event-lineage.md diff --git a/AGENTS.md b/AGENTS.md index 0562c531b..3ec1221b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,8 @@ Opening that filtered post focuses Event Lineage (ADR 0093). Do not invent a week, a theta, or a cutoff body. Opening a Calendar commitment uses the same focus path (ADR 0094). Do not invent a week, a theta, a cutoff body, or a CalDAV event. +Opening a Customer master related post uses the same focus path (ADR 0095). +Do not invent a week, a theta, a cutoff body, a CalDAV event, or a customer. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f5ee1be41..78a4886e3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -282,8 +282,9 @@ Keycloak issued; `src/App.tsx` renders a git-branch SVG of `GET /api/lineage` (click a node to open that post; `post_admin` can rebuild), the post list with a named Weekly VOC ISO-8601 week filter (ADR 0092; opening that filtered post focuses Event Lineage, ADR 0093), -Calendar commitments use the same Event Lineage focus path (ADR 0094), and -the full detail popup includes Korean +Calendar commitments use the same Event Lineage focus path (ADR 0094), +Customer master related posts use the same Event Lineage focus path +(ADR 0095), and the full detail popup includes Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + diff --git a/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md new file mode 100644 index 000000000..7b67dcfe9 --- /dev/null +++ b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md @@ -0,0 +1,5 @@ +# 2.15.0 Opening a Customer master related post focuses Event Lineage + +Customer master names authorized customer entities as current and to open a +related post to read Event Lineage. That open focuses the popup Event Lineage +heading. Home-list opens do not. No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index e4432d7b3..7b5574c67 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). +## [2.15.0] - 2026-08-19 + +### Added + +- Opening a Customer master related post now focuses Event Lineage and names + Keyman and evaluation as the next read. Customer master names authorized + customer entities as current before that open. Home-list opens do not add + that focus or copy. No TEPP theta is invented. No customer is invented +(ADR 0095 / ADR 0037 / ADR 0016). + ## [2.14.0] - 2026-08-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 40e173ead..a332cb5fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,3 +85,10 @@ Open Calendar. Authorized commitments are current. Open a commitment: Event Lineage takes focus and names Keyman and evaluation next (ADR 0094). A home-list open does not. Do not invent a theta or a CalDAV event. + +## Customer master open (v2.15.0) + +Open Customer master. Authorized customer entities are current. Open a +related post: Event Lineage takes focus and names Keyman and evaluation +next (ADR 0095). A home-list open does not. Do not invent a theta or a +customer. diff --git a/docs/adr/0095-customer-master-open-focuses-event-lineage.md b/docs/adr/0095-customer-master-open-focuses-event-lineage.md new file mode 100644 index 000000000..ab9e55f9f --- /dev/null +++ b/docs/adr/0095-customer-master-open-focuses-event-lineage.md @@ -0,0 +1,35 @@ +# ADR 0095: Opening a Customer master related post focuses Event Lineage + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Board Weekly VOC and Calendar commitment opens already focus Event Lineage +(ADR 0093 / ADR 0094). Customer master is the remaining buyer GNB destination +that opens an authorized related post. That open was a home-list open: the +popup body appeared and Event Lineage did not take focus. + +## Decision + +Opening a related post on Customer master is a `fromCustomerMaster` open. +That open reuses the Event Lineage focus path used by report-member, +Weekly VOC, and Calendar opens: + +- Customer master names the next action: authorized customer entities are + current; open a related post to read Event Lineage. +- The popup Event Lineage heading takes focus. +- The popup names the opened post as current in Event Lineage and tells + the buyer to read Keyman and evaluation next. + +A Board home-list open does not focus Event Lineage and does not add that +copy. A `?post=` deep link is still a home-list open. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). Customer +master does not invent a customer or a parent (ADR 0037 / ADR 0010). + +## Consequences + +- Customer master, Calendar, Weekly VOC, and report-member opens share one + focus contract. +- Closing the popup clears the Customer master open flag. diff --git a/frontend/package.json b/frontend/package.json index c6a4389de..f499c9871 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.14.0", + "version": "2.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 3f8450c75..68a169578 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -770,6 +770,24 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/customer-master")) { + return Promise.resolve( + jsonResponse({ + corporate_entities: [ + { + corporate_entity_id: "corp-demo", + entity_name: "Demo Corp", + corporate_entity_code: "DEMO-CORP-01", + entity_level_code: "company", + parent_entity_id: null, + }, + ], + keymen: [], + source_customer_hints: [], + source_author_hints: [], + }), + ); + } if (url.endsWith("/api/rankings")) { const rankings = options?.rankings ?? { status: "unavailable" as const, @@ -1355,6 +1373,26 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/corporate-entities/corp-demo/related")) { + return Promise.resolve( + jsonResponse({ + corporate_entity_id: "corp-demo", + entity_name: "Demo Corp", + related: [ + { + node_id: "post-1", + node_type_code: "node_post", + ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + ontology_label: "Post", + label: "Public post", + relevance: 0.8, + post_body_excerpt: "The full body text.", + post_body_truncated: false, + }, + ], + }), + ); + } if (url.endsWith("/api/posts/post-1/affiliate-tree")) { return Promise.resolve( jsonResponse({ @@ -1895,6 +1933,36 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("opening a Customer master related post focuses Event Lineage; a home list open does not", async () => { + stubBackend(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + const customers = await screen.findByRole("region", { name: "Customer master" }); + expect(within(customers).getByLabelText("Next action")).toHaveTextContent( + "Authorized customer entities are current. Open a related post to read Event Lineage.", + ); + await userEvent.click(within(customers).getByRole("button", { name: /Demo Corp/ })); + await userEvent.click( + await within(customers).findByRole("button", { name: "Open related post: Public post" }), + ); + + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Public post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + const boardAfterCustomer = screen.getByRole("region", { name: "Board" }); + await userEvent.click( + within(boardAfterCustomer).getByRole("button", { name: "View post: Public post" }), + ); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2b2f8087..52185d67b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2472,6 +2472,7 @@ type SelectPostOptions = { fromReportMember?: boolean; fromWeeklyVoc?: boolean; fromCalendar?: boolean; + fromCustomerMaster?: boolean; }; /** @@ -3515,12 +3516,14 @@ function PostList({ showLabPanels = false, postIdToOpen = null, postOpenFromCalendar = false, + postOpenFromCustomerMaster = false, onPostOpened, }: { accessToken: string; showLabPanels?: boolean; postIdToOpen?: string | null; postOpenFromCalendar?: boolean; + postOpenFromCustomerMaster?: boolean; onPostOpened?: () => void; }) { const [posts, setPosts] = useState(null); @@ -3541,6 +3544,7 @@ function PostList({ const [openedFromReportMember, setOpenedFromReportMember] = useState(false); const [openedFromWeeklyVoc, setOpenedFromWeeklyVoc] = useState(false); const [openedFromCalendar, setOpenedFromCalendar] = useState(false); + const [openedFromCustomerMaster, setOpenedFromCustomerMaster] = useState(false); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3596,13 +3600,17 @@ function PostList({ setOpenedFromReportMember(Boolean(options?.fromReportMember)); setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); setOpenedFromCalendar(Boolean(options?.fromCalendar)); + setOpenedFromCustomerMaster(Boolean(options?.fromCustomerMaster)); } useEffect(() => { if (!postIdToOpen) return; - selectPost(postIdToOpen, postOpenFromCalendar ? { fromCalendar: true } : undefined); + selectPost(postIdToOpen, { + fromCalendar: postOpenFromCalendar, + fromCustomerMaster: postOpenFromCustomerMaster, + }); onPostOpened?.(); - }, [onPostOpened, postIdToOpen, postOpenFromCalendar]); + }, [onPostOpened, postIdToOpen, postOpenFromCalendar, postOpenFromCustomerMaster]); function closeSelectedPost() { setSelectedPostId(null); @@ -3611,6 +3619,7 @@ function PostList({ setOpenedFromReportMember(false); setOpenedFromWeeklyVoc(false); setOpenedFromCalendar(false); + setOpenedFromCustomerMaster(false); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -4076,7 +4085,12 @@ function PostList({ openedAfterCutoff ? analysisRunOpenedBodyWarning(openedCutoffIso) : null } knowledgeCutoff={openedAfterCutoff ? openedCutoffIso : null} - focusEventLineage={openedFromReportMember || openedFromWeeklyVoc || openedFromCalendar} + focusEventLineage={ + openedFromReportMember || + openedFromWeeklyVoc || + openedFromCalendar || + openedFromCustomerMaster + } focusAskOnLand={openedFromReportMember} onClose={closeSelectedPost} onSelectPost={selectPost} @@ -4310,6 +4324,11 @@ function CustomerMasterPanel({

{t("Authorized customer scope")}

{t("Customer master")}

{t("Customer entities available to this account.")}

+ {master && master.corporate_entities.length > 0 ? ( +

+ {t("Authorized customer entities are current. Open a related post to read Event Lineage.")} +

+ ) : null} {error ?

{error}

: null} {master === null && !error ?

{t("Loading customer master...")}

: null} {master?.corporate_entities.length === 0 ? ( @@ -4570,6 +4589,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean return new URLSearchParams(window.location.search).get("post"); }); const [postOpenFromCalendar, setPostOpenFromCalendar] = useState(false); + const [postOpenFromCustomerMaster, setPostOpenFromCustomerMaster] = useState(false); // Test-only compatibility for legacy analysis-panel coverage; this prop // never forces the panels open outside Vitest. In a real build the // advanced-review section (ADR 0037) is gated on PostList's own @@ -4650,9 +4670,11 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean showLabPanels={testOnlyLabPanels} postIdToOpen={postToOpen} postOpenFromCalendar={postOpenFromCalendar} + postOpenFromCustomerMaster={postOpenFromCustomerMaster} onPostOpened={() => { setPostToOpen(null); setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(false); }} /> ) : null} @@ -4661,6 +4683,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean accessToken={accessToken} onOpenPost={(postId) => { setPostToOpen(postId); + setPostOpenFromCalendar(false); + setPostOpenFromCustomerMaster(true); setDestination("board"); }} /> @@ -4672,6 +4696,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean onSelectPost={(postId) => { setPostToOpen(postId); setPostOpenFromCalendar(true); + setPostOpenFromCustomerMaster(false); setDestination("board"); }} /> diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 9fa4096d7..fd9341fc2 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -39,6 +39,7 @@ describe("i18n", () => { "Filter by ISO week", "All weeks", "Authorized commitments are current. Open a commitment to read Event Lineage.", + "Authorized customer entities are current. Open a related post to read Event Lineage.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 4158b90cf..ab915b847 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -126,6 +126,8 @@ const TRANSLATIONS: Partial>> = { "{week} Voice of Customer 글이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 글을 여세요.", "Authorized commitments are current. Open a commitment to read Event Lineage.": "권한이 있는 일정이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 일정을 여세요.", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "권한이 있는 고객 엔터티가 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 관련 글을 여세요.", "Filter by visibility": "공개 여부로 필터", "Sort posts": "글 정렬", "All VOC types": "모든 VOC 유형", @@ -457,6 +459,8 @@ const TRANSLATIONS: Partial>> = { "{week} 的 Voice of Customer 文章为当前内容。打开一篇文章阅读事件谱系。", "Authorized commitments are current. Open a commitment to read Event Lineage.": "已授权承诺为当前内容。打开一项承诺阅读事件谱系。", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "已授权客户实体为当前内容。打开一篇相关文章阅读事件谱系。", "Filter by visibility": "按公开状态筛选", "Sort posts": "排序文章", "All VOC types": "所有 VOC 类型", @@ -811,6 +815,8 @@ const TRANSLATIONS: Partial>> = { "{week}のVoice of Customer投稿が現在表示されています。イベント系譜を読むには投稿を開いてください。", "Authorized commitments are current. Open a commitment to read Event Lineage.": "権限のある約束が現在表示されています。イベント系譜を読むには約束を開いてください。", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "権限のある顧客エンティティが現在表示されています。イベント系譜を読むには関連投稿を開いてください。", "Filter by visibility": "公開状態で絞り込み", "Sort posts": "投稿を並べ替え", "All VOC types": "すべての VOC 種類", @@ -1141,6 +1147,8 @@ const TRANSLATIONS: Partial>> = { "Các bài Voice of Customer của {week} đang hiện tại. Hãy mở một bài để đọc Dòng sự kiện.", "Authorized commitments are current. Open a commitment to read Event Lineage.": "Các cam kết được phép đang hiện tại. Hãy mở một cam kết để đọc Dòng sự kiện.", + "Authorized customer entities are current. Open a related post to read Event Lineage.": + "Các thực thể khách hàng được cấp quyền đang hiện tại. Hãy mở một bài liên quan để đọc Dòng sự kiện.", "Filter by visibility": "Lọc theo trạng thái hiển thị", "Sort posts": "Sắp xếp bài viết", "All VOC types": "Tất cả loại VOC", diff --git a/pyproject.toml b/pyproject.toml index bf49f64f4..c94310c83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.14.0" +version = "2.15.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From 2e2449df0ee33123f64555c1ec02b429c4335573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 10:54:59 +0900 Subject: [PATCH 02/23] test: keep customer master router specific --- frontend/src/App.test.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 68a169578..1e7c861c8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -770,24 +770,6 @@ describe("App, authenticated", () => { }), ); } - if (url.endsWith("/api/customer-master")) { - return Promise.resolve( - jsonResponse({ - corporate_entities: [ - { - corporate_entity_id: "corp-demo", - entity_name: "Demo Corp", - corporate_entity_code: "DEMO-CORP-01", - entity_level_code: "company", - parent_entity_id: null, - }, - ], - keymen: [], - source_customer_hints: [], - source_author_hints: [], - }), - ); - } if (url.endsWith("/api/rankings")) { const rankings = options?.rankings ?? { status: "unavailable" as const, From 0c490d98009f96d399e927543f5891abeb43036e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:05:48 +0900 Subject: [PATCH 03/23] fix(ui): preserve Customer master loading ownership --- frontend/src/App.test.tsx | 58 +++++++++++++++++++++++++++++++++++++-- frontend/src/App.tsx | 2 +- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1e7c861c8..8a6c6ed74 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -87,6 +87,7 @@ describe("App, authenticated", () => { postBody?: string; manyCustomerHints?: number; customerEntityHierarchy?: boolean; + deferCustomerRelated?: boolean; boardPosts?: { post_id: string; post_title: string; @@ -96,7 +97,11 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }[]; - }): ReturnType & { releaseMe: () => void } { + }): ReturnType & { + releaseMe: () => void; + releaseGroupRelated: () => void; + releaseDemoRelated: () => void; + } { const statusLabel: Record = { open: "Open", in_progress: "In progress", @@ -127,6 +132,18 @@ describe("App, authenticated", () => { releaseMe = resolve; }) : Promise.resolve(); + let releaseGroupRelated = () => {}; + let releaseDemoRelated = () => {}; + const groupRelatedReady = options?.deferCustomerRelated + ? new Promise((resolve) => { + releaseGroupRelated = resolve; + }) + : Promise.resolve(); + const demoRelatedReady = options?.deferCustomerRelated + ? new Promise((resolve) => { + releaseDemoRelated = resolve; + }) + : Promise.resolve(); const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -1355,8 +1372,17 @@ describe("App, authenticated", () => { }), ); } + if (url.endsWith("/api/corporate-entities/corp-group/related")) { + return groupRelatedReady.then(() => + jsonResponse({ + corporate_entity_id: "corp-group", + entity_name: "Demo Group", + related: [], + }), + ); + } if (url.endsWith("/api/corporate-entities/corp-demo/related")) { - return Promise.resolve( + return demoRelatedReady.then(() => jsonResponse({ corporate_entity_id: "corp-demo", entity_name: "Demo Corp", @@ -1653,7 +1679,11 @@ describe("App, authenticated", () => { return Promise.reject(new Error(`unexpected fetch: ${method} ${url}`)); }); vi.stubGlobal("fetch", fetchMock); - return Object.assign(fetchMock, { releaseMe }); + return Object.assign(fetchMock, { + releaseMe, + releaseGroupRelated, + releaseDemoRelated, + }); } it("renders safe Ask Agent evidence under each cited post", async () => { @@ -1945,6 +1975,28 @@ describe("App, authenticated", () => { expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); }); + it("keeps the current Customer master loading state when an older request finishes", async () => { + const fetchMock = stubBackend({ + customerEntityHierarchy: true, + deferCustomerRelated: true, + }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Customer master" })); + const customers = await screen.findByRole("region", { name: "Customer master" }); + await userEvent.click(within(customers).getByRole("button", { name: /Demo Group/ })); + await userEvent.click(within(customers).getByRole("button", { name: /Demo Corp/ })); + + fetchMock.releaseGroupRelated(); + await waitFor(() => expect(within(customers).getByText("Loading related posts...")).toBeInTheDocument()); + expect(within(customers).queryByText("No linked posts yet.")).not.toBeInTheDocument(); + + fetchMock.releaseDemoRelated(); + expect( + await within(customers).findByRole("button", { name: "Open related post: Public post" }), + ).toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 52185d67b..fe44acf7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4315,7 +4315,7 @@ function CustomerMasterPanel({ } catch { setRelatedByEntity((previous) => ({ ...previous, [entityId]: [] })); } finally { - setRelatedLoading(null); + setRelatedLoading((current) => (current === entityId ? null : current)); } } From 55934e2d1b33421f4a5d3c9c164ed07c5a747e82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:00:14 +0900 Subject: [PATCH 04/23] fix: avoid customer ADR number collision --- ...-relation-evidence.md => 0096-internal-relation-evidence.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/adr/{0095-internal-relation-evidence.md => 0096-internal-relation-evidence.md} (96%) diff --git a/docs/adr/0095-internal-relation-evidence.md b/docs/adr/0096-internal-relation-evidence.md similarity index 96% rename from docs/adr/0095-internal-relation-evidence.md rename to docs/adr/0096-internal-relation-evidence.md index c5e44e4b3..44845b34e 100644 --- a/docs/adr/0095-internal-relation-evidence.md +++ b/docs/adr/0096-internal-relation-evidence.md @@ -1,4 +1,4 @@ -# ADR 0095: Preserve authorized internal evidence for relation verification +# ADR 0096: Preserve authorized internal evidence for relation verification - Status: Accepted - Date: 2026-08-18 From d947b873cf5cc410e2217a1a9853bc03cc4061a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:10:21 +0900 Subject: [PATCH 05/23] fix: align embedded image accessibility assertion --- frontend/src/App.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 8a6c6ed74..26c96303b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -2057,7 +2057,7 @@ describe("App, authenticated", () => { render(); await userEvent.click(await screen.findByRole("button", { name: "View post: Public post" })); - const image = await screen.findByRole("img", { name: /embedded image at character offset/i }); + const image = await screen.findByRole("img", { name: "Embedded image" }); expect(image).toHaveAttribute("src", `data:image/png;base64,${tinyPng}`); expect(screen.getByText("Quote attached.")).toBeInTheDocument(); expect(screen.getByText("Please confirm.")).toBeInTheDocument(); From e97564903adbedcb406a1f430fea8b98cfe5dbcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:51:49 +0900 Subject: [PATCH 06/23] chore: sync lock metadata with release version --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index a24b3ee73..574c44962 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.13.0" +version = "2.15.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 8a0144927eb26bc7f2a274916d4da7220bd30d1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:26:19 +0900 Subject: [PATCH 07/23] test: type latest VOC backend fixture input --- frontend/src/App.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index b2f56bb27..1294dc3cb 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -98,10 +98,6 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }[]; - }): ReturnType & { - releaseMe: () => void; - releaseGroupRelated: () => void; - releaseDemoRelated: () => void; latestVocPost?: { post_id: string; post_title: string; @@ -111,6 +107,10 @@ describe("App, authenticated", () => { visibility_label?: string; created_at: string; }; + }): ReturnType & { + releaseMe: () => void; + releaseGroupRelated: () => void; + releaseDemoRelated: () => void; } { const statusLabel: Record = { open: "Open", From 1803ab3aed502183a43299ab92a142aa1e5285f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:31:33 -0700 Subject: [PATCH 08/23] feat: redesign Customer Master around three-pane relationships --- ...15.0-customer-master-open-event-lineage.md | 6 + ...25-customer-master-three-pane-workspace.md | 125 +++++++ docs/product-technical-gap-baseline.md | 7 + ...stomerMasterRelationshipWorkspace.test.tsx | 207 +++++++++++ .../src/components/CustomerMasterTree.css | 327 +++++++++++++++++- .../components/CustomerMasterTree.stories.tsx | 45 ++- .../src/components/CustomerMasterTree.tsx | 298 +++++++++++++--- frontend/src/customerMasterWorkspace.ts | 156 +++++++++ 8 files changed, 1099 insertions(+), 72 deletions(-) create mode 100644 docs/adr/0125-customer-master-three-pane-workspace.md create mode 100644 frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx create mode 100644 frontend/src/customerMasterWorkspace.ts diff --git a/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md index 7b67dcfe9..99b6ba65d 100644 --- a/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md +++ b/CHANGELOG.d/2.15.0-customer-master-open-event-lineage.md @@ -3,3 +3,9 @@ Customer master names authorized customer entities as current and to open a related post to read Event Lineage. That open focuses the popup Event Lineage heading. Home-list opens do not. No TEPP theta is invented. + +Customer Master now keeps one customer at the center of a responsive three-pane +workspace: authorized hierarchy, visible parent/direct-child relationships, and +source-backed linked evidence. Closing evidence no longer loses the selected +customer context. Desktop, tablet, and phone layouts use the shared UI tokens +and the 1024 px / 768 px responsive boundaries. diff --git a/docs/adr/0125-customer-master-three-pane-workspace.md b/docs/adr/0125-customer-master-three-pane-workspace.md new file mode 100644 index 000000000..b6ae71a74 --- /dev/null +++ b/docs/adr/0125-customer-master-three-pane-workspace.md @@ -0,0 +1,125 @@ +# ADR 0125: Customer-centered three-pane Customer Master workspace + +- **Status:** Accepted +- **Date:** 2026-08-21 +- **Owners:** Customer Master product surface and evidence navigation +- **Figma file ID:** `SBpgot7uTvMxEaxUwvoc0S` +- **Figma desktop frame:** `313:2` +- **Figma mobile frame:** `314:2` + +## Context + +ADR 0124 established a cycle-safe, authorized WAI-ARIA tree for Group → Company → Plant +containment and deliberately kept related-post evidence outside the tree ownership boundary. That +corrected malformed hierarchy handling and keyboard navigation, but the product composition remained +vertically fragmented: + +1. the hierarchy occupied the first block; +2. selecting an entity caused evidence to appear below the complete tree; +3. relationship-network, unresolved-hint, source-author, and Keyman blocks continued further down; +4. the currently selected customer was not held as the stable visual center of the task. + +Users therefore had to remember which entity they selected while scanning a long page. Parent/child +relationships and source evidence were available, but they were not arranged around the customer that +the user was trying to understand. A free-form graph would add visual complexity and would also risk +presenting inferred edges as if they were authoritative Customer Master facts. + +The uploaded *웹 시스템 UI·UX 표준 가이드 Ver.3.0* requires clear navigation hierarchy and active +state, a 1024 px PC boundary, a 768 px phone boundary, responsive content ordering, system-font +control, and content-page actions that remain discoverable on small screens. ADR 0118 adopted those +breakpoints and design-token rules for LineageWeave. + +## Decision + +1. Compose Customer Master as one customer-centered workspace with three explicit semantic panes: + - **01 Customer hierarchy:** the existing authorized, cycle-safe WAI-ARIA tree; + - **02 Selected customer:** one stable customer summary with the visible parent and direct child + relationships around that customer; + - **03 Linked evidence:** only source-backed related posts, with the existing open-post handoff to + Event Lineage. +2. Keep the selected customer separate from whether its evidence pane is open. Closing evidence must + not lose the customer's centered relationship context. +3. Keep `corporate_entity.parent_entity_id` authoritative. The middle pane may recenter on a visible + parent or direct child, but it must not infer hidden parents, siblings, ownership, or alternative + organizational edges. +4. Preserve all ADR 0124 hierarchy semantics and keyboard behavior. Branch disclosure remains + independent from customer selection. +5. Keep source-backed related posts outside `role="tree"`. A tree item may reference the evidence + region with `aria-controls` only while that region exists. +6. Preserve stale-request rejection and per-entity evidence caching when users move rapidly between + customers. +7. Use existing design tokens for border, focus, color, status, spacing, and dark-mode behavior. Do + not introduce a second Customer Master palette. +8. Use the UI·UX guide's three responsive tiers: + - **PC, greater than 1024 px:** all three panes in one horizontal row; + - **Tablet, up to 1024 px:** hierarchy and selected customer side by side, evidence full width; + - **Phone, up to 768 px:** hierarchy → selected customer → evidence as one vertical task sequence. +9. Maintain complete product copy for all five supported locales: English, Korean, Chinese, + Japanese, and Vietnamese. +10. Represent the desktop, phone, malformed-relation, and unselected states in Storybook. The Figma + frames are the visual design evidence; Storybook remains the executable state inventory. + +## Alternatives considered + +### Keep the vertical tree and accordion evidence + +Rejected because it preserves the long-memory task: the selected customer scrolls away while evidence +and other relationship blocks appear below. + +### Replace the tree with a network graph + +Rejected because graph layout does not provide a predictable hierarchy scan, is harder to operate with +a keyboard, and can blur the boundary between authoritative containment and inferred relationships. +Graphs remain appropriate for Event Lineage, not for the Customer Master authority projection. + +### Put all relationships in one wide table + +Rejected because a table flattens the Group → Company → Plant path and makes recentering around one +customer less direct. Exact-value tables may supplement a graph, but they do not replace the +hierarchical navigation contract here. + +## Consequences + +### Positive + +- The selected customer remains visually and semantically central while users inspect its parent, + children, and evidence. +- The page expresses a stable left-to-right task: choose → understand relations → verify evidence. +- Evidence can close without losing the selected customer or its relationship context. +- Existing WAI-ARIA tree behavior, malformed-relation visibility, authorization scope, and + source-backed evidence boundaries remain intact. +- Responsive layouts preserve the same semantic order instead of hiding relationship context behind a + separate phone-only interaction model. +- Figma and Storybook now describe the same product surface and edge states. + +### Trade-offs + +- A three-pane desktop layout uses more horizontal space than the previous vertical list. +- Tablet users receive a two-row composition rather than all three panes in one row. +- Only the authoritative parent and direct children are shown in the middle pane. Siblings, historical + roles, billing structure, and inferred relationships require separately typed products or later + effective-dated relation models. + +## Verification + +- Existing `CustomerMasterTree` tests continue to cover WAI-ARIA metadata, roving focus, + Arrow/Home/End navigation, independent branch disclosure, stale request rejection, request failure, + evidence caching, and malformed hierarchy members. +- New workspace tests cover the three-pane composition, stable selected-customer state, parent and + direct-child recentering, source evidence outside the tree, evidence close/reopen behavior, + unresolved relation explanation, leaf boundary copy, and five-locale copy completeness. +- Frontend lint, TypeScript, complete Vitest, production build, and Storybook build must pass on the + exact PR head. +- GitHub protected checks and independent review remain the final merge authority. + +## References — APA 7th + +ContextualWisdomLab. (2026). *ADR 0118: UI·UX Standard Guide Ver.3.0 design overhaul*. + +ContextualWisdomLab. (2026). *ADR 0124: Cycle-safe customer master tree projection*. + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2*. +https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Tree view pattern*. +https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c6618240e..d69c840f9 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -23,4 +23,11 @@ - **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. - **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). +## 4. Current Stacked PR Product-Surface Gaps +- **Customer Master relationship composition — PR #262**: (Resolved on the current feature branch) The hierarchy, selected customer, and linked evidence were previously stacked vertically, so the selected customer scrolled away while the user inspected relationships and source posts. ADR 0125 and Figma frames `313:2` / `314:2` define a customer-centered three-pane workspace that preserves the WAI-ARIA tree, keeps the selected customer stable, and places source-backed evidence in a separate pane. +- **Responsive Customer Master flow — PR #262**: (Resolved on the current feature branch) PC uses three horizontal panes, tablet uses two columns plus full-width evidence, and phone preserves the semantic order hierarchy → selected customer → evidence at the shared 1024 px / 768 px breakpoints. +- **Effective-dated relationship authority**: (Open) The current Customer Master projection still owns only one `parent_entity_id`. Legal ownership, operating structure, sales roll-up, billing hierarchy, historical roles, and multiple simultaneous relationship types require a normalized, effective-dated relation model before they can be shown as authoritative facts. +- **Unresolved hierarchy repair workflow**: (Open) Cycle, self-parent, and missing-visible-parent members remain safely visible and marked unresolved, but operators still need a source-data quality queue, evidence review, and approved correction workflow. +- **Customer relationship exact-value export**: (Open) The three-pane workspace is accessible and source-backed, but an auditable CSV/JSON export of the selected customer, visible relations, truth status, effective interval, and evidence references remains a later product slice. + *This document is continuously updated by the hourly automated agent loop.* diff --git a/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx b/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx new file mode 100644 index 000000000..d4a9e1b2a --- /dev/null +++ b/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { CustomerMasterEntity, RelatedNode } from "../api"; +import { getCustomerMasterWorkspaceCopy } from "../customerMasterWorkspace"; +import { SUPPORTED_LOCALES } from "../i18n"; +import { CustomerMasterTree } from "./CustomerMasterTree"; + +function entity( + id: string, + name: string, + parentEntityId: string | null = null, + level: "Group" | "Company" | "Plant" = "Company", +): CustomerMasterEntity { + return { + corporate_entity_id: id, + corporate_entity_code: id.toUpperCase(), + entity_name: name, + entity_level_code: level.toLowerCase(), + entity_level_label: level, + parent_entity_id: parentEntityId, + }; +} + +const hierarchy = [ + entity("group", "Demo Group", null, "Group"), + entity("company", "Demo Company", "group", "Company"), + entity("plant", "Demo Plant", "company", "Plant"), +]; + +function post(entityId: string): RelatedNode { + return { + node_id: `post-${entityId}`, + node_type_code: "node_post", + relevance: 1, + label: `${entityId} evidence`, + post_body_excerpt: `${entityId} source-backed excerpt`, + post_body_truncated: false, + }; +} + +describe("Customer Master three-pane workspace", () => { + it("centers the selected customer between hierarchy and source-backed evidence", async () => { + const loadRelated = vi.fn(async (entityId: string) => [post(entityId)]); + render( + undefined} + />, + ); + + const workspace = screen.getByRole("heading", { name: "Choose a customer in scope" }) + .closest(".customer-master-relationship-workspace"); + expect(workspace).not.toBeNull(); + expect(workspace?.querySelectorAll(".customer-master-workspace-pane")).toHaveLength(3); + + await userEvent.click(screen.getByRole("treeitem", { name: /Demo Company/ })); + + const focusPane = screen.getByRole("heading", { name: "Customer relationship focus" }) + .closest("section"); + expect(focusPane).not.toBeNull(); + expect(within(focusPane as HTMLElement).getByRole("heading", { name: "Demo Company" })) + .toBeInTheDocument(); + expect( + within(focusPane as HTMLElement).getByRole("button", { + name: "Center this customer: Demo Group", + }), + ).toBeInTheDocument(); + expect( + within(focusPane as HTMLElement).getByRole("button", { + name: "Center this customer: Demo Plant", + }), + ).toBeInTheDocument(); + + const evidence = await screen.findByRole("region", { + name: "Related posts: Demo Company", + }); + expect(screen.getByRole("tree")).not.toContainElement(evidence); + expect(within(evidence).getByText("company source-backed excerpt")).toBeInTheDocument(); + expect(loadRelated).toHaveBeenCalledWith("company"); + }); + + it("recenters through relationship cards without inventing hidden relations", async () => { + render( + [post(entityId)]} + onOpenPost={() => undefined} + />, + ); + + await userEvent.click(screen.getByRole("treeitem", { name: /Demo Company/ })); + await userEvent.click( + screen.getByRole("button", { name: "Center this customer: Demo Group" }), + ); + + const focusPane = screen.getByRole("heading", { name: "Customer relationship focus" }) + .closest("section"); + expect( + within(focusPane as HTMLElement).getByRole("heading", { name: "Demo Group" }), + ).toBeInTheDocument(); + expect(within(focusPane as HTMLElement).getByText( + "No parent organization is visible in the authorized scope.", + )).toBeInTheDocument(); + expect( + within(focusPane as HTMLElement).getByRole("button", { + name: "Center this customer: Demo Company", + }), + ).toBeInTheDocument(); + expect(await screen.findByRole("region", { name: "Related posts: Demo Group" })) + .toBeInTheDocument(); + }); + + it("keeps the customer selected while the evidence pane is closed and reopened from cache", async () => { + const loadRelated = vi.fn(async (entityId: string) => [post(entityId)]); + render( + undefined} + />, + ); + + const company = screen.getByRole("treeitem", { name: /Demo Company/ }); + await userEvent.click(company); + expect(await screen.findByRole("region", { name: "Related posts: Demo Company" })) + .toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close linked evidence" })); + expect(screen.queryByRole("region", { name: "Related posts: Demo Company" })) + .not.toBeInTheDocument(); + expect(company).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("heading", { name: "Demo Company" })).toBeInTheDocument(); + + await userEvent.click(screen.getAllByRole("button", { name: "Open linked evidence" })[0]); + expect(await screen.findByRole("region", { name: "Related posts: Demo Company" })) + .toBeInTheDocument(); + expect(loadRelated).toHaveBeenCalledTimes(1); + }); + + it("supports deterministic selected and unselected Storybook states without fabricating evidence", () => { + const loadRelated = vi.fn(async () => []); + const { rerender } = render( + undefined} + />, + ); + + expect(screen.getByRole("heading", { name: "Demo Company" })).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Related posts: Demo Company" })) + .not.toBeInTheDocument(); + expect(loadRelated).not.toHaveBeenCalled(); + + rerender( + undefined} + />, + ); + expect(screen.queryByRole("heading", { name: "Demo Company" })) + .not.toBeInTheDocument(); + expect(screen.getByText( + "Select a customer from the hierarchy to center its relationships.", + )).toBeInTheDocument(); + }); + + it("explains leaf and unresolved relationship boundaries in the center pane", async () => { + render( + []} + onOpenPost={() => undefined} + />, + ); + + await userEvent.click(screen.getByRole("treeitem", { name: /Unresolved Plant.*unresolved/i })); + const focusPane = screen.getByRole("heading", { name: "Customer relationship focus" }) + .closest("section"); + expect(within(focusPane as HTMLElement).getByText( + "This hierarchy relation is unresolved. Review the source data before treating it as authoritative.", + )).toBeInTheDocument(); + expect(within(focusPane as HTMLElement).getByText( + "No parent organization is visible in the authorized scope.", + )).toBeInTheDocument(); + expect(within(focusPane as HTMLElement).getByText( + "No direct child organization is visible in the authorized scope.", + )).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText("No linked posts yet.")).toBeInTheDocument()); + }); +}); + +describe("Customer Master workspace localization", () => { + it("provides the same complete copy contract for all product locales", () => { + const englishKeys = Object.keys(getCustomerMasterWorkspaceCopy("en")).sort(); + for (const locale of SUPPORTED_LOCALES) { + const localized = getCustomerMasterWorkspaceCopy(locale); + expect(Object.keys(localized).sort(), locale).toEqual(englishKeys); + expect(Object.values(localized).every((value) => value.trim().length > 0), locale).toBe(true); + } + }); +}); diff --git a/frontend/src/components/CustomerMasterTree.css b/frontend/src/components/CustomerMasterTree.css index 0908db652..b89c9bae5 100644 --- a/frontend/src/components/CustomerMasterTree.css +++ b/frontend/src/components/CustomerMasterTree.css @@ -1,19 +1,76 @@ -.customer-master-tree-widget { +.customer-master-relationship-workspace { display: grid; - gap: 0.75rem; + grid-template-columns: minmax(16rem, 0.9fr) minmax(18rem, 1fr) minmax(18rem, 1.1fr); + gap: 1rem; + align-items: start; } -.customer-master-tree-widget .customer-tree-node { +.customer-master-workspace-pane { + min-width: 0; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: var(--color-background); +} + +.customer-master-focus-pane { + border: 2px solid var(--color-accent-border); + box-shadow: 0 0 0 1px var(--color-accent-background); +} + +.customer-master-pane-header { + display: grid; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.customer-master-pane-header p, +.customer-master-pane-header h3, +.customer-master-pane-header span { margin: 0; } -.customer-master-tree-widget .customer-tree-node[data-hierarchy-issue] { - border-color: var(--badge-status-danger-text); +.customer-master-pane-header p { + color: var(--color-accent); + font-size: var(--font-size-badge); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.customer-master-pane-header h3 { + color: var(--color-text-heading); + font-size: 1.05rem; + line-height: 1.35; +} + +.customer-master-pane-header span { + color: var(--color-text); + font-size: 0.83rem; + line-height: 1.5; +} + +.customer-master-tree-widget { + display: grid; + max-height: 34rem; + gap: 0.65rem; + margin: 0; + padding: 0; + overflow: auto; + list-style: none; + scrollbar-gutter: stable; +} + +.customer-master-tree-widget .customer-tree-node { + margin: 0; + padding: 0; + border: 0; + background: transparent; } .customer-master-tree-widget .customer-tree-node:focus-visible { - outline: 3px solid var(--accent); - outline-offset: 4px; + outline: 3px solid var(--color-focus-border); + outline-offset: 3px; } .customer-master-tree-widget .customer-entity-button { @@ -21,6 +78,27 @@ grid-template-columns: var(--size-control-min) minmax(0, 1fr); align-items: start; gap: var(--space-control-gap); + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-background); + cursor: pointer; + transition: border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease; +} + +.customer-master-tree-widget .customer-entity-button:hover { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.customer-master-tree-widget .customer-tree-node[aria-selected="true"] > .customer-entity-button { + border-color: var(--color-accent-border); + background: var(--color-accent-background); + box-shadow: inset 3px 0 0 var(--color-accent); +} + +.customer-master-tree-widget .customer-tree-node[data-hierarchy-issue] > .customer-entity-button { + border-color: var(--badge-status-danger-text); } .customer-tree-branch-indicator, @@ -46,16 +124,28 @@ .customer-tree-label { display: flex; + min-width: 0; flex-direction: column; gap: 0.25rem; align-items: flex-start; } +.customer-tree-label strong { + color: var(--color-text-heading); + overflow-wrap: anywhere; +} + +.customer-tree-label > span:not(.customer-tree-unresolved) { + color: var(--color-text); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + .customer-master-tree-group { display: grid; - gap: 0.75rem; - margin: 0.75rem 0 0; - padding: 0 0 0 1.25rem; + gap: 0.65rem; + margin: 0.65rem 0 0; + padding: 0 0 0 1.1rem; list-style: none; border-inline-start: 1px solid var(--color-border-subtle); } @@ -70,15 +160,226 @@ font-weight: 700; } -.customer-master-tree-widget .customer-tree-node[aria-selected="true"] > .customer-entity-button { +.customer-master-focus-content, +.customer-master-relationship-section, +.customer-master-relationship-list, +.customer-tree-evidence { + display: grid; + gap: 0.75rem; +} + +.customer-master-selected-summary { + display: grid; + gap: 0.35rem; + padding: 1rem; + border-radius: var(--radius-panel); + background: var(--color-accent-background); +} + +.customer-master-selected-summary h4, +.customer-master-selected-summary p { + margin: 0; +} + +.customer-master-selected-summary h4 { + color: var(--color-text-heading); + font-size: 1.15rem; +} + +.customer-master-selected-summary p { + color: var(--color-text); + font-size: 0.85rem; +} + +.customer-master-selected-label, +.customer-master-relationship-kind { color: var(--color-accent); + font-size: var(--font-size-badge); + font-weight: 700; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.customer-master-status-badge { + width: fit-content; + padding: var(--space-chip-block) var(--space-chip-inline); + border-radius: var(--radius-chip); + font-size: var(--font-size-badge); + font-weight: 700; +} + +.customer-master-status-verified { + background: var(--badge-status-success-bg); + color: var(--badge-status-success-text); +} + +.customer-master-status-unresolved { + background: var(--badge-status-danger-bg); + color: var(--badge-status-danger-text); +} + +.customer-master-unresolved-callout { + margin: 0; + padding: 0.75rem; + border: 1px solid var(--badge-status-danger-text); + border-radius: var(--radius-control); + background: var(--badge-status-danger-bg); + color: var(--badge-status-danger-text); + font-size: 0.82rem; + line-height: 1.5; +} + +.customer-master-relationship-section h4 { + margin: 0; + color: var(--color-text-heading); + font-size: 0.85rem; +} + +.customer-master-relationship-card { + display: grid; + width: 100%; + gap: 0.25rem; + padding: 0.8rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-background); + color: var(--color-text); + text-align: left; + cursor: pointer; +} + +.customer-master-relationship-card strong { + color: var(--color-text-heading); +} + +.customer-master-relationship-card:hover { + border-color: var(--color-accent-border); + background: var(--color-accent-background); +} + +.customer-master-relationship-card:focus-visible, +.customer-master-evidence-action:focus-visible, +.customer-master-evidence-close:focus-visible { + outline: 3px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.customer-master-empty-relation, +.customer-master-workspace-placeholder { + margin: 0; + padding: 1rem; + border: 1px dashed var(--color-border); + border-radius: var(--radius-control); + color: var(--color-text); + font-size: 0.85rem; + line-height: 1.5; +} + +.customer-master-evidence-action { + width: 100%; + min-height: 2.75rem; + padding: 0.7rem 1rem; + border: 1px solid var(--color-btn-primary-bg); + border-radius: var(--radius-control); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + font-weight: 700; + cursor: pointer; +} + +.customer-master-evidence-action:hover { + background: var(--color-btn-primary-hover); +} + +.customer-master-evidence-action-secondary { + border-color: var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); +} + +.customer-master-evidence-action-secondary:hover { + background: var(--color-btn-secondary-hover); +} + +.customer-master-evidence-context { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem; + border-radius: var(--radius-control); + background: var(--color-accent-background); +} + +.customer-master-evidence-context > div { + display: grid; + gap: 0.25rem; +} + +.customer-master-evidence-context strong { + color: var(--color-text-heading); +} + +.customer-master-evidence-context span { + color: var(--color-text); + font-size: 0.8rem; + line-height: 1.45; +} + +.customer-master-evidence-close { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + min-width: var(--size-control-min); + min-height: var(--size-control-min); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-background); + color: var(--color-text-heading); + font-size: 1.1rem; + cursor: pointer; } .customer-tree-evidence { - margin-top: 1rem; + margin-top: 0; +} + +.customer-tree-evidence > ul { + display: grid; + gap: 0.65rem; + margin: 0; + padding: 0; + list-style: none; +} + +.customer-master-evidence-pane .related-post-card { + width: 100%; + text-align: left; +} + +@media (max-width: 1024px) { + .customer-master-relationship-workspace { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .customer-master-evidence-pane { + grid-column: 1 / -1; + } } -@media (max-width: 640px) { +@media (max-width: 768px) { + .customer-master-relationship-workspace { + grid-template-columns: minmax(0, 1fr); + } + + .customer-master-evidence-pane { + grid-column: auto; + } + + .customer-master-tree-widget { + max-height: none; + } + .customer-master-tree-group { padding-inline-start: 0.65rem; } diff --git a/frontend/src/components/CustomerMasterTree.stories.tsx b/frontend/src/components/CustomerMasterTree.stories.tsx index 8c0bacfae..6cb3553c8 100644 --- a/frontend/src/components/CustomerMasterTree.stories.tsx +++ b/frontend/src/components/CustomerMasterTree.stories.tsx @@ -38,10 +38,21 @@ const hierarchy: CustomerMasterEntity[] = [ ]; const meta = { - title: "Customers/CustomerMasterTree", + title: "Product/Customer Master/Three Pane Workspace", component: CustomerMasterTree, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], args: { entities: hierarchy, + initialSelectedEntityId: "company-grid", loadRelated: async (entityId: string) => [ { node_id: `post-${entityId}`, @@ -60,14 +71,38 @@ const meta = { export default meta; type Story = StoryObj; -export const GroupCompanyPlant: Story = {}; +export const DesktopThreePane: Story = {}; + +export const PhoneStackedSteps: Story = { + parameters: { + viewport: { + defaultViewport: "mobile1", + }, + }, +}; export const MalformedRelationsRemainVisible: Story = { args: { + initialSelectedEntityId: "orphan", entities: [ - { ...hierarchy[1], corporate_entity_id: "cycle-a", entity_name: "Cycle A", parent_entity_id: "cycle-b" }, - { ...hierarchy[1], corporate_entity_id: "cycle-b", entity_name: "Cycle B", parent_entity_id: "cycle-a" }, - { ...hierarchy[1], corporate_entity_id: "orphan", entity_name: "Missing visible parent", parent_entity_id: "outside-scope" }, + { + ...hierarchy[1], + corporate_entity_id: "cycle-a", + entity_name: "Cycle A", + parent_entity_id: "cycle-b", + }, + { + ...hierarchy[1], + corporate_entity_id: "cycle-b", + entity_name: "Cycle B", + parent_entity_id: "cycle-a", + }, + { + ...hierarchy[1], + corporate_entity_id: "orphan", + entity_name: "Missing visible parent", + parent_entity_id: "outside-scope", + }, ], }, }; diff --git a/frontend/src/components/CustomerMasterTree.tsx b/frontend/src/components/CustomerMasterTree.tsx index 5f1e9f100..20f1b75f2 100644 --- a/frontend/src/components/CustomerMasterTree.tsx +++ b/frontend/src/components/CustomerMasterTree.tsx @@ -4,11 +4,14 @@ import { useMemo, useRef, useState, + type FocusEvent, type KeyboardEvent, + type MouseEvent, type ReactNode, } from "react"; import type { CustomerMasterEntity, RelatedNode } from "../api"; -import { t, tf } from "../i18n"; +import { getCustomerMasterWorkspaceCopy } from "../customerMasterWorkspace"; +import { t, tf, useLocale } from "../i18n"; import { buildCustomerEntityForest, flattenVisibleCustomerTree, @@ -19,10 +22,12 @@ import "./CustomerMasterTree.css"; const NODE_POST = "node_post"; +/** Inputs for the authorized Customer Master relationship workspace. */ export interface CustomerMasterTreeProps { entities: readonly CustomerMasterEntity[]; loadRelated: (entityId: string) => Promise; onOpenPost: (postId: string) => void; + initialSelectedEntityId?: string | null; } /** A source-backed related-post card shared by customer hierarchy and hint lists. */ @@ -69,25 +74,53 @@ function branchEntityIds(roots: readonly CustomerEntityTreeNode[]): Set return result; } +function indexCustomerTree( + roots: readonly CustomerEntityTreeNode[], +): Map { + const index = new Map(); + const stack = [...roots]; + while (stack.length > 0) { + const node = stack.pop()!; + index.set(node.entity.corporate_entity_id, node); + stack.push(...node.children); + } + return index; +} + /** - * Render the authorized customer master as a cycle-safe WAI-ARIA tree. + * Render the authorized Customer Master as a customer-centered three-pane workspace. * - * Hierarchy disclosure and evidence disclosure are deliberately separate: arrow keys operate the - * organization hierarchy while activating a tree item opens the entity's source-backed posts. + * The left pane preserves the cycle-safe WAI-ARIA hierarchy, the middle pane keeps one customer and + * its visible parent/child relations in focus, and the right pane owns only source-backed evidence. */ export function CustomerMasterTree({ entities, loadRelated, onOpenPost, + initialSelectedEntityId = null, }: CustomerMasterTreeProps) { + const locale = useLocale(); + const copy = getCustomerMasterWorkspaceCopy(locale); const forest = useMemo(() => buildCustomerEntityForest(entities), [entities]); const allBranchIds = useMemo(() => branchEntityIds(forest), [forest]); + const treeNodeById = useMemo(() => indexCustomerTree(forest), [forest]); + const entityById = useMemo( + () => new Map(entities.map((entity) => [entity.corporate_entity_id, entity])), + [entities], + ); + const resolvedInitialSelection = + initialSelectedEntityId && entityById.has(initialSelectedEntityId) + ? initialSelectedEntityId + : null; const [expandedEntityIds, setExpandedEntityIds] = useState>( () => new Set(allBranchIds), ); const [focusedEntityId, setFocusedEntityId] = useState( () => forest[0]?.entity.corporate_entity_id ?? null, ); + const [selectedEntityId, setSelectedEntityId] = useState( + resolvedInitialSelection, + ); const [evidenceEntityId, setEvidenceEntityId] = useState(null); const [relatedByEntity, setRelatedByEntity] = useState>({}); const [relatedLoadingId, setRelatedLoadingId] = useState(null); @@ -97,11 +130,12 @@ export function CustomerMasterTree({ useEffect(() => { setExpandedEntityIds(new Set(allBranchIds)); setFocusedEntityId(forest[0]?.entity.corporate_entity_id ?? null); + setSelectedEntityId(resolvedInitialSelection); setEvidenceEntityId(null); setRelatedByEntity({}); setRelatedLoadingId(null); relatedRequestSerial.current += 1; - }, [allBranchIds, forest]); + }, [allBranchIds, forest, resolvedInitialSelection]); useEffect( () => () => { @@ -118,10 +152,6 @@ export function CustomerMasterTree({ () => new Map(visibleItems.map((item) => [item.entityId, item])), [visibleItems], ); - const entityById = useMemo( - () => new Map(entities.map((entity) => [entity.corporate_entity_id, entity])), - [entities], - ); useEffect(() => { if (focusedEntityId && visibleById.has(focusedEntityId)) return; @@ -142,17 +172,24 @@ export function CustomerMasterTree({ }); }, []); - const toggleEvidence = useCallback( + const activateEntity = useCallback( async (entityId: string) => { + setSelectedEntityId(entityId); + setFocusedEntityId(entityId); if (evidenceEntityId === entityId) { relatedRequestSerial.current += 1; setEvidenceEntityId(null); setRelatedLoadingId(null); return; } - setEvidenceEntityId(entityId); - if (relatedByEntity[entityId]) return; + const requestSerial = ++relatedRequestSerial.current; + setEvidenceEntityId(entityId); + if (relatedByEntity[entityId]) { + setRelatedLoadingId(null); + return; + } + setRelatedLoadingId(entityId); try { const related = await loadRelated(entityId); @@ -220,7 +257,7 @@ export function CustomerMasterTree({ case "Enter": case " ": { event.preventDefault(); - void toggleEvidence(item.entityId); + void activateEntity(item.entityId); return; } default: @@ -236,6 +273,7 @@ export function CustomerMasterTree({ if (!item) return null; const isBranch = node.children.length > 0; const isExpanded = expandedEntityIds.has(entityId); + const isSelected = selectedEntityId === entityId; const isEvidenceOpen = evidenceEntityId === entityId; const accessibleLabel = [ entity.entity_name, @@ -247,7 +285,7 @@ export function CustomerMasterTree({ return (
  • { + ref={(element: HTMLLIElement | null) => { if (element) treeItemRefs.current.set(entityId, element); else treeItemRefs.current.delete(entityId); }} @@ -259,14 +297,14 @@ export function CustomerMasterTree({ aria-posinset={item.positionInSet} aria-setsize={item.setSize} aria-expanded={isBranch ? isExpanded : undefined} - aria-selected={isEvidenceOpen} + aria-selected={isSelected} aria-controls={isEvidenceOpen ? `customer-evidence-${entityId}` : undefined} data-hierarchy-issue={node.hierarchyIssue ?? undefined} - onFocus={(event) => { + onFocus={(event: FocusEvent) => { if (event.currentTarget === event.target) setFocusedEntityId(entityId); }} - onKeyDown={(event) => handleTreeKeyDown(event, item)} - onClick={(event) => { + onKeyDown={(event: KeyboardEvent) => handleTreeKeyDown(event, item)} + onClick={(event: MouseEvent) => { event.stopPropagation(); const target = event.target as HTMLElement; if (target !== event.currentTarget) { @@ -277,7 +315,7 @@ export function CustomerMasterTree({ setBranchExpanded(entityId, !isExpanded); return; } - void toggleEvidence(entityId); + void activateEntity(entityId); }} >
    @@ -292,7 +330,9 @@ export function CustomerMasterTree({ {entity.entity_name} - {entity.corporate_entity_code} · {entity.entity_level_label} + + {entity.corporate_entity_code} · {entity.entity_level_label} + {node.hierarchyIssue ? ( {t("unresolved")} ) : null} @@ -308,6 +348,12 @@ export function CustomerMasterTree({ }); } + const selectedEntity = selectedEntityId ? entityById.get(selectedEntityId) : undefined; + const selectedNode = selectedEntityId ? treeNodeById.get(selectedEntityId) : undefined; + const selectedParent = selectedEntity?.parent_entity_id + ? entityById.get(selectedEntity.parent_entity_id) + : undefined; + const selectedChildren = selectedNode?.children ?? []; const evidenceEntity = evidenceEntityId ? entityById.get(evidenceEntityId) : undefined; const relatedPosts = evidenceEntityId ? (relatedByEntity[evidenceEntityId] ?? []).filter( @@ -315,42 +361,186 @@ export function CustomerMasterTree({ ) : []; + function renderRelationshipButton( + entity: CustomerMasterEntity, + relationshipLabel: string, + ): ReactNode { + return ( + + ); + } + return ( - <> -
      +
      - {renderNodes(forest)} -
    - {evidenceEntityId && evidenceEntity ? ( -
    +

    {copy.hierarchyKicker}

    +

    {copy.hierarchyTitle}

    + {copy.hierarchyHelp} + +
      - {relatedLoadingId === evidenceEntityId ?

      {t("Loading related posts...")}

      : null} - {relatedLoadingId !== evidenceEntityId && relatedPosts.length === 0 ? ( -

      {t("No linked posts yet.")}

      - ) : null} - {relatedPosts.length > 0 ? ( -
        - {relatedPosts.map((related) => ( -
      • - -
      • - ))} -
      - ) : null} -
    - ) : null} - + {renderNodes(forest)} + + + +
    +
    +

    {copy.focusKicker}

    +

    {copy.focusTitle}

    + {copy.focusHelp} +
    + {selectedEntity && selectedNode ? ( +
    +
    + {copy.selectedCustomer} +

    {selectedEntity.entity_name}

    +

    + {selectedEntity.corporate_entity_code} · {selectedEntity.entity_level_label} +

    + + {selectedNode.hierarchyIssue ? t("unresolved") : copy.verifiedMaster} + +
    + + {selectedNode.hierarchyIssue ? ( +

    {copy.unresolvedRelation}

    + ) : null} + +
    +

    {copy.parentRelationship}

    + {selectedParent + ? renderRelationshipButton(selectedParent, copy.parentRelationship) + :

    {copy.noParent}

    } +
    + +
    +

    {copy.childRelationships}

    + {selectedChildren.length > 0 ? ( +
    + {selectedChildren.map((child) => ( +
    + {renderRelationshipButton(child.entity, copy.childRelationships)} +
    + ))} +
    + ) : ( +

    {copy.noChildren}

    + )} +
    + + +
    + ) : ( +

    {copy.selectCustomerPrompt}

    + )} +
    + + +
    ); } diff --git a/frontend/src/customerMasterWorkspace.ts b/frontend/src/customerMasterWorkspace.ts new file mode 100644 index 000000000..d4dab70cb --- /dev/null +++ b/frontend/src/customerMasterWorkspace.ts @@ -0,0 +1,156 @@ +import type { Locale } from "./i18n"; + +/** Localized copy used by the three-pane Customer Master workspace. */ +export interface CustomerMasterWorkspaceCopy { + hierarchyKicker: string; + hierarchyTitle: string; + hierarchyHelp: string; + focusKicker: string; + focusTitle: string; + focusHelp: string; + evidenceKicker: string; + evidenceTitle: string; + evidenceHelp: string; + selectCustomerPrompt: string; + selectEvidencePrompt: string; + parentRelationship: string; + childRelationships: string; + noParent: string; + noChildren: string; + openEvidence: string; + closeEvidence: string; + verifiedMaster: string; + selectedCustomer: string; + unresolvedRelation: string; + relationshipAction: string; + evidenceNextAction: string; +} + +const COPY_BY_LOCALE: Record = { + en: { + hierarchyKicker: "01 · Customer hierarchy", + hierarchyTitle: "Choose a customer in scope", + hierarchyHelp: "Use the authorized hierarchy for navigation, then keep one customer centered.", + focusKicker: "02 · Selected customer", + focusTitle: "Customer relationship focus", + focusHelp: "Review the visible parent and direct children around the selected customer.", + evidenceKicker: "03 · Linked evidence", + evidenceTitle: "Source-backed customer evidence", + evidenceHelp: "Open a source post to continue into Event Lineage.", + selectCustomerPrompt: "Select a customer from the hierarchy to center its relationships.", + selectEvidencePrompt: "Select a customer to inspect its linked source evidence.", + parentRelationship: "Parent relationship", + childRelationships: "Direct child relationships", + noParent: "No parent organization is visible in the authorized scope.", + noChildren: "No direct child organization is visible in the authorized scope.", + openEvidence: "Open linked evidence", + closeEvidence: "Close linked evidence", + verifiedMaster: "Verified customer master", + selectedCustomer: "Selected customer", + unresolvedRelation: + "This hierarchy relation is unresolved. Review the source data before treating it as authoritative.", + relationshipAction: "Center this customer", + evidenceNextAction: "Open the source post to continue in Event Lineage.", + }, + ko: { + hierarchyKicker: "01 · 고객 계층", + hierarchyTitle: "권한 범위에서 고객사 선택", + hierarchyHelp: "권한이 확인된 계층에서 탐색한 뒤 한 고객사를 가운데에 고정합니다.", + focusKicker: "02 · 선택 고객사", + focusTitle: "고객사 관계 중심", + focusHelp: "선택 고객사를 기준으로 현재 보이는 상위 관계와 직접 하위 관계를 확인합니다.", + evidenceKicker: "03 · 연결 근거", + evidenceTitle: "원문으로 확인된 고객 근거", + evidenceHelp: "원문 글을 열어 이벤트 계보로 이어서 확인합니다.", + selectCustomerPrompt: "왼쪽 고객 계층에서 고객사를 선택해 관계의 중심으로 두세요.", + selectEvidencePrompt: "고객사를 선택하면 연결된 원문 근거를 확인할 수 있습니다.", + parentRelationship: "상위 관계", + childRelationships: "직접 하위 관계", + noParent: "권한 범위에서 확인되는 상위 조직이 없습니다.", + noChildren: "권한 범위에서 확인되는 직접 하위 조직이 없습니다.", + openEvidence: "연결 근거 열기", + closeEvidence: "연결 근거 닫기", + verifiedMaster: "확인된 고객 마스터", + selectedCustomer: "선택 고객사", + unresolvedRelation: "이 계층 관계는 미해결 상태입니다. 권위 있는 사실로 사용하기 전에 원천 데이터를 검토하세요.", + relationshipAction: "이 고객사를 가운데에 고정", + evidenceNextAction: "원문 글을 열어 이벤트 계보에서 이어서 확인하세요.", + }, + zh: { + hierarchyKicker: "01 · 客户层级", + hierarchyTitle: "在授权范围内选择客户", + hierarchyHelp: "从已授权层级中导航,并将一个客户固定在中央。", + focusKicker: "02 · 已选客户", + focusTitle: "以客户为中心的关系", + focusHelp: "围绕已选客户查看可见的上级关系和直接下级关系。", + evidenceKicker: "03 · 关联证据", + evidenceTitle: "有原文依据的客户证据", + evidenceHelp: "打开来源文章,继续查看事件谱系。", + selectCustomerPrompt: "从左侧客户层级中选择客户,将其作为关系中心。", + selectEvidencePrompt: "选择客户后可查看其关联的来源证据。", + parentRelationship: "上级关系", + childRelationships: "直接下级关系", + noParent: "授权范围内没有可见的上级组织。", + noChildren: "授权范围内没有可见的直接下级组织。", + openEvidence: "打开关联证据", + closeEvidence: "关闭关联证据", + verifiedMaster: "已验证客户主数据", + selectedCustomer: "已选客户", + unresolvedRelation: "此层级关系尚未解决。在作为权威事实使用前,请检查源数据。", + relationshipAction: "将此客户置于中央", + evidenceNextAction: "打开来源文章,继续查看事件谱系。", + }, + ja: { + hierarchyKicker: "01 · 顧客階層", + hierarchyTitle: "権限範囲から顧客を選択", + hierarchyHelp: "認可済みの階層をたどり、1社を中央に固定します。", + focusKicker: "02 · 選択中の顧客", + focusTitle: "顧客を中心とした関係", + focusHelp: "選択した顧客を基準に、表示可能な上位関係と直属の下位関係を確認します。", + evidenceKicker: "03 · 関連証拠", + evidenceTitle: "原文に裏付けられた顧客証拠", + evidenceHelp: "元投稿を開き、イベント系譜で続けて確認します。", + selectCustomerPrompt: "左の顧客階層から顧客を選び、関係の中心に置いてください。", + selectEvidencePrompt: "顧客を選択すると、関連する原文証拠を確認できます。", + parentRelationship: "上位関係", + childRelationships: "直属の下位関係", + noParent: "権限範囲内に表示できる上位組織はありません。", + noChildren: "権限範囲内に表示できる直属の下位組織はありません。", + openEvidence: "関連証拠を開く", + closeEvidence: "関連証拠を閉じる", + verifiedMaster: "確認済み顧客マスター", + selectedCustomer: "選択中の顧客", + unresolvedRelation: "この階層関係は未解決です。権威ある事実として扱う前に元データを確認してください。", + relationshipAction: "この顧客を中央に固定", + evidenceNextAction: "元投稿を開き、イベント系譜で続けて確認してください。", + }, + vi: { + hierarchyKicker: "01 · Cây phân cấp khách hàng", + hierarchyTitle: "Chọn khách hàng trong phạm vi được cấp quyền", + hierarchyHelp: "Đi theo cây phân cấp đã được cấp quyền rồi giữ một khách hàng ở vị trí trung tâm.", + focusKicker: "02 · Khách hàng đã chọn", + focusTitle: "Quan hệ lấy khách hàng làm trung tâm", + focusHelp: "Xem quan hệ cấp trên và các quan hệ cấp dưới trực tiếp quanh khách hàng đã chọn.", + evidenceKicker: "03 · Bằng chứng liên kết", + evidenceTitle: "Bằng chứng khách hàng có nguồn gốc", + evidenceHelp: "Mở bài viết nguồn để tiếp tục trong Dòng sự kiện.", + selectCustomerPrompt: "Chọn một khách hàng từ cây phân cấp bên trái để đặt làm trung tâm quan hệ.", + selectEvidencePrompt: "Chọn khách hàng để xem bằng chứng nguồn được liên kết.", + parentRelationship: "Quan hệ cấp trên", + childRelationships: "Quan hệ cấp dưới trực tiếp", + noParent: "Không có tổ chức cấp trên nào hiển thị trong phạm vi được cấp quyền.", + noChildren: "Không có tổ chức cấp dưới trực tiếp nào hiển thị trong phạm vi được cấp quyền.", + openEvidence: "Mở bằng chứng liên kết", + closeEvidence: "Đóng bằng chứng liên kết", + verifiedMaster: "Danh mục khách hàng đã xác minh", + selectedCustomer: "Khách hàng đã chọn", + unresolvedRelation: "Quan hệ phân cấp này chưa được giải quyết. Hãy kiểm tra dữ liệu nguồn trước khi coi là sự thật có thẩm quyền.", + relationshipAction: "Đặt khách hàng này ở trung tâm", + evidenceNextAction: "Mở bài viết nguồn để tiếp tục trong Dòng sự kiện.", + }, +}; + +/** Return the complete Customer Master workspace copy for a supported locale. */ +export function getCustomerMasterWorkspaceCopy(locale: Locale): CustomerMasterWorkspaceCopy { + return COPY_BY_LOCALE[locale]; +} From 7093c8b571fb238dfadb560e7e70994a7728b610 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:41:52 -0700 Subject: [PATCH 09/23] fix: align core ontology with ORG and SHACL contract --- docs/ontology/lineageweave-kg.ttl | 48 ++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/ontology/lineageweave-kg.ttl b/docs/ontology/lineageweave-kg.ttl index d9156d332..8242ea2f4 100644 --- a/docs/ontology/lineageweave-kg.ttl +++ b/docs/ontology/lineageweave-kg.ttl @@ -26,12 +26,17 @@ # tests/test_ontology.py for the round-trip check that every code below # actually exists as a common_lookup_value row, and vice versa. # -# Every custom term carries a :lookupCode annotation naming the exact +# Every custom term carrying a :lookupCode annotation names the exact # `common_lookup_value.lookup_code` it corresponds to -- that literal # string, not the IRI fragment, is what the relational schema stores. ################################################################# a owl:Ontology ; + owl:versionIRI ; + owl:versionInfo "1.0.0" ; + owl:imports , + , + ; rdfs:label "LineageWeave Knowledge Graph Ontology" ; rdfs:comment "Formal OWL 2 / RDFS / SKOS vocabulary for LineageWeave's knowledge_graph_edge node and edge types, entity_relationship_type, person_side, corporate_entity_level, and post_summary_role.actor_type_code controlled vocabularies." . @@ -64,11 +69,16 @@ :lookupCode "counterparty" . :CorporateEntity a owl:Class ; - rdfs:subClassOf skos:Concept ; + rdfs:subClassOf org:Organization ; rdfs:label "Corporate entity" ; - rdfs:comment "A corporate_entity row. Also a skos:Concept so the self-referencing parent_entity_id hierarchy (e.g. Group -> Company -> Plant) is expressible with skos:broader/skos:narrower on instances." ; + rdfs:comment "A corporate_entity row grounded as a W3C ORG organization. Its authoritative parent_entity_id containment is exposed through :subOrganizationOf; SKOS is reserved for the separate level-classification concepts." ; :lookupCode "node_corporate_entity" . +:CorporateEntityLevel a owl:Class ; + rdfs:subClassOf skos:Concept ; + rdfs:label "Corporate entity level" ; + rdfs:comment "A controlled SKOS classification concept describing whether a corporate entity is a Group, Company, or Plant." . + :Team a owl:Class ; rdfs:subClassOf org:OrganizationalUnit ; rdfs:label "Team" ; @@ -125,10 +135,11 @@ :lookupCode "edge_mention_team" . :teamAffiliatedWith a owl:ObjectProperty ; + rdfs:subPropertyOf org:unitOf ; rdfs:domain :Team ; rdfs:range :CorporateEntity ; rdfs:label "team affiliated with" ; - rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id)." ; + rdfs:comment "The company a cataloged team belongs to (cataloged_team.affiliated_corporate_entity_id), specialized from W3C ORG unitOf." ; :lookupCode "edge_team_affiliation" . :mentionsOrganization a owl:ObjectProperty ; @@ -138,6 +149,29 @@ rdfs:comment "A resolved organization is named by a post (post_organization_mention)." ; :lookupCode "edge_mention_organization" . +################################################################# +# Object properties -- authoritative organization containment +################################################################# + +:subOrganizationOf a owl:ObjectProperty ; + rdfs:subPropertyOf org:subOrganizationOf ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntity ; + rdfs:label "sub-organization of" ; + rdfs:comment "The authoritative direct corporate parent represented by corporate_entity.parent_entity_id, specialized from W3C ORG subOrganizationOf." . + +:hasSubOrganization a owl:ObjectProperty ; + owl:inverseOf :subOrganizationOf ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntity ; + rdfs:label "has sub-organization" . + +:hasEntityLevel a owl:ObjectProperty ; + rdfs:domain :CorporateEntity ; + rdfs:range :CorporateEntityLevel ; + rdfs:label "has corporate entity level" ; + rdfs:comment "Classifies one corporate entity with exactly one Group, Company, or Plant concept; SHACL owns the closed-world cardinality." . + ################################################################# # Object properties -- entity_relationship_type # (post_counterparty_entity.relationship_type_code) @@ -181,18 +215,18 @@ rdfs:label "Corporate entity level scheme" ; rdfs:comment "The Acme Group -> Acme Electronics Korea -> Acme Electronics Gwangju Plant kind of level, ordered broadest first." . -:GroupLevel a skos:Concept ; +:GroupLevel a :CorporateEntityLevel, skos:Concept ; skos:inScheme :corporateEntityLevelScheme ; skos:prefLabel "Group"@en ; :lookupCode "group" . -:CompanyLevel a skos:Concept ; +:CompanyLevel a :CorporateEntityLevel, skos:Concept ; skos:inScheme :corporateEntityLevelScheme ; skos:broader :GroupLevel ; skos:prefLabel "Company"@en ; :lookupCode "company" . -:PlantLevel a skos:Concept ; +:PlantLevel a :CorporateEntityLevel, skos:Concept ; skos:inScheme :corporateEntityLevelScheme ; skos:broader :CompanyLevel ; skos:prefLabel "Plant"@en ; From 5e706b73080e46f33a84081a470259f53096c9f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:09:50 +0900 Subject: [PATCH 10/23] fix: keep malformed customer parents unresolved --- .../2.15.0-customer-hierarchy-boundary.md | 7 +++++++ ...stomerMasterRelationshipWorkspace.test.tsx | 20 +++++++++++++++++++ .../src/components/CustomerMasterTree.tsx | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md diff --git a/CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md b/CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md new file mode 100644 index 000000000..9fcae3ac6 --- /dev/null +++ b/CHANGELOG.d/2.15.0-customer-hierarchy-boundary.md @@ -0,0 +1,7 @@ +# 2.15.0 — Keep unresolved customer parents non-authoritative + +## Fixed + +- A self-parent or cyclic customer relation now remains visibly unresolved in + the focus workspace instead of being presented as a valid parent. Review the + source hierarchy before using that relationship for navigation. diff --git a/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx b/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx index d4a9e1b2a..16fa26c78 100644 --- a/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx +++ b/frontend/src/components/CustomerMasterRelationshipWorkspace.test.tsx @@ -193,6 +193,26 @@ describe("Customer Master three-pane workspace", () => { )).toBeInTheDocument(); await waitFor(() => expect(screen.getByText("No linked posts yet.")).toBeInTheDocument()); }); + + it("does not show a malformed self-parent as an authoritative parent", async () => { + render( + []} + onOpenPost={() => undefined} + />, + ); + + await userEvent.click(screen.getByRole("treeitem", { name: /Self Parent.*unresolved/i })); + const focusPane = screen.getByRole("heading", { name: "Customer relationship focus" }) + .closest("section"); + expect(within(focusPane as HTMLElement).getByText( + "No parent organization is visible in the authorized scope.", + )).toBeInTheDocument(); + expect(within(focusPane as HTMLElement).queryByRole("button", { + name: "Center this customer: Self Parent", + })).not.toBeInTheDocument(); + }); }); describe("Customer Master workspace localization", () => { diff --git a/frontend/src/components/CustomerMasterTree.tsx b/frontend/src/components/CustomerMasterTree.tsx index 20f1b75f2..9395b28b0 100644 --- a/frontend/src/components/CustomerMasterTree.tsx +++ b/frontend/src/components/CustomerMasterTree.tsx @@ -350,7 +350,7 @@ export function CustomerMasterTree({ const selectedEntity = selectedEntityId ? entityById.get(selectedEntityId) : undefined; const selectedNode = selectedEntityId ? treeNodeById.get(selectedEntityId) : undefined; - const selectedParent = selectedEntity?.parent_entity_id + const selectedParent = selectedNode && !selectedNode.hierarchyIssue && selectedEntity?.parent_entity_id ? entityById.get(selectedEntity.parent_entity_id) : undefined; const selectedChildren = selectedNode?.children ?? []; From 4dba417fc61ed852e51c9c96710f7b8b5e5c0abe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:28:15 -0700 Subject: [PATCH 11/23] fix: require complete organization token corroboration (#327) * fix: require complete organization token corroboration * fix: match corroboration tokens at boundaries * test: document stdlib log signature * fix: recognize bounded Korean organization particles * fix: ignore URL userinfo during corroboration * test: remove unused ruff suppression * test: cover malformed corroboration URLs * fix: reject non-http relation evidence links * fix: match contiguous Hangul organization names --- docs/adr/0005-relation-verification-agent.md | 12 +- lineageweave/relation_verification.py | 88 +++++--- tests/test_relation_verification.py | 208 ++++++++++++++++++- 3 files changed, 271 insertions(+), 37 deletions(-) diff --git a/docs/adr/0005-relation-verification-agent.md b/docs/adr/0005-relation-verification-agent.md index ed5bd8108..1570d29d6 100644 --- a/docs/adr/0005-relation-verification-agent.md +++ b/docs/adr/0005-relation-verification-agent.md @@ -38,10 +38,14 @@ observed from LLM classification), not "this specific relationship claim is definitely true" -- a genuinely false relationship between two REAL organizations still returns results about each organization separately, so this is an existence/plausibility check, not a full -relationship-truth adjudicator. That is a real upgrade path once real -usage shows the coarser signal under- or over-trusting results in -practice, not implemented here because nothing yet demonstrates the -need for it over this cheaper stage. +relationship-truth adjudicator. A result is accepted only when every +distinctive token in the proposed organization name occurs in the +result's non-search host or content snippet; the result title is excluded +because search engines echo the query there. This prevents one generic +word in an unrelated result from validating an invented multi-token name. +That is a real upgrade path once real usage shows the coarser signal under- +or over-trusting results in practice, not implemented here because +nothing yet demonstrates the need for full NLI over this cheaper stage. The real implementation, `SearxngRelationVerificationClient`, queries a **self-hosted** Searxng instance (`docker/searxng/`), never a diff --git a/lineageweave/relation_verification.py b/lineageweave/relation_verification.py index 509c7552a..3d1b8c366 100644 --- a/lineageweave/relation_verification.py +++ b/lineageweave/relation_verification.py @@ -61,6 +61,11 @@ "and", } ) +_HANGUL_TOKEN = re.compile(r"[가-힣]+") +_KOREAN_PARTICLE_SUFFIX = re.compile( + r"(?:에게서|한테서|에서는|으로는|이라고|에서|에게|한테|께서|부터|까지|처럼|보다|만큼|" + r"으로|이랑|라고|이|가|은|는|을|를|의|에|께|와|과|도|만|로|랑|하고)+" +) STATUS_PENDING = "verify_pending" STATUS_CORROBORATED = "verify_corroborated" @@ -108,7 +113,7 @@ class NullRelationVerificationClient: available = False def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: # pragma: no cover - """Verify whether the relationship has supporting external evidence.""" + """Reject verification because this client has no search transport.""" raise RuntimeError( "NullRelationVerificationClient has no search channel; check .available first" ) @@ -118,20 +123,24 @@ class SearxngRelationVerificationClient: """Queries a self-hosted Searxng instance's JSON API for corroborating evidence of a claimed organization/relationship. - The presence/absence signal is deliberately coarse: any search result - for "```` ````" is treated as - corroboration that the named organization has a real-world footprint - consistent with the claim, not proof the specific relationship is - true (a genuinely false relationship between two REAL organizations - would still return results about each organization separately). This - catches the failure mode actually observed from LLM classification -- - an invented organization name with zero web footprint -- rather than - claiming to adjudicate relationship truth from search snippets alone. + The presence/absence signal is deliberately coarse: a result whose + host or snippet contains every distinctive token in the organization + name is treated as corroboration that the named organization has a + real-world footprint consistent with the claim, not proof the specific + relationship is true (a genuinely false relationship between two REAL + organizations would still return results about each organization + separately). Requiring every token prevents an unrelated page that + happens to contain one common word from corroborating an invented name. + This catches the failure mode actually observed from LLM + classification -- an invented organization name with zero web + footprint -- rather than claiming to adjudicate relationship truth from + search snippets alone. """ available = True def __init__(self, base_url: str, *, timeout: float = 15.0) -> None: + """Configure a validated Searxng base URL and request timeout.""" parsed = urlparse(base_url) if parsed.scheme not in {"http", "https"}: raise ValueError(f"unsupported Searxng base URL scheme: {parsed.scheme or 'missing'}") @@ -139,7 +148,7 @@ def __init__(self, base_url: str, *, timeout: float = 15.0) -> None: self._timeout = timeout def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: - """Verify whether the relationship has supporting external evidence.""" + """Return the first corroborating result, or an explicit negative result.""" query = f"{organization_name} {relationship_label}" body = get_json( f"{self._base_url}/search?q={quote(query, safe='')}&format=json", @@ -161,32 +170,51 @@ def corroborating_evidence_url(organization_name: str, result: dict[str, Any]) - """Return ``result['url']`` when it is a real-world footprint of ``organization_name``. Search engines echo the query in result titles, so "any hit" is not - corroboration. A single distinctive token is not enough either -- an - invented name can still contain an ordinary dictionary word (e.g. - "Fictitious", "Nonexistent") that coincidentally appears on an - unrelated page, so a genuine multi-token name requires a majority of - its tokens to co-occur in the same result; a one-token name has no - majority to require and falls back to that single token. The host - must also not itself be a search page. Missing or empty URLs are not - evidence. + corroboration. A result counts only when every distinctive name token + appears in the host or snippet, and the host is not itself a search + page. The title is intentionally excluded because search engines echo + the query there. Missing or empty URLs are not evidence. """ url = result.get("url") if not isinstance(url, str) or not url.strip(): return None - host = urlparse(url).netloc.lower() + try: + parsed_url = urlparse(url) + host = (parsed_url.hostname or "").lower() + except ValueError: + return None + if parsed_url.scheme not in {"http", "https"}: + return None if not host or any(marker in host for marker in _SEARCH_HOST_MARKERS): return None - tokens = [ - token.lower() - for token in _ORG_TOKEN.findall(organization_name) - if token.lower() not in _ORG_TOKEN_STOPWORDS - ] + organization_tokens = [token.lower() for token in _ORG_TOKEN.findall(organization_name)] + tokens = {token for token in organization_tokens if token not in _ORG_TOKEN_STOPWORDS} if not tokens: return None - haystack = f"{host} {result.get('content') or ''}".lower() - # Every distinctive token must occur in the same result. Matching one - # token lets generic pages about words such as "fictitious" corroborate a - # made-up multi-word organization. - if all(token in haystack for token in tokens): + haystack_tokens = { + token.lower() + for token in _ORG_TOKEN.findall(f"{host} {result.get('content') or ''}") + } + if all( + any(_organization_token_matches(token, candidate) for candidate in haystack_tokens) + for token in tokens + ) or _concatenated_hangul_name_matches(organization_tokens, haystack_tokens): return url return None + + +def _concatenated_hangul_name_matches(expected_tokens: list[str], observed_tokens: set[str]) -> bool: + """Accept a spaced Hangul name when a page writes its parts contiguously.""" + if len(expected_tokens) < 2 or not all(_HANGUL_TOKEN.fullmatch(token) for token in expected_tokens): + return False + compact_name = "".join(expected_tokens) + return any(_organization_token_matches(compact_name, observed) for observed in observed_tokens) + + +def _organization_token_matches(expected: str, observed: str) -> bool: + """Match exact tokens, or a Hangul token followed only by Korean particles.""" + if expected == observed: + return True + if not _HANGUL_TOKEN.fullmatch(expected) or not observed.startswith(expected): + return False + return _KOREAN_PARTICLE_SUFFIX.fullmatch(observed[len(expected) :]) is not None diff --git a/tests/test_relation_verification.py b/tests/test_relation_verification.py index f515d5d25..83826838f 100644 --- a/tests/test_relation_verification.py +++ b/tests/test_relation_verification.py @@ -21,19 +21,34 @@ STATUS_CORROBORATED, STATUS_UNCORROBORATED, NullRelationVerificationClient, + RelationVerificationClient, SearxngRelationVerificationClient, corroborating_evidence_url, ) class _ResultsHandler(BaseHTTPRequestHandler): + """Serve deterministic search responses over the real HTTP boundary.""" + received_query: str = "" - def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API + def do_GET(self) -> None: + """Return the response shape selected by the received search query.""" parsed = urlparse(self.path) query = parse_qs(parsed.query) type(self).received_query = query.get("q", [""])[0] - if "Acme" in type(self).received_query: + if "Malformed" in type(self).received_query: + payload = {"query": type(self).received_query, "results": {"unexpected": True}} + elif "Mixed" in type(self).received_query: + payload = { + "query": type(self).received_query, + "results": [ + None, + {"url": "https://other.example/item", "content": "unrelated"}, + {"url": "https://mixed.example/item", "content": "Mixed Signal"}, + ], + } + elif "Acme" in type(self).received_query: payload = { "query": type(self).received_query, "results": [ @@ -53,11 +68,13 @@ def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler API self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: # noqa: A002 -- stdlib signature + def log_message(self, format: str, *args) -> None: + """Keep the test server quiet while preserving the stdlib signature.""" return def _serve() -> tuple[HTTPServer, str]: + """Start an ephemeral local HTTP server and return its base URL.""" server = HTTPServer(("127.0.0.1", 0), _ResultsHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -66,13 +83,21 @@ def _serve() -> tuple[HTTPServer, str]: def test_null_client_is_unavailable_not_silently_uncorroborated() -> None: + """A missing search channel is unavailable, not a negative finding.""" client = NullRelationVerificationClient() assert client.available is False with pytest.raises(RuntimeError): client.verify("Acme Corp", "Voice of Customer") +def test_protocol_stub_cannot_be_mistaken_for_a_verification() -> None: + """The protocol's runtime stub fails instead of fabricating a result.""" + with pytest.raises(NotImplementedError): + RelationVerificationClient.verify(object(), "Acme Corp", "Voice of Customer") + + def test_searxng_client_reports_corroborated_with_evidence_url() -> None: + """A matching result returns corroboration and its evidence URL.""" server, base = _serve() try: client = SearxngRelationVerificationClient(base_url=base) @@ -87,6 +112,7 @@ def test_searxng_client_reports_corroborated_with_evidence_url() -> None: def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_is_empty() -> None: + """An empty result set remains explicitly uncorroborated.""" server, base = _serve() try: client = SearxngRelationVerificationClient(base_url=base) @@ -98,7 +124,36 @@ def test_searxng_client_reports_uncorroborated_with_no_evidence_url_when_search_ assert result.evidence_url is None +def test_searxng_client_rejects_malformed_results_shape() -> None: + """A non-list result collection cannot become corroborating evidence.""" + server, base = _serve() + try: + result = SearxngRelationVerificationClient(base_url=base).verify( + "Malformed Organization", "Supplier" + ) + finally: + server.shutdown() + + assert result.status_code == STATUS_UNCORROBORATED + assert result.evidence_url is None + + +def test_searxng_client_skips_invalid_and_unrelated_results() -> None: + """The client scans past invalid and unrelated entries to real evidence.""" + server, base = _serve() + try: + result = SearxngRelationVerificationClient(base_url=base).verify( + "Mixed Signal", "Supplier" + ) + finally: + server.shutdown() + + assert result.status_code == STATUS_CORROBORATED + assert result.evidence_url == "https://mixed.example/item" + + def test_query_echo_on_a_search_host_is_not_corroboration() -> None: + """A search-result URL and echoed title cannot become evidence.""" assert ( corroborating_evidence_url( "Zzqxvthorp Fictitious Nonexistent Org", @@ -112,7 +167,36 @@ def test_query_echo_on_a_search_host_is_not_corroboration() -> None: ) +@pytest.mark.parametrize("url", [None, "", "relative-path"]) +def test_missing_empty_or_relative_url_is_not_evidence(url: object) -> None: + """Evidence needs a non-empty absolute URL with a real host.""" + assert corroborating_evidence_url("Acme Corp", {"url": url}) is None + + +@pytest.mark.parametrize("url", ["file://acme.example/item", "javascript://acme.example/item"]) +def test_non_http_evidence_url_is_not_accepted(url: str) -> None: + """Evidence links must be browser-safe HTTP(S) resources.""" + assert corroborating_evidence_url("Acme Corp", {"url": url}) is None + + +def test_malformed_ipv6_url_is_not_evidence() -> None: + """Malformed bracketed hosts fail closed instead of crashing verification.""" + assert corroborating_evidence_url("Acme Corp", {"url": "https://[::1/x"}) is None + + +def test_name_with_only_legal_suffixes_has_no_distinctive_token() -> None: + """Legal suffix stopwords alone cannot identify an organization.""" + assert ( + corroborating_evidence_url( + "Corp Ltd", + {"url": "https://business.example/item", "content": "Corp Ltd"}, + ) + is None + ) + + def test_org_token_in_result_host_is_corroboration() -> None: + """A distinctive organization token in the host is evidence.""" assert ( corroborating_evidence_url( "Acme Corp", @@ -122,6 +206,92 @@ def test_org_token_in_result_host_is_corroboration() -> None: ) +def test_partial_multi_token_name_is_not_corroboration() -> None: + """One generic token must not validate an invented multi-token name.""" + assert ( + corroborating_evidence_url( + "Fictitious Nonexistent Org", + { + "url": "https://microsoft.example/news", + "title": "Fictitious names, domains, and addresses", + "content": "This page discusses fictitious names.", + }, + ) + is None + ) + + +def test_all_distinctive_multi_token_name_parts_are_corroboration() -> None: + """All distinctive name tokens may be distributed across host and content.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://aurora-grid.example/news", + "title": "Aurora Grid Power", + "content": "Aurora Grid Power announced a delivery window.", + }, + ) + == "https://aurora-grid.example/news" + ) + + +def test_title_only_full_name_is_not_corroboration() -> None: + """A title echo alone is not an organization footprint.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://news.example/item", + "title": "Aurora Grid Power", + "content": "", + }, + ) + is None + ) + + +def test_compound_host_token_is_not_two_name_tokens() -> None: + """A compound host word must not match separate organization tokens.""" + assert ( + corroborating_evidence_url( + "Green House", + {"url": "https://greenhouse.example/news", "title": "News", "content": ""}, + ) + is None + ) + + +def test_spaced_hangul_name_matches_contiguous_page_token() -> None: + """A page may concatenate the parts of a spaced Korean name.""" + assert ( + corroborating_evidence_url( + "한빛 그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드가 공급 일정을 발표했다.", + }, + ) + == "https://news.example/item" + ) + + +def test_userinfo_tokens_are_not_hostname_evidence() -> None: + """URL credentials cannot corroborate an unrelated actual hostname.""" + assert ( + corroborating_evidence_url( + "Aurora Grid Power", + { + "url": "https://aurora-grid-power.example@unrelated.example/news", + "title": "News", + "content": "", + }, + ) + is None + ) + + def test_legal_suffix_alone_is_not_corroboration() -> None: """'Corp' is in almost every corporate host; it is not evidence.""" assert ( @@ -134,6 +304,7 @@ def test_legal_suffix_alone_is_not_corroboration() -> None: def test_hangul_org_name_token_is_corroboration() -> None: + """A complete Hangul organization token in content is evidence.""" assert ( corroborating_evidence_url( "한빛그리드", @@ -147,6 +318,37 @@ def test_hangul_org_name_token_is_corroboration() -> None: ) +def test_hangul_org_name_with_particle_is_corroboration() -> None: + """A Korean particle attached to the complete name keeps the token match.""" + assert ( + corroborating_evidence_url( + "한빛그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드가 공급 일정을 발표했다.", + }, + ) + == "https://news.example/item" + ) + + +def test_longer_hangul_business_name_is_not_a_particle_match() -> None: + """An unrelated longer Korean name must not satisfy a shorter name token.""" + assert ( + corroborating_evidence_url( + "한빛그리드", + { + "url": "https://news.example/item", + "title": "News", + "content": "한빛그리드솔루션이 공급 일정을 발표했다.", + }, + ) + is None + ) + + def test_searxng_client_refuses_non_http_scheme() -> None: + """The client rejects non-HTTP URLs before making a request.""" with pytest.raises(ValueError, match="unsupported Searxng base URL scheme"): SearxngRelationVerificationClient(base_url="file:///etc/passwd") From 7e2982bd94e8eb786cf47639a0d523ab6093d57f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:28:21 -0700 Subject: [PATCH 12/23] feat: name Weekly VOC and focus Event Lineage (v2.13.0) (#260) * feat: name Weekly VOC and focus Event Lineage (v2.13.0) Weekly VOC keeps Voice of Customer posts for the latest ISO-8601 week. Opening that filtered post focuses Event Lineage. Home-list opens do not. * fix: reject impossible Weekly VOC dates * fix: avoid stacked ADR number collision * fix: align embedded image accessibility assertion * test: align queue schema and orchestrator contracts * chore: sync lock metadata with release version * fix: derive Weekly VOC from authorized newest post * fix(board): keep weekly VOC filters complete * fix(board): fetch posts for selected ISO week * test(api): prove ISO week scope excludes other corps * ci: remove completed Global Ask repair workflow * fix: remove unused locale return imports --- AGENTS.md | 5 + ARCHITECTURE.md | 4 +- CHANGELOG.d/2.12.0-weekly-voc-iso-week.md | 5 + .../2.13.0-weekly-voc-open-event-lineage.md | 6 + CHANGELOG.md | 41 ++++ CLAUDE.md | 8 + backend/app/main.py | 51 +++-- backend/tests/test_api.py | 18 +- docs/adr/0092-weekly-voc-iso-week-filter.md | 35 +++ ...3-weekly-voc-open-focuses-event-lineage.md | 33 +++ ....md => 0094-internal-relation-evidence.md} | 2 +- frontend/package.json | 2 +- frontend/src/App.css | 167 ++++++++++++++ frontend/src/App.test.tsx | 211 +++++++++++++++++- frontend/src/App.tsx | 97 +++++++- frontend/src/api.ts | 5 + frontend/src/i18n.test.ts | 17 ++ frontend/src/i18n.ts | 20 ++ frontend/src/isoWeek.test.ts | 39 ++++ frontend/src/isoWeek.ts | 47 ++++ .../0050_post_content_ingestion_queue.sql | 2 +- pyproject.toml | 2 +- tests/test_static_sql_review_contracts.py | 2 +- uv.lock | 2 +- 24 files changed, 782 insertions(+), 39 deletions(-) create mode 100644 CHANGELOG.d/2.12.0-weekly-voc-iso-week.md create mode 100644 CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md create mode 100644 docs/adr/0092-weekly-voc-iso-week-filter.md create mode 100644 docs/adr/0093-weekly-voc-open-focuses-event-lineage.md rename docs/adr/{0092-internal-relation-evidence.md => 0094-internal-relation-evidence.md} (96%) create mode 100644 frontend/src/isoWeek.test.ts create mode 100644 frontend/src/isoWeek.ts diff --git a/AGENTS.md b/AGENTS.md index 858175ccb..764e100cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,11 @@ adjudication does -- never a raw LLM API. Demo TEPP seed goes through envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`), never a fabricated theta or a local psychometric substitute. +Buyer Board **Weekly VOC** is an ISO-8601 week list filter (ADR 0092). +Opening that filtered post focuses Event Lineage (ADR 0093). Do not +invent a week, a theta, or a cutoff body. + + ## Tests ```bash diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4ff2e159..878bb4069 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -280,7 +280,9 @@ Keycloak (`src/main.tsx`'s `AuthProvider`) -- no mocked auth, no static HTML. `src/api.ts` calls the FastAPI backend directly with the token Keycloak issued; `src/App.tsx` renders a git-branch SVG of `GET /api/lineage` (click a node to open that post; `post_admin` can -rebuild), the post list, and a full detail popup: Korean +rebuild), the post list with a named Weekly VOC ISO-8601 week filter +(ADR 0092; opening that filtered post focuses Event Lineage, ADR 0093), +and a full detail popup: Korean summary/key-events/R&R, VOC evidence excerpts, an Event Lineage panel (direct vs. indirect links; a link opens that post), the Keyman affiliate tree (resolved ancestors plus unresolved org roots), Keyman + diff --git a/CHANGELOG.d/2.12.0-weekly-voc-iso-week.md b/CHANGELOG.d/2.12.0-weekly-voc-iso-week.md new file mode 100644 index 000000000..5ce801c7c --- /dev/null +++ b/CHANGELOG.d/2.12.0-weekly-voc-iso-week.md @@ -0,0 +1,5 @@ +# 2.12.0 Weekly VOC ISO-week list filter + +Weekly VOC on Board keeps Voice of Customer posts for the latest ISO-8601 +week (UTC Thursday rule). Other VOC types and older weeks drop out. The +Board names Event Lineage as the next read. No TEPP theta is invented. diff --git a/CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md b/CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md new file mode 100644 index 000000000..7709c44cd --- /dev/null +++ b/CHANGELOG.d/2.13.0-weekly-voc-open-event-lineage.md @@ -0,0 +1,6 @@ +# 2.13.0 Opening Weekly VOC focuses Event Lineage + +Open a Voice of Customer post from an active Weekly VOC filter and the +popup Event Lineage heading takes focus. The popup names that post as +current and to read Keyman and evaluation next. Home-list opens do not. +No TEPP theta is invented. diff --git a/CHANGELOG.md b/CHANGELOG.md index 605b79a1c..a3814399c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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). +## [2.13.0] - 2026-08-19 + +### Added + +- Opening a Voice of Customer post from an active Weekly VOC filter now + focuses Event Lineage and names Keyman and evaluation as the next read. + Home-list opens do not add that focus or copy. No TEPP theta is + invented. No cutoff body is invented (ADR 0093 / ADR 0016). + +## [2.12.0] - 2026-08-19 + +### Added + +- Board now names Weekly VOC as an ISO-8601 week list filter. The control + keeps Voice of Customer posts for the latest week present in the loaded + list (UTC Thursday rule) and tells the buyer to open a post to read + Event Lineage. Reset filters returns every VOC type and every week. + No TEPP theta is invented (ADR 0092). + ## [Unreleased] ### Changed @@ -18,6 +37,8 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Removed the completed one-shot Global Ask package-manager repair workflow; + normal product CI remains the only branch validation path. - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. @@ -49,6 +70,26 @@ All notable changes to this project are documented here. Format follows - Large corpora now use bounded post and Event Lineage landing projections so buyers can open complete post-specific detail from a responsive first view. +## [2.11.0] - 2026-08-18 + +### Added + +- Relation verification now preserves a separately authorized internal source + post containing normalized organization and relationship context. The + counterparty popup can open that evidence without treating it as an + external-search URL or changing the external verification status. +- Large corpora now use bounded post and Event Lineage landing projections so + the React screen remains usable before opening complete post-specific detail. + +## [2.10.0] - 2026-08-18 + +### Added + +- Production OIDC can now use a real Keyverse issuer through + `KEYVERSE_ISSUER` and `KEYVERSE_CLIENT_ID`. The backend discovers the + provider's JWKS and verifies the issuer; Compose keeps local Keycloak only + as an explicit development fallback and does not emulate Keyverse. + ## [2.12.5] - 2026-08-18 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..81fefe402 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,3 +70,11 @@ cited source. After that next action, the popup lands the first cited evidence. Changing the week first still focuses the report period field. Mean θ stays on the period-report panel. + +## Weekly VOC (v2.12.0 / v2.13.0) + +On Board, click **Weekly VOC**. Voice of Customer posts for the latest +ISO-8601 week stay; other VOC types and older weeks drop out. The Board +names Event Lineage as the next read (ADR 0092). Open a remaining post: +Event Lineage takes focus and names Keyman and evaluation next +(ADR 0093). A home-list open does not. Do not invent a theta. diff --git a/backend/app/main.py b/backend/app/main.py index 8a71dcab6..281c63823 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -540,7 +540,7 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor async def _post_filter_options( conn: asyncpg.Connection, corporate_entity_ids: frozenset[str] -) -> tuple[list[dict[str, str]], list[dict[str, str]]]: +) -> tuple[list[dict[str, str]], list[dict[str, str]], list[str]]: """Return every authorized filter value, not only values on the current page.""" visibility_sql = f""" select distinct post.visibility_code as code, @@ -568,6 +568,15 @@ async def _post_filter_options( and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ + week_sql = f""" + select distinct to_char(post.created_at at time zone 'UTC', 'IYYY-"W"IW') as iso_week + from source_post post + where (post.visibility_code = 'public' + or post.corporate_entity_id::text = any($1::text[])) + and post.created_at is not null + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + order by iso_week desc + """ # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. visibility_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli visibility_sql, list(corporate_entity_ids) @@ -576,9 +585,14 @@ async def _post_filter_options( type_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli type_sql, list(corporate_entity_ids) ) + # Safe SQL: the ISO-week query is closed; entity ids remain an asyncpg parameter. + week_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + week_sql, list(corporate_entity_ids) + ) return ( [{"code": row["code"], "label": row["label"]} for row in type_rows], [{"code": row["code"], "label": row["label"]} for row in visibility_rows], + [row["iso_week"] for row in week_rows], ) @@ -1157,6 +1171,7 @@ async def list_posts( search: str | None = Query(None, max_length=200), voc_type: list[str] | None = Query(None, max_length=80), visibility: str | None = Query(None, max_length=80), + iso_week: str | None = Query(None, max_length=8, pattern=r"^\d{4}-W\d{2}$"), sort: Literal["newest", "oldest", "title"] = Query("newest"), account: CurrentAccount = Depends(get_current_account), pool: asyncpg.Pool = Depends(get_pool), @@ -1165,7 +1180,7 @@ async def list_posts( _require_post_read(account) search_term = search.strip() if search and search.strip() else None async with pool.acquire() as conn: - voc_type_options, visibility_options = await _post_filter_options( + voc_type_options, visibility_options, iso_week_options = await _post_filter_options( conn, account.corporate_entity_ids ) body_search_ids: list[str] = [] @@ -1211,7 +1226,7 @@ async def list_posts( case when $1::text is null then 0 when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0 - when post.post_id = any($5::uuid[]) then 1 + when post.post_id = any($6::uuid[]) then 1 else 2 end as search_priority, count(*) over() as total_count @@ -1275,7 +1290,7 @@ async def list_posts( ) >= 0.45 ) ) - or post.post_id = any($5::uuid[]) + or post.post_id = any($6::uuid[]) or exists ( select 1 from post_project_mention project where project.post_id = post.post_id @@ -1342,18 +1357,22 @@ async def list_posts( ) and ($3::text[] is null or post.voc_type_code = any($3::text[])) and ($4::text is null or post.visibility_code = $4) + and ( + $5::text is null + or to_char(post.created_at at time zone 'UTC', 'IYYY-"W"IW') = $5 + ) order by search_priority asc, case - when $1::text is not null and post.post_id = any($5::uuid[]) - then array_position($5::uuid[], post.post_id) + when $1::text is not null and post.post_id = any($6::uuid[]) + then array_position($6::uuid[], post.post_id) end asc, - case when $8::text = 'title' then lower(coalesce(post.post_title, '')) end asc, - case when $8::text = 'oldest' then post.created_at end asc, - case when $8::text in ('newest', 'title') then post.created_at end desc, + case when $9::text = 'title' then lower(coalesce(post.post_title, '')) end asc, + case when $9::text = 'oldest' then post.created_at end asc, + case when $9::text in ('newest', 'title') then post.created_at end desc, post.post_id desc - offset $6 - limit $7 + offset $7 + limit $8 ) select page.*, case @@ -1400,17 +1419,18 @@ async def list_posts( case when $1::text is not null then page.search_priority end asc, case when $1::text is not null and page.search_priority = 1 - then array_position($5::uuid[], page.post_id) + then array_position($6::uuid[], page.post_id) end asc, - case when $8::text = 'title' then lower(coalesce(page.post_title, '')) end asc, - case when $8::text = 'oldest' then page.created_at end asc, - case when $8::text in ('newest', 'title') then page.created_at end desc, + case when $9::text = 'title' then lower(coalesce(page.post_title, '')) end asc, + case when $9::text = 'oldest' then page.created_at end asc, + case when $9::text in ('newest', 'title') then page.created_at end desc, page.post_id desc """, search_term, list(account.corporate_entity_ids), [code.strip() for code in voc_type if code.strip()] if voc_type else None, visibility.strip() if visibility and visibility.strip() else None, + iso_week, body_search_ids, offset, limit, @@ -1426,6 +1446,7 @@ async def list_posts( "offset": offset, "voc_type_options": voc_type_options, "visibility_options": visibility_options, + "iso_week_options": iso_week_options, } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c50a34ae2..622741583 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -452,7 +452,12 @@ def _insert_post( "Ada West at Test Corp followed up with Priya Nair at Northridge Grid about the delayed shipment. " "The weather in Gwangju was irrelevant.", ) - other_private_post_id = _insert_post("Other-corp private post", other_corp_id, "private") + other_private_post_id = _insert_post( + "Other-corp private post", + other_corp_id, + "private", + created_at="2026-02-03T12:00:00Z", + ) late_own_private_post_id = _insert_post( "Late own-corp private post", own_corp_id, @@ -1474,6 +1479,8 @@ def test_post_list_includes_public_and_own_corp_but_excludes_other_corp(client, assert {option["code"] for option in payload["voc_type_options"]} == {"voc"} assert {option["code"] for option in payload["visibility_options"]} == {"public", "private"} assert next(option for option in payload["visibility_options"] if option["code"] == "public")["label"] == "Public" + assert set(payload["iso_week_options"]) == {"2026-W02", "2026-W04"} + assert "2026-W06" not in payload["iso_week_options"] def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, seeded_db) -> None: @@ -1493,6 +1500,15 @@ def test_post_list_supports_bounded_offset_pages(client, demo_analyst_token, see assert title_sorted.status_code == 200, title_sorted.text assert title_sorted.json()["posts"][0]["post_title"] == "Edited own-corp private post" + week_filtered = client.get( + "/api/posts?iso_week=2026-W04", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert week_filtered.status_code == 200, week_filtered.text + assert [post["post_title"] for post in week_filtered.json()["posts"]] == [ + "Late own-corp private post" + ] + invalid_sort = client.get( "/api/posts?sort=unsupported", headers={"Authorization": f"Bearer {demo_analyst_token}"}, diff --git a/docs/adr/0092-weekly-voc-iso-week-filter.md b/docs/adr/0092-weekly-voc-iso-week-filter.md new file mode 100644 index 000000000..d2cb32e82 --- /dev/null +++ b/docs/adr/0092-weekly-voc-iso-week-filter.md @@ -0,0 +1,35 @@ +# ADR 0092: Weekly VOC is an ISO-8601 week list filter + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +Board already exposes checkbox VOC-type filters (ADR 0060). Buyers still need +one named control that shows this week's Voice of Customer posts without +inventing a measurement or collapsing other VOC types into a guessed default. +PR #259 stacked a `` remains available beside the named + control. Reset filters returns both the checkboxes and the week to All. +- The Board names the next action: Voice of Customer posts for that week + are current; open a post to read Event Lineage. +- No TEPP theta is invented. No cutoff body is invented (ADR 0016). + +## Consequences + +- Weekly VOC composes with the existing checkbox VOC vocabulary instead of + replacing it. +- Posts whose `created_at` cannot be parsed contribute no week and cannot + be selected by this filter. +- Home-list and Customer-master opens are unchanged until ADR 0093. diff --git a/docs/adr/0093-weekly-voc-open-focuses-event-lineage.md b/docs/adr/0093-weekly-voc-open-focuses-event-lineage.md new file mode 100644 index 000000000..86bc73b0a --- /dev/null +++ b/docs/adr/0093-weekly-voc-open-focuses-event-lineage.md @@ -0,0 +1,33 @@ +# ADR 0093: Opening a Weekly VOC post focuses Event Lineage + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +ADR 0092 names Weekly VOC as an ISO-week list filter and tells the buyer to +open a post to read Event Lineage. Report-member opens already focus the +popup Event Lineage heading. A home-list open must not steal that focus or +add that next-action copy. + +## Decision + +Opening a Board post while Weekly VOC is active (`voc` only and a concrete +ISO week) is a `fromWeeklyVoc` open. That open reuses the existing Event +Lineage focus path used by report-member opens: + +- The popup Event Lineage heading takes focus. +- The popup names the opened post as current in Event Lineage and tells + the buyer to read Keyman and evaluation next. + +A Board open from the unfiltered home list, a reset filter list, or any +path that did not set `fromWeeklyVoc` does not focus Event Lineage and +does not add that copy. Closing the popup clears the Weekly VOC open flag. + +No TEPP theta is invented. No cutoff body is invented (ADR 0016). + +## Consequences + +- Weekly VOC and report-member opens share one focus contract. +- Changing VOC checkboxes or the ISO week so Weekly VOC is no longer + active makes the next Board open a home-list open. diff --git a/docs/adr/0092-internal-relation-evidence.md b/docs/adr/0094-internal-relation-evidence.md similarity index 96% rename from docs/adr/0092-internal-relation-evidence.md rename to docs/adr/0094-internal-relation-evidence.md index 575231775..31d8e8f22 100644 --- a/docs/adr/0092-internal-relation-evidence.md +++ b/docs/adr/0094-internal-relation-evidence.md @@ -1,4 +1,4 @@ -# ADR 0092: Preserve authorized internal evidence for relation verification +# ADR 0094: Preserve authorized internal evidence for relation verification - Status: Accepted - Date: 2026-08-18 diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..20fda0d5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index d188b03e9..2add95ce0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -959,6 +959,173 @@ outline-offset: -3px; } +.board-surface { + display: block; +} + +.board-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.board-header h2 { + margin: 0.2rem 0 0.35rem; +} + +.board-header p { + margin: 0; +} + +.board-result-count { + color: var(--text-muted); + font-size: var(--lw-font-size-meta); + white-space: nowrap; +} + +.board-controls { + display: grid; + grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr)) auto; + gap: 0.75rem; + align-items: end; + margin-bottom: 1.25rem; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); +} + +.board-controls label { + display: grid; + gap: 0.3rem; + color: var(--text-muted); + font-size: var(--lw-font-size-meta); + font-weight: 700; +} + +.board-search-help { + grid-column: 1 / -1; + margin: 0; +} + +.board-voc-type-filter { + display: flex; + flex-wrap: wrap; + gap: 0.1rem 0.85rem; + border: none; + padding: 0; + margin: 0; +} + +.board-voc-type-filter legend { + width: 100%; + padding: 0; + color: var(--text-muted); + font-size: var(--lw-font-size-meta); + font-weight: 700; +} + +.board-voc-type-filter label { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + font-weight: 400; +} + +.board-voc-type-filter input[type="checkbox"] { + width: auto; + min-height: 0; +} + +.board-weekly-voc[aria-pressed="true"] { + border-color: var(--text); + font-weight: 700; +} + +.board-next-action { + margin: 0 0 1rem; +} + +.board-controls input, +.board-controls select { + min-height: var(--size-control-min); + width: 100%; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text); + font: inherit; +} + +.board-reset { + min-height: var(--size-control-min); + white-space: nowrap; +} + +.board-empty { + padding: 1.25rem; + border: 1px dashed var(--border); + border-radius: 10px; + color: var(--text-muted); +} + +.board-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.board-pagination button { + min-width: 2.25rem; + min-height: var(--size-control-min); +} + +.board-pagination button[aria-current="page"] { + border-color: var(--color-accent-border); + background: var(--color-accent-background); + color: var(--text-h); + font-weight: 700; +} + +.post-card { + margin-bottom: 0.5rem; +} + +.post-card .post-list-item { + margin-bottom: 0; +} + +.post-card-main { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.post-body-excerpt { + display: -webkit-box; + overflow: hidden; + color: var(--text); + font-size: 0.9rem; + line-height: 1.45; + text-align: left; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.post-card-badges { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + justify-content: flex-end; +} + .post-list-item[aria-current="true"], .ticket-list-item[aria-current="true"] { border-color: var(--color-accent-info); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index dfc24f9c8..2991c04c7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; import { setLocale } from "./i18n"; +import { isoWeekFromCreatedAt } from "./isoWeek"; const signinRedirect = vi.fn(); const signoutRedirect = vi.fn(); @@ -103,6 +104,34 @@ describe("App, authenticated", () => { postBody?: string; manyCustomerHints?: number; customerEntityHierarchy?: boolean; + boardPosts?: { + post_id: string; + post_title: string; + voc_type_code: string; + voc_type_label?: string; + visibility_code?: string; + visibility_label?: string; + created_at: string; + }[]; + latestVocPost?: { + post_id: string; + post_title: string; + voc_type_code: string; + voc_type_label?: string; + visibility_code?: string; + visibility_label?: string; + created_at: string; + }; + isoWeekOptions?: string[]; + weekFilteredPosts?: { + post_id: string; + post_title: string; + voc_type_code: string; + voc_type_label?: string; + visibility_code?: string; + visibility_label?: string; + created_at: string; + }[]; staleSummary?: boolean; contentAfterSummary?: boolean; }): ReturnType & { releaseMe: () => void; releasePosts: () => void } { @@ -1066,22 +1095,38 @@ describe("App, authenticated", () => { } const postsUrl = new URL(url, "https://backend.test"); if (postsUrl.pathname === "/api/posts") { + const vocRequest = postsUrl.searchParams.get("voc_type") === "voc"; + const boardPosts = vocRequest && options?.latestVocPost + ? [options.latestVocPost] + : [ + { + 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", + }, + ...(options?.boardPosts ?? []), + ]; + const isoWeekOptions = options?.isoWeekOptions ?? Array.from( + new Set( + boardPosts + .map((post) => isoWeekFromCreatedAt(post.created_at)) + .filter((week): week is string => Boolean(week)), + ), + ).sort((left, right) => right.localeCompare(left)); + const responsePosts = + postsUrl.searchParams.get("iso_week") && options?.weekFilteredPosts + ? options.weekFilteredPosts + : boardPosts; return postsReady.then(() => jsonResponse( postsUrl.searchParams.get("search") ? [] : { - posts: [ - { - 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", - }, - ], + posts: responsePosts, total_count: 1, limit: 50, offset: 0, @@ -1090,6 +1135,7 @@ describe("App, authenticated", () => { { code: "vop", label: "Voice of Partner" }, ], visibility_options: [{ code: "public", label: "Public" }], + iso_week_options: isoWeekOptions, }, ), ); @@ -1843,6 +1889,149 @@ describe("App, authenticated", () => { expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); }); + it("clicking Weekly VOC keeps the 2026-W01 Voice of Customer post and names Event Lineage as the next action", async () => { + stubBackend({ + boardPosts: [ + { + post_id: "post-vom-w01", + post_title: "Internal memo", + voc_type_code: "vom", + voc_type_label: "Voice of Market", + visibility_code: "internal", + visibility_label: "Internal", + created_at: "2026-01-02T00:00:00Z", + }, + { + post_id: "post-voc-w52", + post_title: "Older Voice of Customer", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2025-12-22T00:00:00Z", + }, + ], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + expect(within(board).getByRole("button", { name: "View post: Internal memo" })).toBeInTheDocument(); + expect(within(board).getByRole("button", { name: "View post: Older Voice of Customer" })).toBeInTheDocument(); + + const weeklyVoc = within(board).getByRole("button", { name: "Weekly VOC" }); + await userEvent.selectOptions(within(board).getByLabelText("Sort posts"), "title"); + expect(weeklyVoc).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(weeklyVoc); + + expect(weeklyVoc).toHaveAttribute("aria-pressed", "true"); + await waitFor(() => expect(within(board).getByLabelText("Sort posts")).toHaveValue("newest")); + expect(within(board).getByLabelText("Filter by ISO week")).toHaveValue("2026-W01"); + expect(within(board).getByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + expect(within(board).queryByRole("button", { name: "View post: Internal memo" })).not.toBeInTheDocument(); + expect( + within(board).queryByRole("button", { name: "View post: Older Voice of Customer" }), + ).not.toBeInTheDocument(); + expect(within(board).getByLabelText("Next action")).toHaveTextContent( + "Voice of Customer posts for 2026-W01 are current. Open a post to read Event Lineage.", + ); + + await userEvent.click(within(board).getByRole("button", { name: "Reset filters" })); + expect(weeklyVoc).toHaveAttribute("aria-pressed", "false"); + expect(within(board).getByRole("button", { name: "View post: Internal memo" })).toBeInTheDocument(); + expect(within(board).getByRole("button", { name: "View post: Older Voice of Customer" })).toBeInTheDocument(); + }); + + it("shows authorized ISO weeks supplied by the API even when a week is outside the loaded page", async () => { + const fetchMock = stubBackend({ + isoWeekOptions: ["2026-W08", "2026-W01"], + weekFilteredPosts: [ + { + post_id: "post-voc-w08", + post_title: "Older page Voice of Customer", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2026-02-18T00:00:00Z", + }, + ], + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.selectOptions(within(board).getByLabelText("Filter by ISO week"), "2026-W08"); + await waitFor(() => + expect(within(board).getByRole("button", { name: "View post: Older page Voice of Customer" })).toBeInTheDocument(), + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("iso_week=2026-W08"), + expect.anything(), + ); + }); + + it("gets the Weekly VOC week from the authorized newest VOC post, not the loaded page", async () => { + const fetchMock = stubBackend({ + boardPosts: [ + { + post_id: "post-voc-old", + post_title: "Older Voice of Customer", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2025-12-22T00:00:00Z", + }, + ], + latestVocPost: { + post_id: "post-voc-new", + post_title: "Newest Voice of Customer", + voc_type_code: "voc", + voc_type_label: "Voice of Customer", + visibility_code: "public", + visibility_label: "Public", + created_at: "2026-02-18T00:00:00Z", + }, + }); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click(within(board).getByRole("button", { name: "Weekly VOC" })); + + await waitFor(() => expect(within(board).getByLabelText("Filter by ISO week")).toHaveValue("2026-W08")); + expect(within(board).getByRole("button", { name: "View post: Newest Voice of Customer" })).toBeInTheDocument(); + expect(within(board).queryByRole("button", { name: "View post: Older Voice of Customer" })).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("limit=1"), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-access-token" }) }), + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("voc_type=voc"), + expect.anything(), + ); + }); + + it("opening a Weekly VOC post focuses Event Lineage; a home list open does not", async () => { + stubBackend(); + render(); + + const board = await screen.findByRole("region", { name: "Board" }); + await userEvent.click(within(board).getByRole("button", { name: "Weekly VOC" })); + await userEvent.click(within(board).getByRole("button", { name: "View post: Public post" })); + + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).toHaveFocus(); + expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( + "Public post is current in Event Lineage. Read Keyman and evaluation next.", + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click(within(board).getByRole("button", { name: "Reset filters" })); + await userEvent.click(within(board).getByRole("button", { name: "View post: Public post" })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(document.getElementById("post-event-lineage")).not.toHaveFocus(); + expect(screen.queryByRole("status", { name: "Event Lineage next action" })).not.toBeInTheDocument(); + }); + it("renders the A-100 fork as a git-style DAG, not a flat edge list", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61faf58a6..d22e2d821 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -103,6 +103,7 @@ import { tf, useLocale, } from "./i18n"; +import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; @@ -2582,6 +2583,7 @@ type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; fromReportMember?: boolean; + fromWeeklyVoc?: boolean; /** Set when re-entering a post from a popstate (browser back/forward) so * the handler doesn't push a duplicate history entry for a navigation * the browser already performed. */ @@ -3648,6 +3650,7 @@ function PostList({ const [openedGroupingLabel, setOpenedGroupingLabel] = useState(null); const [landOnComparison, setLandOnComparison] = useState(false); const [openedFromReportMember, setOpenedFromReportMember] = useState(false); + const [openedFromWeeklyVoc, setOpenedFromWeeklyVoc] = useState(false); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3657,6 +3660,8 @@ function PostList({ const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState([]); const [vocTypeFilterOptions, setVocTypeFilterOptions] = useState([]); + const [weekFilter, setWeekFilter] = useState("all"); + const [isoWeekFilterOptions, setIsoWeekFilterOptions] = useState([]); const [visibilityFilter, setVisibilityFilter] = useState("all"); const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); const [sortOrder, setSortOrder] = useState("newest"); @@ -3718,6 +3723,7 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); if (!options?.fromPopState) { const url = new URL(window.location.href); if (url.searchParams.get("post") !== postId) { @@ -3754,6 +3760,7 @@ function PostList({ setOpenedAfterCutoff(false); setOpenedCutoffIso(null); setOpenedFromReportMember(false); + setOpenedFromWeeklyVoc(false); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -3783,12 +3790,14 @@ function PostList({ typeFilter.length > 0 ? typeFilter : undefined, visibilityFilter === "all" ? undefined : visibilityFilter, sort, + weekFilter === "all" ? undefined : weekFilter, ); if (requestId !== postsRequest.current) return; setPosts(response.posts); setTotalPosts(response.total_count); setVocTypeFilterOptions(response.voc_type_options ?? []); setVisibilityFilterOptions(response.visibility_options ?? []); + setIsoWeekFilterOptions(response.iso_week_options ?? []); setCurrentPage(page); } catch (err) { if (requestId !== postsRequest.current) return; @@ -3796,7 +3805,7 @@ function PostList({ } finally { if (requestId === postsRequest.current) setLoadingPage(false); } - }, [accessToken, searchQuery, sortOrder, typeFilter, visibilityFilter]); + }, [accessToken, searchQuery, sortOrder, typeFilter, visibilityFilter, weekFilter]); useEffect(() => { void loadPostPage(1); @@ -3869,7 +3878,9 @@ function PostList({ .filter((post) => { const matchesType = typeFilter.length === 0 || typeFilter.includes(post.voc_type_code); const matchesVisibility = visibilityFilter === "all" || post.visibility_code === visibilityFilter; - return matchesType && matchesVisibility; + const matchesWeek = + weekFilter === "all" || isoWeekFromCreatedAt(post.created_at) === weekFilter; + return matchesType && matchesVisibility && matchesWeek; }) .sort((left, right) => { if (sortOrder === "title") { @@ -3878,7 +3889,46 @@ function PostList({ const direction = sortOrder === "newest" ? -1 : 1; return direction * left.created_at.localeCompare(right.created_at); }); - const hasBoardFilters = Boolean(searchInput.trim()) || Boolean(searchQuery) || typeFilter.length > 0 || visibilityFilter !== "all"; + const weeklyVocActive = + typeFilter.length === 1 && typeFilter[0] === "voc" && weekFilter !== "all"; + const weekOptions = isoWeekFilterOptions.length + ? isoWeekFilterOptions + : Array.from( + new Set( + loadedPosts + .map((post) => isoWeekFromCreatedAt(post.created_at)) + .filter((week): week is string => Boolean(week)), + ), + ).sort((left, right) => right.localeCompare(left)); + const applyWeeklyVoc = async () => { + try { + const latestVocPage = await fetchPosts( + accessToken, + 1, + 0, + undefined, + ["voc"], + undefined, + "newest", + ); + const vocWeek = latestIsoWeek( + latestVocPage.posts.map((post) => isoWeekFromCreatedAt(post.created_at)), + ); + setTypeFilter(["voc"]); + setWeekFilter(vocWeek ?? "all"); + setSortOrder("newest"); + setCurrentPage(1); + setError(null); + } catch (err) { + setError(String(err)); + } + }; + const hasBoardFilters = + Boolean(searchInput.trim()) || + Boolean(searchQuery) || + typeFilter.length > 0 || + visibilityFilter !== "all" || + weekFilter !== "all"; const totalPages = Math.max(1, Math.ceil(totalPosts / POST_PAGE_SIZE)); const pageItems: Array = totalPages <= 7 @@ -3928,6 +3978,7 @@ function PostList({ setSearchInput(""); setSearchQuery(""); setTypeFilter([]); + setWeekFilter("all"); setVisibilityFilter("all"); setSortOrder("newest"); }} @@ -3967,6 +4018,29 @@ function PostList({ ))} + +