From c790e3ef6834278469effc0089505e41af869549 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 07:17:37 +0000
Subject: [PATCH] feat: name leftover criterion on a matching calendar
commitment (v0.89.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After seed, leftover calendar commitments read Closest leftover ·
sales-lead next to the due date. Click opens that post. A commitment
that is not a leftover pair stays unmarked. Empty calendar stays
fail-closed. Never invent a fused score or a theta.
---
AGENTS.md | 5 +-
ARCHITECTURE.md | 4 +-
CHANGELOG.d/0.89.0-leftover-calendar.md | 8 +++
CHANGELOG.md | 10 +++
...30-leftover-pair-on-calendar-commitment.md | 58 +++++++++++++++
frontend/package.json | 2 +-
frontend/src/App.test.tsx | 70 +++++++++++++++++++
frontend/src/App.tsx | 61 ++++++++++++++--
lineageweave/__init__.py | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
11 files changed, 210 insertions(+), 14 deletions(-)
create mode 100644 CHANGELOG.d/0.89.0-leftover-calendar.md
create mode 100644 docs/adr/0030-leftover-pair-on-calendar-commitment.md
diff --git a/AGENTS.md b/AGENTS.md
index c790995c1..32e0b0ab4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,12 +75,13 @@ in the same spirit) -- never against real data, per the hard rule above.
against a live local stack (`make up`) and self-skip without one -- see
[README.md](README.md#local-product-stack-docker-compose).
-Period leftover pairs (ADR 0017 / 0018) are computed in
+Period leftover pairs (ADR 0017 / 0018 / 0030) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Gabriel factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
-a click opens that post.
+a click opens that post. A calendar commitment that is a leftover
+pair also names the leftover criterion next to the due date.
`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f8a83ceb1..d2146db35 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -122,7 +122,9 @@ flowchart LR
`rankweave_client.py`'s default transport raises
`RankWeaveNotAvailable`. `GET /api/rankings` then returns
`rankweave_not_available` and an empty ranking list. Hidden posts
- are omitted from every channel. See ADR 0024.
+ are omitted from every channel. See ADR 0024. A calendar
+ commitment that is also a leftover pair names that criterion
+ (ADR 0030).
## Standards and citations
diff --git a/CHANGELOG.d/0.89.0-leftover-calendar.md b/CHANGELOG.d/0.89.0-leftover-calendar.md
new file mode 100644
index 000000000..d3a9d4998
--- /dev/null
+++ b/CHANGELOG.d/0.89.0-leftover-calendar.md
@@ -0,0 +1,8 @@
+# 0.89.0 — Leftover badge on a matching calendar commitment
+
+## Added
+
+- Calendar commitments that are leftover pairs name the leftover
+ criterion (ADR 0030). After `make seed`, the leftover commitment
+ reads **Closest leftover · sales-lead** next to the due date;
+ click opens that post. An empty calendar stays fail-closed.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bfcaa28f..551e6d84a 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).
+## [0.89.0] - 2026-08-18
+
+### Added
+
+- Calendar commitments that are leftover pairs name the leftover
+ criterion (ADR 0030). After `make seed`, the leftover commitment
+ reads **Closest leftover · sales-lead** next to the due date;
+ click opens that post. An empty calendar stays fail-closed.
+ Never invent a fused score or a theta.
+
## [0.75.0] - 2026-08-17
### Added
diff --git a/docs/adr/0030-leftover-pair-on-calendar-commitment.md b/docs/adr/0030-leftover-pair-on-calendar-commitment.md
new file mode 100644
index 000000000..685b181f9
--- /dev/null
+++ b/docs/adr/0030-leftover-pair-on-calendar-commitment.md
@@ -0,0 +1,58 @@
+# ADR 0030 — Name leftover on a matching calendar commitment
+
+**Decision status:** Accepted
+**Date:** 2026-08-18
+
+## Context
+
+ADR 0018 puts leftover pairs above the period-report member list.
+Calendar sits under Rankings and lists due commitments (Allen
+intervals). A buyer who starts from Calendar still sees only the
+commitment title, post title, status, and due date. The leftover
+post is already on that list when it has a due ticket; the row just
+does not name the leftover criterion.
+
+Do not invent a second leftover store. Do not invent a fused score
+or a theta. An empty calendar stays **No upcoming commitments**.
+
+## Decision
+
+When an authorized leftover pair names a calendar commitment, that
+button shows `Closest leftover · {criterion}` or
+`Farthest leftover · {criterion}` and includes the same caption in
+its accessible name.
+
+A commitment that is not a leftover pair stays unmarked. A leftover
+pair for a hidden post never reaches Calendar (ADR 0017 ABAC). A
+report fetch error clears leftover badges and leaves the due list
+intact — never an invented pair.
+
+After `make seed`, the leftover calendar commitment reads **Closest
+leftover · sales-lead** next to the due date; click still opens
+that post.
+
+Leftover evidence is the same authorized `leftover_pairs` already
+on the period-report payload.
+
+## Consequences
+
+Leftover buttons above the member list stay (ADR 0018). Rankings
+leftover badges stay on #252 / ADR 0029. Member badges stay on
+#234 / ADR 0028. This slice only labels the already-visible
+calendar commitment.
+
+## Related
+
+Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md) and
+[ADR 0018](0018-leftover-pair-report-ui.md).
+
+## References
+
+Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping
+unobserved item–respondent interactions: A latent space item response
+model with interaction map. *Psychometrika, 86*(2), 378–403.
+https://doi.org/10.1007/s11336-021-09762-5
+
+Allen, J. F. (1983). Maintaining knowledge about temporal intervals.
+*Communications of the ACM, 26*(11), 832–843.
+https://doi.org/10.1145/182.358434
diff --git a/frontend/package.json b/frontend/package.json
index 575b7c586..51401bfd3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.75.0",
+ "version": "0.89.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index a32a26403..6756cf914 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1330,6 +1330,76 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});
+ it("names leftover criterion on a matching calendar commitment", async () => {
+ stubBackend({
+ calendarCommitments: [
+ {
+ issue_ticket_id: "ticket-a100",
+ post_id: "post-1",
+ ticket_status_code: "open",
+ ticket_status_label: "Open",
+ ticket_title: "Send Northridge Grid the revised quote",
+ assigned_account_id: null,
+ due_date: "2026-01-12",
+ commitment_summary: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ post_title: "Public post",
+ },
+ {
+ issue_ticket_id: "ticket-b200",
+ post_id: "post-2",
+ ticket_status_code: "open",
+ ticket_status_label: "Open",
+ ticket_title: "Send Westfield Power the revised specification",
+ assigned_account_id: null,
+ due_date: "2026-01-14",
+ commitment_summary: null,
+ created_at: "2026-01-02T00:00:00Z",
+ updated_at: "2026-01-02T00:00:00Z",
+ post_title: "Specification revision requested",
+ },
+ {
+ issue_ticket_id: "ticket-c300",
+ post_id: "post-9",
+ ticket_status_code: "open",
+ ticket_status_label: "Open",
+ ticket_title: "Confirm Riverbend delivery window",
+ assigned_account_id: null,
+ due_date: "2026-01-16",
+ commitment_summary: null,
+ created_at: "2026-01-03T00:00:00Z",
+ updated_at: "2026-01-03T00:00:00Z",
+ post_title: "Riverbend calendar commitment",
+ },
+ ],
+ });
+ render();
+
+ const leftoverCommitment = await screen.findByRole("button", {
+ name: /open commitment for: public post \(closest leftover · sales-lead\)/i,
+ });
+ expect(leftoverCommitment).toHaveTextContent("Send Northridge Grid the revised quote");
+ expect(leftoverCommitment).toHaveTextContent("Closest leftover · sales-lead");
+ expect(leftoverCommitment).toHaveAccessibleName(
+ "Open commitment for: Public post (Closest leftover · sales-lead)",
+ );
+ expect(
+ await screen.findByRole("button", {
+ name: /open commitment for: specification revision requested \(farthest leftover · negative\)/i,
+ }),
+ ).toHaveTextContent("Farthest leftover · negative");
+ const unmarked = screen.getByRole("button", {
+ name: /^open commitment for: riverbend calendar commitment$/i,
+ });
+ expect(unmarked).toHaveTextContent("due 2026-01-16");
+ expect(unmarked).not.toHaveTextContent("leftover");
+
+ await userEvent.click(leftoverCommitment);
+
+ await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+ });
+
it("shows the calibrated period-report mean theta on the home page", async () => {
stubBackend();
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6056e5eb4..703f8fa8e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -52,6 +52,7 @@ import {
type PostSummary,
type RankingList,
type RelatedNode,
+ type LeftoverPair,
type VocEvidence,
} from "./api";
import { LineageDag } from "./LineageDag";
@@ -82,6 +83,16 @@ function criterionShortLabel(itemCode: string): string {
return CRITERION_SHORT_LABEL[itemCode] ?? itemCode;
}
+function leftoverRowLabel(pairKind: string): string {
+ return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover";
+}
+
+function leftoverCaptionForPairs(pairs: LeftoverPair[]): string {
+ return pairs
+ .map((pair) => `${leftoverRowLabel(pair.pair_kind)} · ${criterionShortLabel(pair.criterion_code)}`)
+ .join("; ");
+}
+
// This popup's layout follows the textual product brief (Korean summary,
// key events, R&R, Event Lineage, Keyman, in-popup chat with a sliding
// evidence panel) rather than the referenced Figma frame's actual pixel
@@ -1373,9 +1384,11 @@ function RankingsPanel({
function CalendarPanel({
accessToken,
+ leftoverPairs,
onSelectPost,
}: {
accessToken: string;
+ leftoverPairs: LeftoverPair[];
onSelectPost: (postId: string) => void;
}) {
const [commitments, setCommitments] = useState(null);
@@ -1399,11 +1412,18 @@ function CalendarPanel({