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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/0.89.0-leftover-calendar.md
Original file line number Diff line number Diff line change
@@ -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.
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).

## [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
Expand Down
58 changes: 58 additions & 0 deletions docs/adr/0030-leftover-pair-on-calendar-commitment.md
Original file line number Diff line number Diff line change
@@ -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
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": "0.75.0",
"version": "0.89.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);

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(<App />);
Expand Down
61 changes: 54 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
type PostSummary,
type RankingList,
type RelatedNode,
type LeftoverPair,
type VocEvidence,
} from "./api";
import { LineageDag } from "./LineageDag";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1373,9 +1384,11 @@ function RankingsPanel({

function CalendarPanel({
accessToken,
leftoverPairs,
onSelectPost,
}: {
accessToken: string;
leftoverPairs: LeftoverPair[];
onSelectPost: (postId: string) => void;
}) {
const [commitments, setCommitments] = useState<CalendarEntry[] | null>(null);
Expand All @@ -1399,11 +1412,18 @@ function CalendarPanel({
</p>
) : (
<ul className="ticket-list">
{commitments.map((entry) => (
{commitments.map((entry) => {
const leftoverForEntry = leftoverPairs.filter((pair) => pair.post_id === entry.post_id);
const leftoverCaption = leftoverCaptionForPairs(leftoverForEntry);
return (
<li key={entry.issue_ticket_id} className="ticket-list-item">
<button
className="post-list-item"
aria-label={`Open commitment for: ${entry.post_title}`}
aria-label={
leftoverCaption
? `Open commitment for: ${entry.post_title} (${leftoverCaption})`
: `Open commitment for: ${entry.post_title}`
}
onClick={() => onSelectPost(entry.post_id)}
>
<span className="ticket-title">{entry.commitment_summary ?? entry.ticket_title}</span>
Expand All @@ -1412,9 +1432,18 @@ function CalendarPanel({
{entry.ticket_status_label ?? entry.ticket_status_code}
</span>
<span className="post-badge">due {entry.due_date}</span>
{leftoverForEntry.map((pair) => (
<span
key={`${pair.pair_kind}:${pair.criterion_code}`}
className="post-badge"
>
{leftoverRowLabel(pair.pair_kind)} · {criterionShortLabel(pair.criterion_code)}
</span>
))}
</button>
</li>
))}
);
})}
</ul>
)}
</section>
Expand All @@ -1425,10 +1454,12 @@ function ReportsPanel({
accessToken,
canRebuild,
onSelectPost,
onLeftoverPairsChange,
}: {
accessToken: string;
canRebuild: boolean;
onSelectPost: (postId: string) => void;
onLeftoverPairsChange: (pairs: LeftoverPair[]) => void;
}) {
const [grouping, setGrouping] = useState("process_unit");
const [period, setPeriod] = useState("2026-W02");
Expand All @@ -1455,9 +1486,13 @@ function ReportsPanel({
setPayload(reports);
setIndex(periods);
setComparison(compared);
onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? []));
})
.catch((err) => setError(String(err)));
}, [accessToken, grouping, period]);
.catch((err) => {
setError(String(err));
onLeftoverPairsChange([]);
});
}, [accessToken, grouping, period, onLeftoverPairsChange]);

async function handleRebuild() {
setRebuilding(true);
Expand All @@ -1472,8 +1507,10 @@ function ReportsPanel({
setPayload(reports);
setIndex(periods);
setComparison(compared);
onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? []));
} catch (err) {
setError(String(err));
onLeftoverPairsChange([]);
} finally {
setRebuilding(false);
}
Expand Down Expand Up @@ -1658,6 +1695,7 @@ function PostList({ accessToken }: { accessToken: string }) {
const [graph, setGraph] = useState<LineageGraph | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
const [leftoverPairs, setLeftoverPairs] = useState<LeftoverPair[]>([]);
const [canRebuild, setCanRebuild] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [rebuildError, setRebuildError] = useState<string | null>(null);
Expand Down Expand Up @@ -1690,8 +1728,17 @@ function PostList({ accessToken }: { accessToken: string }) {
return (
<>
<RankingsPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<CalendarPanel accessToken={accessToken} onSelectPost={setSelectedPostId} />
<ReportsPanel accessToken={accessToken} canRebuild={canRebuild} onSelectPost={setSelectedPostId} />
<CalendarPanel
accessToken={accessToken}
leftoverPairs={leftoverPairs}
onSelectPost={setSelectedPostId}
/>
<ReportsPanel
accessToken={accessToken}
canRebuild={canRebuild}
onSelectPost={setSelectedPostId}
onLeftoverPairsChange={setLeftoverPairs}
/>
<section className="popup-section lineage-home">
<div className="lineage-home-header">
<h2>Event Lineage</h2>
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@
"sentence_excerpts",
]

__version__ = "0.75.0"
__version__ = "0.89.0"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
version = "0.75.0"
version = "0.89.0"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
license = { text = "MIT" }
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading