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({ ))} + +