From c107d1f3d9c3fba8242dbe4fdea1066fa96cecd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:19:17 +0000 Subject: [PATCH 01/12] 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. --- 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 | 19 +++++ CLAUDE.md | 9 ++ docs/adr/0070-weekly-voc-iso-week-filter.md | 35 ++++++++ ...1-weekly-voc-open-focuses-event-lineage.md | 33 ++++++++ frontend/package.json | 2 +- frontend/src/App.css | 9 ++ frontend/src/App.test.tsx | 82 +++++++++++++++++++ frontend/src/App.tsx | 76 ++++++++++++++++- frontend/src/i18n.test.ts | 17 ++++ frontend/src/i18n.ts | 20 +++++ frontend/src/isoWeek.test.ts | 28 +++++++ frontend/src/isoWeek.ts | 30 +++++++ pyproject.toml | 2 +- 17 files changed, 375 insertions(+), 7 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/0070-weekly-voc-iso-week-filter.md create mode 100644 docs/adr/0071-weekly-voc-open-focuses-event-lineage.md create mode 100644 frontend/src/isoWeek.test.ts create mode 100644 frontend/src/isoWeek.ts diff --git a/AGENTS.md b/AGENTS.md index aa93be63e..4a5d0c5ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,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 0070). +Opening that filtered post focuses Event Lineage (ADR 0071). Do not +invent a week, a theta, or a cutoff body. + + ## Tests ```bash diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cac728a18..cee0882c7 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 0070; opening that filtered post focuses Event Lineage, ADR 0071), +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 f5719013a..31debb721 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 0071 / 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 0070). + ## [2.10.0] - 2026-08-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..e2aefa18b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,3 +70,12 @@ 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 0070). Open a remaining post: +Event Lineage takes focus and names Keyman and evaluation next +(ADR 0071). A home-list open does not. Do not invent a theta. + diff --git a/docs/adr/0070-weekly-voc-iso-week-filter.md b/docs/adr/0070-weekly-voc-iso-week-filter.md new file mode 100644 index 000000000..b4ad265d8 --- /dev/null +++ b/docs/adr/0070-weekly-voc-iso-week-filter.md @@ -0,0 +1,35 @@ +# ADR 0070: 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 0071. diff --git a/docs/adr/0071-weekly-voc-open-focuses-event-lineage.md b/docs/adr/0071-weekly-voc-open-focuses-event-lineage.md new file mode 100644 index 000000000..3aa3263ca --- /dev/null +++ b/docs/adr/0071-weekly-voc-open-focuses-event-lineage.md @@ -0,0 +1,33 @@ +# ADR 0071: Opening a Weekly VOC post focuses Event Lineage + +- Status: Accepted +- Date: 2026-08-19 + +## Context + +ADR 0070 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/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 b37ed6661..6b63bcb7f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -145,6 +145,15 @@ 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); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 16abc86b0..61763f574 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -87,6 +87,15 @@ 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; + }[]; }): ReturnType & { releaseMe: () => void } { const statusLabel: Record = { open: "Open", @@ -1043,6 +1052,7 @@ describe("App, authenticated", () => { visibility_label: "Public", created_at: "2026-01-01T00:00:00Z", }, + ...(options?.boardPosts ?? []), ], total_count: 1, limit: 50, @@ -1762,6 +1772,78 @@ 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" }); + expect(weeklyVoc).toHaveAttribute("aria-pressed", "false"); + await userEvent.click(weeklyVoc); + + expect(weeklyVoc).toHaveAttribute("aria-pressed", "true"); + 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("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 0ea650457..b824ad597 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -98,6 +98,7 @@ import { tf, useLocale, } from "./i18n"; +import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -2464,6 +2465,7 @@ type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; fromReportMember?: boolean; + fromWeeklyVoc?: boolean; }; /** @@ -3522,6 +3524,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); @@ -3531,6 +3534,7 @@ function PostList({ const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState([]); const [vocTypeFilterOptions, setVocTypeFilterOptions] = useState([]); + const [weekFilter, setWeekFilter] = useState("all"); const [visibilityFilter, setVisibilityFilter] = useState("all"); const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); const [sortOrder, setSortOrder] = useState("newest"); @@ -3574,6 +3578,7 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + setOpenedFromWeeklyVoc(Boolean(options?.fromWeeklyVoc)); } useEffect(() => { @@ -3587,6 +3592,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"); @@ -3702,7 +3708,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") { @@ -3711,7 +3719,30 @@ 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 = 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 = () => { + const vocWeek = latestIsoWeek( + loadedPosts + .filter((post) => post.voc_type_code === "voc") + .map((post) => isoWeekFromCreatedAt(post.created_at)), + ); + setTypeFilter(["voc"]); + setWeekFilter(vocWeek ?? "all"); + }; + 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 @@ -3761,6 +3792,7 @@ function PostList({ setSearchInput(""); setSearchQuery(""); setTypeFilter([]); + setWeekFilter("all"); setVisibilityFilter("all"); setSortOrder("newest"); }} @@ -3796,6 +3828,29 @@ function PostList({ ))} + +