Repository navigation
story/9.6: Challenger promises view - #184
Merged
Merged
Conversation
added 2 commits
October 20, 2026 04:51
…ts, link prior office
Gherkin first: backend/tests/features/challenger_promises.feature and its
step defs (12 scenarios, all red before app/promises/ existed). Adds
GET /v1/candidates/{id}/promises?profile_id=, the promise-side counterpart
to the vote-based /alignment endpoint for a candidate with no voting record:
- ChallengerPromisesService groups a person's `promises` rows under the
voter's own chosen `stance_id`s (the same key AlignmentReason groups by),
counting anything else as unmatched rather than dropping it.
- A prior office is read off the person's own `people.terms[]` — any term
whose district is not this candidacy's own seat — never fabricated.
- Each promise resolves to its `promise_sources` row for a real citation
(url + capture date); a promise whose source has gone missing is omitted
rather than shown uncited.
- Never recommends, endorses or ranks; computed per request, nothing stored.
96% coverage on app/promises (90%+ bar); full suite green (4038 passed).
…prior office surfaced
Gherkin first: frontend/tests/features/challenger_promises.feature (7
scenarios) and its Playwright/Cucumber steps, stubbing
GET /v1/candidates/{id}/promises at the network boundary, same posture as
candidate_detail.feature. Confirmed red-phase sanity by deliberately
breaking the empty-state copy and re-running the suite (1 scenario failed
as expected, 0 elsewhere) before restoring it.
Adds:
- src/api/promises.ts — the typed client for the new endpoint.
- src/candidate/ChallengerPromisesView.tsx — one group per voter interest
(mirrors CandidateDetailPage's stance_id grouping), a plain unmatched-count
line rather than hiding promises outside the voter's interests, a
testable/not-testable label per promise, a reused Drawer for each
promise's source citation, and a plain prior-office notice (never a
fabricated link — there is no vote-based record to link a challenger's
prior office to).
- Wires into BallotPage: "View details" now routes a challenger to this
view and an incumbent to the existing CandidateDetailPage, via a small,
backward-compatible widening of BallotView's onViewDetails callback to
also carry is_incumbent (it already has the value per row).
Full frontend pipeline green: tsc --noEmit, vitest (223 passed), and
Playwright+Cucumber (77 passed, 5 pre-existing pending, 0 failed).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #62 (story 9.6, Epic 9, 3 pts).
Acceptance criteria: "Promises are grouped by my interests; a prior record in another office is linked if one exists."
Backend —
GET /v1/candidates/{id}/promises?profile_id=backend/app/promises/models.py—ChallengerPromises,PromiseGroup,PromiseItem,PriorOffice. No score, confidence badge or ranking anywhere in the shape (extra="forbid"throughout, same posture asapp.scoring.models).backend/app/promises/service.py—ChallengerPromisesService.promises_for:promisesrows directly (notcandidate_positions, which a challenger has none of by definition — see the module docstring for why this is a separate endpoint rather than a widened/alignment).stance_id(the same keyAlignmentReasonalready groups by), loaded through the existingVoterProfileService.stance_idyet, or one the voter didn't choose, is counted inunmatched_countrather than dropped silently.promise_sourcesrow for a real citation (url + capture date); a promise whose source has gone missing is omitted rather than shown uncited.people.terms[]whose district isn't this candidacy's own seat — not a new backend concept, reusing the same dataapp.ballot.service._officeholders_fromalready reads for "who currently holds a seat". Never fabricated; empty when none exists.backend/app/promises/router.py— thin FastAPI router, copyingapp.scoring.router's dependency-injection and 404-mapping pattern line for line.app.main;tests/test_health_unit.py's route inventory updated.Frontend —
ChallengerPromisesViewfrontend/src/api/promises.ts— typed client mirroring the backend models.frontend/src/candidate/ChallengerPromisesView.tsx— one group per matching interest (same structureCandidateDetailPageuses forAlignmentReason[]), a plain "N more promises don't match your priorities" line (never hidden), a testable/not-testable label per promise, the existingDrawer(story 7.5) reused for each promise's source citation, and a plain prior-office notice — not a clickable link: there's no vote-based detail view for a prior office to point at, so an<a>would go nowhere real (same "never fabricate a link" ruleCitationLinkalready holds).BallotPage: "View details" now routes a challenger to this view and an incumbent to the existingCandidateDetailPage.BallotView'sonViewDetailscallback was widened to carryisIncumbentalongside the candidacy id (backward-compatible — it already had the value per row).Honest state of live data
The live
promisestable is empty. Stories 9.4/9.5 (PR #182, just merged) ran the real promise extractor against all 6 livepromise_sourcesrows and found it currently produces zero genuine promises — every candidate output today, including this view, will show its honest empty state ("no promises on file yet"). This view is built and tested against realistic fixture data (standard for Gherkin/BDD), not verified against populated live data, because there is no populated live data to verify against right now. No live/integration script was run or fabricated for this story.Test plan
backend/tests/features/challenger_promises.feature(12 scenarios) andfrontend/tests/features/challenger_promises.feature(7 scenarios), committed before any production code.pytest -q— 4038 passed, 3 pre-existing xfailed, 0 failed.ruff check ./ruff format --check .clean. Coverage onapp/promises: 96% (bar is 90%).tsc --noEmitclean;vitest run— 223 passed;npm run test:bdd(Playwright + Cucumber) — 77 passed, 5 pre-existing pending, 0 failed.Judgment calls
stance_id, not an arbitrary category. MatchesAlignmentReason's own grouping exactly, so a voter who has seen the incumbent view recognizes the same structure./alignmentendpoint untouched rather than widening it to include promises. A promise is unscored evidence; a position is a scored one. Folding them into one response risked a voter reading a promise as if it carried the same weight as a measured vote — seeapp.promises.models's module docstring.BallotPagerather than leaving the component unreachable from the live app, since every other per-candidate detail screen in this codebase (CandidateDetailPage,CompareView) is driven end-to-end through the real app shell in its own BDD suite, not rendered standalone.Open questions for human review
is_incumbentfor this race.unmatched_counthas no drill-down (a voter can't see what the unmatched promises actually say). That felt like real scope creep for 3 points; flagging in case product wants it sooner.