Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.d/2.12.0-board-weekly-voc-filter.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions docs/adr/0051-board-weekly-voc-filter.md
Original file line number Diff line number Diff line change
@@ -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/
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.11.0",
"version": "2.12.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn> & { releaseMe: () => void } {
const statusLabel: Record<string, string> = {
open: "Open",
Expand Down Expand Up @@ -1028,6 +1037,7 @@ describe("App, authenticated", () => {
visibility_label: "Public",
created_at: "2026-01-01T00:00:00Z",
},
...(options?.boardPosts ?? []),
],
),
);
Expand Down Expand Up @@ -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(<App />);

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(<App showLabPanels />);
Expand Down
62 changes: 60 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<PostFilterOption[]>([]);
const [visibilityFilterOptions, setVisibilityFilterOptions] = useState<PostFilterOption[]>([]);
Expand Down Expand Up @@ -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") {
Expand All @@ -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<number | "ellipsis"> =
totalPages <= 7
Expand Down Expand Up @@ -3467,6 +3493,7 @@ function PostList({
setSearchInput("");
setSearchQuery("");
setTypeFilter("all");
setWeekFilter("all");
setVisibilityFilter("all");
setSortOrder("newest");
}}
Expand Down Expand Up @@ -3498,6 +3525,29 @@ function PostList({
))}
</select>
</label>
<button
type="button"
className="board-weekly-voc"
aria-pressed={weeklyVocActive}
onClick={applyWeeklyVoc}
>
{t("Weekly VOC")}
</button>
<label>
{t("Filter by ISO week")}
<select
value={weekFilter}
onChange={(event) => setWeekFilter(event.target.value)}
aria-label={t("Filter by ISO week")}
>
<option value="all">{t("All weeks")}</option>
{weekOptions.map((week) => (
<option key={week} value={week}>
{week}
</option>
))}
</select>
</label>
<label>
{t("Filter by visibility")}
<select
Expand Down Expand Up @@ -3531,6 +3581,14 @@ function PostList({
</button>
)}
</form>
{weeklyVocActive ? (
<p className="board-next-action" role="status" aria-label={t("Next action")}>
{tf(
"Voice of Customer posts for {week} are current. Open a post to read Event Lineage.",
{ week: weekFilter },
)}
</p>
) : null}
{posts.length === 0 ? (
<p className="board-empty" role="status">
{hasBoardFilters
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,18 @@ describe("i18n", () => {
setLocale(locale);
expect(tf("{post} is current in Event Lineage. Read Keyman and evaluation next.", { post: "DEMO" })).toBe(expected);
});

it.each([
["ko", "2026-W01 Voice of Customer 글이 현재 표시되어 있습니다. 이벤트 계보를 읽으려면 글을 여세요."],
["zh", "2026-W01 的 Voice of Customer 文章为当前内容。打开一篇文章阅读事件谱系。"],
["ja", "2026-W01のVoice of Customer投稿が現在表示されています。イベント系譜を読むには投稿を開いてください。"],
["vi", "Các bài Voice of Customer của 2026-W01 đang hiện tại. Hãy mở một bài để đọc Dòng sự kiện."],
] as const)("formats weekly VOC next action in %s", (locale, expected) => {
setLocale(locale);
expect(
tf("Voice of Customer posts for {week} are current. Open a post to read Event Lineage.", {
week: "2026-W01",
}),
).toBe(expected);
});
});
Loading
Loading