diff --git a/AGENTS.md b/AGENTS.md index 2b5c6379..f73afb38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,10 @@ pnpm run lint && pnpm run test && pnpm run build # Storybook inventory (ADR 0020 tokens): pnpm run build-storybook ``` +Board **Weekly VOC** (ADR 0051 / v2.12.0) names the latest authorized +ISO week among Voice of Customer posts. Do not substitute the operator +laptop clock. Seed Public post is `2026-W01`. Do not invent a theta. + A run-bearing analysis-run registry empties only after an unrevoked `analysis_run_retention_grant` and `GRANT analysis_run_retention_admin` (ADR 0020 / v0.87.0). The documented phrase is not a secret. Do not diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aa8d256d..84e9f015 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -276,7 +276,11 @@ Person, team, and organization mention channels load independently React + Vite + TypeScript, pinned Node via `mise.toml`, pnpm via Corepack. `react-oidc-context` drives a real Authorization Code redirect through Keycloak (`src/main.tsx`'s `AuthProvider`) -- no mocked auth, no static -HTML. `src/api.ts` calls the FastAPI backend directly with the token +HTML. The authenticated buyer GNB is Board, Customer master, Calendar, +and Ask Agent (ADR 0037). Board **Weekly VOC** (ADR 0051) keeps Voice +of Customer posts for the latest authorized ISO week and names Event +Lineage as the next action; it does not use the wall-clock week. +`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 diff --git a/CHANGELOG.d/2.12.0-board-weekly-voc-filter.md b/CHANGELOG.d/2.12.0-board-weekly-voc-filter.md new file mode 100644 index 00000000..0306dbb8 --- /dev/null +++ b/CHANGELOG.d/2.12.0-board-weekly-voc-filter.md @@ -0,0 +1,5 @@ +# Board Weekly VOC filter + +- Added a named Weekly VOC control on Board. It keeps Voice of Customer + posts for the latest authorized ISO week and names Event Lineage as + the next action. It does not use the wall-clock week. diff --git a/CHANGELOG.md b/CHANGELOG.md index f5719013..ca2f0371 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.12.0] - 2026-08-19 + +### Added + +- Board now has a named **Weekly VOC** list filter. Click it to keep + Voice of Customer posts for the latest authorized ISO week (Public + post is `2026-W01` after seed) and read Event Lineage next. The + filter does not use the operator laptop clock and does not invent a + TEPP theta. + ## [2.10.0] - 2026-08-18 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf5076..a285fa11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,13 @@ Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the ADRs under `docs/adr/`. Do not fork those rules here. +## Board Weekly VOC (v2.12.0) + +Board **Weekly VOC** keeps Voice of Customer posts for the latest +authorized ISO week and names Event Lineage as the next action +(ADR 0051). Do not use the operator laptop clock. Seed Public post +is `2026-W01`. Do not invent a theta. + ## Analysis-run retention (v0.87.0) To empty a run-bearing registry, insert an unrevoked diff --git a/docs/adr/0051-board-weekly-voc-filter.md b/docs/adr/0051-board-weekly-voc-filter.md new file mode 100644 index 00000000..13306e38 --- /dev/null +++ b/docs/adr/0051-board-weekly-voc-filter.md @@ -0,0 +1,65 @@ +# ADR 0051 — Board Weekly VOC is an ISO-week list filter + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-19 +**Depends on:** ADR 0037 buyer GNB and product surface; ADR 0050 seed +period-report analysis run +**Refs:** Issue #251 buyer IA (게시판 weekly VOC is a list filter); +UX critique on #74 (주간 VOC → 사건 lineage → 역할·책임). +ISO 8601 weeks. Seed period remains January 2026. + +## Context + +The buyer Board already filters authorized posts by VOC type, +visibility, sort, and semantic search. The product IA names the first +buyer screen **주간 VOC**: Voice of Customer posts for one ISO week, +then open a post to read Event Lineage. + +Wall-clock “this week” is empty on the seeded January 2026 corpus +(Public post is `2026-01-01T00:00:00Z` → `2026-W01`). Inventing a +current-week empty state would hide the only authorized VOC post. +A later Voice of Customer post (for example `2026-W03`) must not be +treated as this week unless it is the latest authorized VOC week. + +Period leftover pairs, analysis runs, and TEPP remain off this +surface (ADR 0037 advanced review). This slice does not invent a +fused RankWeave score or a TEPP theta. + +## Decision + +- Board exposes a **Weekly VOC** control. Pressing it sets VOC type + to `voc` and the ISO week to the latest `YYYY-Www` among authorized + Voice of Customer posts already loaded for this account. +- An ISO-week select remains available so the buyer can name another + week present in the authorized set. Reset clears the week. +- Filtering is client-side on `created_at` via the UTC Thursday rule + (ISO 8601-1). Missing or unusable timestamps stay out of a named + week rather than being coerced. +- While Weekly VOC is pressed, next-action copy names the week and + Event Lineage: Voice of Customer posts for that week are current; + open a post to read Event Lineage. +- The week is never taken from the operator laptop clock. Seed week + `2026-W02` stays the period-report scope (ADR 0050); it is not + implied here unless a VOC post actually falls in that week. + +## Consequences + +After seed, Demo Analyst opens Board and clicks **Weekly VOC**. +Public post (`2026-W01`, Voice of Customer) stays. A same-week Voice +of Market memo and an earlier `2025-W52` Voice of Customer post +leave the list. The next action names Event Lineage. Opening that +post uses the existing popup (원문 / 5W1H / Keyman / KG / VOC / +할 일 / Event Lineage / Ask). No theta is shown. + +## References — APA 7th + +International Organization for Standardization. (2019). *ISO 8601-1:2019: +Date and time—Representations for information interchange—Part 1: Basic +rules* (confirmed 2024; Amendment 1:2022). + +Jeon, J.-J., Park, J., & Jin, I. H. (2021). Bayesian semi-parametric +item response models for analyzing large-scale survey data +(arXiv:2007.08719). https://arxiv.org/abs/2007.08719 + +Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. +World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ diff --git a/frontend/package.json b/frontend/package.json index a3e18554..c9616c3f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.11.0", + "version": "2.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 435b8ffe..1d0df384 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -115,6 +115,15 @@ margin: 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 2d52a787..f021c018 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -78,6 +78,15 @@ describe("App, authenticated", () => { deferMe?: boolean; meFailed?: boolean; postBody?: string; + 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", @@ -1028,6 +1037,7 @@ describe("App, authenticated", () => { visibility_label: "Public", created_at: "2026-01-01T00:00:00Z", }, + ...(options?.boardPosts ?? []), ], ), ); @@ -1513,6 +1523,56 @@ describe("App, authenticated", () => { expect(within(board).getByRole("button", { name: "View post: Public post" })).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("opens a post from a DAG node click", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b54db35c..fbe3939e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -80,6 +80,7 @@ import { PostBody } from "./PostBody"; import { FiveW1H } from "./components/FiveW1H"; import { subgraphForPost } from "./lineageLayout"; import { LOCALE_LABELS, SUPPORTED_LOCALES, setLocale, t, tf, useLocale } from "./i18n"; +import { isoWeekFromCreatedAt, latestIsoWeek } from "./isoWeek"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -3273,6 +3274,7 @@ function PostList({ const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); + const [weekFilter, setWeekFilter] = useState("all"); const [visibilityFilter, setVisibilityFilter] = useState("all"); const [typeFilterOptions, setTypeFilterOptions] = useState([]); const [visibilityFilterOptions, setVisibilityFilterOptions] = useState([]); @@ -3408,7 +3410,9 @@ function PostList({ .filter((post) => { const matchesType = typeFilter === "all" || post.voc_type_code === typeFilter; 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") { @@ -3417,7 +3421,29 @@ 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 !== "all" || visibilityFilter !== "all"; + const weeklyVocActive = typeFilter === "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 !== "all" || + visibilityFilter !== "all" || + weekFilter !== "all"; const totalPages = Math.max(1, Math.ceil(totalPosts / POST_PAGE_SIZE)); const pageItems: Array = totalPages <= 7 @@ -3467,6 +3493,7 @@ function PostList({ setSearchInput(""); setSearchQuery(""); setTypeFilter("all"); + setWeekFilter("all"); setVisibilityFilter("all"); setSortOrder("newest"); }} @@ -3498,6 +3525,29 @@ function PostList({ ))} + +