From 43756e79a5a7cf968d25e2dc948912170336fe64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:12:19 +0000 Subject: [PATCH] feat: name Weekly VOC on Board as an ISO-week list filter (v2.12.0) Buyer Board now keeps Voice of Customer posts for the latest authorized ISO week and names Event Lineage as the next action. Seed Public post stays in 2026-W01. Do not use the wall-clock week. Do not invent a theta. --- AGENTS.md | 4 ++ ARCHITECTURE.md | 6 +- CHANGELOG.d/2.12.0-board-weekly-voc-filter.md | 5 ++ CHANGELOG.md | 10 +++ CLAUDE.md | 7 ++ docs/adr/0051-board-weekly-voc-filter.md | 65 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 9 +++ frontend/src/App.test.tsx | 60 +++++++++++++++++ frontend/src/App.tsx | 62 +++++++++++++++++- frontend/src/i18n.test.ts | 14 ++++ frontend/src/i18n.ts | 20 ++++++ frontend/src/isoWeek.test.ts | 28 ++++++++ frontend/src/isoWeek.ts | 30 +++++++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- 16 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/2.12.0-board-weekly-voc-filter.md create mode 100644 docs/adr/0051-board-weekly-voc-filter.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 2b5c63795..f73afb386 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 aa8d256d6..84e9f0154 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 000000000..0306dbb85 --- /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 f5719013a..ca2f03719 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 1bcf50763..a285fa110 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 000000000..13306e384 --- /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 a3e185542..c9616c3fa 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 435b8ffeb..1d0df384b 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 2d52a7878..f021c0181 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 b54db35c4..fbe3939e1 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({ ))} + +