diff --git a/docs/features/catalog-stats.md b/docs/features/catalog-stats.md index f9552de59..24ee77af1 100644 --- a/docs/features/catalog-stats.md +++ b/docs/features/catalog-stats.md @@ -139,19 +139,27 @@ how to frame the numbers; the backend never pre-computes that choice away. ## Cards vs. votes (`distinctCardsWithHumanVotes`, `distinctCardsRoutedToReview`, `distinctCardsRoutedToReviewWithHumanVotes`) -Added 2026-07-29 so a future front-page consumer can render a -cards-over-cards participation ratio instead of votes-over-cards. Before -this trio of fields, the only human-activity numerator available was -`humanVotes.total` (a vote count) against a `total`/`confirmable` -denominator that is card-counted - dividing the two over-counts -participation, since one card can carry several independent human votes -(a printing tag, an artist vote, and a descriptor tag are three separate -votes on the same card). `frontend/src/features/stats/ ParticipationGraph.tsx`'s own module docstring documents this exact -`humanVotes.total / total` ratio as the approximation it deliberately -avoids rendering (measured 2026-07-29, ≈0.1%, worse once machine votes -grow) - these fields make an exact cards/cards ratio possible without -that pitfall. Wiring the graph itself up to these fields is a separate, -follow-up task (this pass only adds the backend counts). +Added 2026-07-29 so a front-page consumer can render a cards-over-cards +participation ratio instead of votes-over-cards. Before this trio of +fields, the only human-activity numerator available was `humanVotes.total` +(a vote count) against a `total`/`confirmable` denominator that is +card-counted - dividing the two over-counts participation, since one card +can carry several independent human votes (a printing tag, an artist +vote, and a descriptor tag are three separate votes on the same card). +`frontend/src/features/stats/ParticipationGraph.tsx`'s own module +docstring documents this exact `humanVotes.total / total` ratio as the +approximation it originally shipped with (measured 2026-07-29, ≈0.1%, +worse once machine votes grow) - these fields make an exact cards/cards +ratio possible without that pitfall. + +**Consumer swap shipped (2026-07-29, this task):** the homepage graph's +gate and drawn series (`frontend/src/features/stats/humanProgressReveal.ts`'s +`humanProgressRatioPercent`) now compute +`distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview` +instead of `humanVotes.total / total` - the old votes-over-cards path (and +its units-mismatch caveat) is deleted, not kept as a fallback. See "The +gated human-progress series" below for the full consumer-side writeup, +including the live-API-skew guard this swap requires. - **`distinctCardsWithHumanVotes`** - distinct `card_id` across `CardPrintingTag`/`CardArtistVote`/`CardTagVote`, filtered to @@ -242,20 +250,33 @@ The owner's original idea for the homepage graph was a literal progress bar the ratio drops below `HUMAN_VOTE_REVEAL_PERCENT - HUMAN_VOTE_REVEAL_HYSTERESIS_PP`. This prevents the homepage layout from flipping on every load if the ratio sits right on the boundary. - **Single accessor, single computation**: `humanProgressRatioPercent()` is - the one place `humanVotes.total / total` is computed; both the reveal gate - and the drawn bar's fill width read the SAME value, computed once per - render in `ParticipationGraph.tsx`. Gating on one number and drawing - another would let the series unlock while still rendering as a hairline - - the exact failure this feature exists to prevent. -- **Units caveat**: `participation.humanVotes.total` counts _votes_; - `participation.total` counts _cards_. One card can carry several human - votes (printing tag + artist + tag), so this ratio over-counts relative to - "distinct cards with at least one human vote" - it is an approximation of - catalog coverage, not an exact measure. Documented at - `humanProgressRatioPercent`'s own definition; swapping in an exact - backend field (distinct cards carrying >= 1 human vote) later is a - one-line change to that function's body only. That field does not exist - yet - not built as part of this pass. + the one place the ratio is computed; both the reveal gate and the drawn + bar's fill width read the SAME value, computed once per render in + `ParticipationGraph.tsx`. Gating on one number and drawing another would + let the series unlock while still rendering as a hairline - the exact + failure this feature exists to prevent. +- **Card-denominated ratio (2026-07-29 consumer swap)**: + `humanProgressRatioPercent()` computes + `distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview` + - cards over cards, with the numerator a proper subset of the + denominator by construction (see "Cards vs. votes" above and that + field's own backend test). This REPLACES the original + `humanVotes.total / total` votes-over-cards approximation entirely - the + old path (and its units-mismatch caveat, which no longer applies) is + deleted, not kept as a fallback. +- **Live-API-skew guard**: `humanProgressRatioPercent()` returns `null` + (never `NaN`) whenever the three card-denominated fields aren't all + present, finite numbers on the given object - the guaranteed state of + every `1/catalogStats/` response until the production API is deployed + past PR #566 (`store/api.ts`'s `getCatalogStats` trusts the fetch + response's shape directly, with no runtime validation, so a real + response missing these fields reaches this function exactly as typed). + `shouldRevealHumanProgress` treats `null` as unconditionally + below-threshold - `ParticipationGraph.tsx` renders its below-threshold + design, unchanged, with no placeholder/NaN/thrown error. Covered by + `humanProgressReveal.test.ts`'s "live-skew guard" describe block and + `ParticipationGraph.test.tsx`'s matching describe block (constructs a + `Participation` object with the three fields deleted). - **Below the threshold, nothing changes**: no placeholder, no "0% complete" hint, no teaser - the series simply does not exist, and the graph is byte-for-byte the design described above. **At or above it**, @@ -265,6 +286,38 @@ The owner's original idea for the homepage graph was a literal progress bar page's CTA buttons already use). Neither state ever renders a percentage, or `participation.total` itself, as literal text - the ratio only ever drives the bar's width. +- **The headline flips too (2026-07-29 consumer-swap directive, item 2)**: + below threshold, the headline/copy is the `confirmable`-count call to + action, unchanged from today. At/above threshold, the headline switches + to a "cards the machine routed to people" framing - distinct copy, not + the below-threshold sentences with a bar bolted on. The flip is driven + by the same hysteresis-gated `humanProgressRevealed` boolean as the bar + itself, which means it is NOT a one-way latch: since + `distinctCardsRoutedToReview` only ever grows, the ratio can retreat + below the hysteresis floor even while people are actively voting + (denominator outrunning numerator), and the headline would revert on + the next load. Known, accepted limitation this pass - tracked as + orchestration issue #22. +- **A count that only ever rises (item 3)**: because + `distinctCardsRoutedToReview` (the ratio's denominator) grows on its + own, the ratio - and the bar's width - can fall even on a day people are + actively contributing. `ParticipationGraph.tsx`'s `ReviewedCardCount` + renders `distinctCardsRoutedToReviewWithHumanVotes` (the ratio's + numerator) as a plain count beside the bar specifically because that + number never decreases - see that component's own comment for why it + is not "redundant with the bar." +- **The in-session "you contributed" dot (item 4)**: the dot-matrix's + dashed "you would be the Nth" mark becomes a filled, green, + "you're one of them" dot plus a short thank-you once THIS browser tab + has cast a vote this session. Driven entirely by in-session Redux state + (`frontend/src/features/stats/sessionContributionSlice.ts`, dispatched + from `QuestionFeed.tsx`'s existing `bumpSessionCount` on every + successful vote) - deliberately NOT `localStorage` and NOT a new + "has this anonymous_id voted" endpoint (see that slice's own module + comment for why both were rejected). Colour is `var(--bs-success)` - + `colors.ts`'s existing status-good token, not a new hue - paired with + changed accessible-name/title text and a visible thank-you paragraph, + so the state never depends on colour alone. - A separate, always-present addition (not gated by this threshold): a "Start with one card" button immediately after the dot-matrix's hollow "you could be next" mark, linking to `/whatsthat`, gated on @@ -333,9 +386,13 @@ imports the msw node server; see that file's own header comment and graph reads correctly under both today's real vote ratio and the post-machine-sweep ratio, with no percentage ever computed against `total` - see that file's own module comment; also covers the "Start with -one card" CTA gating and the gated human-progress series, third fixture -`participationAtRevealThreshold`), and +one card" CTA gating, the gated human-progress series and its +routed-to-review headline flip, the always-rising +`distinctCardsRoutedToReviewWithHumanVotes` count, the live-skew guard +(the three card-denominated fields deleted entirely), and the in-session +green-dot/thank-you mechanic), and `frontend/src/features/stats/humanProgressReveal.test.ts` (the reveal -threshold's own hysteresis band, pure-function level - see "The gated +threshold's own hysteresis band and the card-denominated accessor's +live-skew `null` guard, both pure-function level - see "The gated human-progress series" above). Playwright: `frontend/tests/Stats.spec.ts` and `frontend/tests/ParticipationGraph.spec.ts`. diff --git a/frontend/src/features/questionFeed/QuestionFeed.test.tsx b/frontend/src/features/questionFeed/QuestionFeed.test.tsx index d2e5e8fa7..5041fbff1 100644 --- a/frontend/src/features/questionFeed/QuestionFeed.test.tsx +++ b/frontend/src/features/questionFeed/QuestionFeed.test.tsx @@ -553,6 +553,77 @@ describe("QuestionFeed", () => { expect(screen.queryByTestId("question-feed-rate-limited")).toBeNull(); }); + // The seam ParticipationGraph.test.tsx's own in-session green-dot tests can't see: those + // dispatch `recordSessionContribution()` directly and prove the homepage dot responds to it, + // but never prove a real vote is what fires that dispatch. THIS test is the other half - it + // proves `bumpSessionCount()` (the single choke point every successful vote in this feed + // already flows through) actually dispatches `recordSessionContribution()` into the real + // store, via a real vote-casting call (`APISubmitPrintingTag`), not a mocked one. If a future + // refactor drops that dispatch, or `bumpSessionCount` stops being the choke point, this is the + // test that fails - and its name says what broke. + it("casting a vote turns the homepage's in-session 'you contributed' dot green - a successful printing-tag vote dispatches recordSessionContribution", async () => { + server.use(questionFeedOnce()); + let submittedIsNoMatch: boolean | undefined; + server.use( + http.post(buildRoute("2/submitPrintingTag/"), async ({ request }) => { + const body = (await request.json()) as { isNoMatch?: boolean }; + submittedIsNoMatch = body.isNoMatch; + return HttpResponse.json( + { resolvedPrinting: null, isNoMatch: true, voteTally: [] }, + { status: 200 } + ); + }) + ); + const store = renderFeed(); + await revealCard(); + + expect(store.getState().sessionContribution.hasContributedThisSession).toBe( + false + ); + + fireEvent.click(await screen.findByTestId("question-feed-no-match")); + await waitFor(() => expect(submittedIsNoMatch).toBe(true)); + + await waitFor(() => + expect( + store.getState().sessionContribution.hasContributedThisSession + ).toBe(true) + ); + }); + + it("a FAILED printing-tag vote does NOT turn the homepage's in-session dot green - recordSessionContribution is only dispatched on success", async () => { + server.use(questionFeedOnce()); + server.use(submitTagVoteResolvesToApply); + server.use( + http.post(buildRoute("2/submitPrintingTag/"), () => + HttpResponse.json( + { + name: "Bad Request", + message: "This card has already been resolved.", + }, + { status: 400 } + ) + ) + ); + const store = renderFeed(); + await revealCard(); + + const noMatchButton = await screen.findByTestId("question-feed-no-match"); + fireEvent.click(noMatchButton); + + // Wait on the failure's own observable side-effect (the existing toast assertion pattern + // above) so this assertion isn't racing the rejected promise. + await waitFor(() => { + const notifications = Object.values( + store.getState().toasts.notifications + ); + expect(notifications).toHaveLength(1); + }); + expect(store.getState().sessionContribution.hasContributedThisSession).toBe( + false + ); + }); + it("clears a stale rate-limit banner once the next item loads", async () => { let feedFetchCount = 0; server.use( diff --git a/frontend/src/features/questionFeed/QuestionFeed.tsx b/frontend/src/features/questionFeed/QuestionFeed.tsx index a20b12f0c..f31419ee7 100644 --- a/frontend/src/features/questionFeed/QuestionFeed.tsx +++ b/frontend/src/features/questionFeed/QuestionFeed.tsx @@ -82,6 +82,7 @@ import { ZoomableThumbnail, } from "@/features/printingTags/cardPanel"; import { WhatsThatWords } from "@/features/questionFeed/WhatsThatWords"; +import { recordSessionContribution } from "@/features/stats/sessionContributionSlice"; import { APIGetQuestionFeed, APISubmitIllustrationVote, @@ -712,8 +713,15 @@ export function QuestionFeed() { // same as every other piece of in-flight feed state here), never meant to survive a "clear // site data" test the way persisted state would need to. const [sessionTaggedCount, setSessionTaggedCount] = useState(0); - const bumpSessionCount = () => + const bumpSessionCount = () => { setSessionTaggedCount((previous) => previous + 1); + // 2026-07-29 directive item 4 - the homepage's dashed "you would be the Nth" dot turns into + // a filled, green thank-you once THIS client has cast a vote. This is the single place every + // successful vote in this feed already funnels through, so it's also the single place that + // in-session fact gets recorded - see sessionContributionSlice.ts's own module comment for + // why this is a Redux dispatch (not localStorage, not a new endpoint). + dispatch(recordSessionContribution()); + }; // ANNEX C's "confirm-lands" micro-feedback - a brief fade-in on a successful cast, shown // while the next item's fetch is already in flight (advance() below never adds an artificial // delay of its own - the interaction contract's "advance immediately" behavior is unchanged; diff --git a/frontend/src/features/stats/ParticipationGraph.test.tsx b/frontend/src/features/stats/ParticipationGraph.test.tsx index 685142ad7..27f0c5329 100644 --- a/frontend/src/features/stats/ParticipationGraph.test.tsx +++ b/frontend/src/features/stats/ParticipationGraph.test.tsx @@ -7,18 +7,29 @@ * first place, so a worse ratio next week can't break it. * * All renders now go through a real Redux `Provider` (mirroring Navbar.test.tsx's own precedent) - * - `ParticipationGraph` reads `useRemoteBackendConfigured` for the new "Start with one card" CTA - * (2026-07-29 owner ruling) added alongside this guard. + * - `ParticipationGraph` reads `useRemoteBackendConfigured` for the "Start with one card" CTA and + * `useHasContributedThisSession` (`sessionContributionSlice.ts`) for the in-session green-dot + * mechanic, both real store-backed selectors. + * + * 2026-07-29 consumer-swap directive additions: the gate/bar now read the card-denominated ratio + * (`humanProgressRatioPercent` in `humanProgressReveal.ts`), so `justBelowThreshold`/ + * `justAboveThreshold` below nudge `distinctCardsRoutedToReview` (that ratio's own denominator), + * not `total` (which the old votes-over-cards ratio depended on and the new one doesn't read at + * all). Also covers: the always-rising `distinctCardsRoutedToReviewWithHumanVotes` count, the + * live-skew guard (the three card-denominated fields absent entirely), and the in-session + * green-dot/thank-you mechanic. */ -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import React from "react"; import { Provider } from "react-redux"; +import { Participation } from "@/common/schema_types"; import { localBackend, noBackend } from "@/common/test-constants"; import { HUMAN_VOTE_REVEAL_PERCENT, humanProgressRatioPercent, } from "@/features/stats/humanProgressReveal"; +import { recordSessionContribution } from "@/features/stats/sessionContributionSlice"; import { participationAtRevealThreshold, participationCurrentRatio, @@ -140,18 +151,21 @@ describe("ParticipationGraph - the 'Start with one card' CTA", () => { // 2026-07-29 owner ruling: the threshold-gated human-progress series // (features/stats/humanProgressReveal.ts). Below HUMAN_VOTE_REVEAL_PERCENT the page is -// byte-for-byte the design covered by the guard above; at/above it, the series joins. +// byte-for-byte the design covered by the guard above; at/above it, the series joins and the +// headline flips to the routed-to-review framing (2026-07-29 consumer-swap directive, item 2). describe("ParticipationGraph - threshold-gated human-progress series", () => { // Derived from the shared participationAtRevealThreshold fixture (exactly at - // HUMAN_VOTE_REVEAL_PERCENT's default of 10%, 237/2_370) by nudging `total` - the same - // humanVotes/distinctHumanVoters, only the ratio moves. + // HUMAN_VOTE_REVEAL_PERCENT's default of 10%, 100/1_000 card-denominated) by nudging + // `distinctCardsRoutedToReview` - the new ratio's own denominator (the old `total`-nudging + // trick no longer moves this ratio at all, since the card-denominated ratio never reads + // `total`). const justBelowThreshold = { ...participationAtRevealThreshold, - total: 2_371, // 237 / 2_371 ≈ 9.996% - just under 10% + distinctCardsRoutedToReview: 1_001, // 100 / 1_001 ≈ 9.99% - just under 10% }; const justAboveThreshold = { ...participationAtRevealThreshold, - total: 2_369, // 237 / 2_369 ≈ 10.004% - just over 10% + distinctCardsRoutedToReview: 999, // 100 / 999 ≈ 10.01% - just over 10% }; it("just below threshold: the series does not exist - no placeholder, no teaser, unchanged headline/copy", () => { @@ -167,7 +181,7 @@ describe("ParticipationGraph - threshold-gated human-progress series", () => { ).toBeInTheDocument(); }); - it("at exactly the threshold: the series joins the graph, one axis, no percentage/total text", () => { + it("at exactly the threshold: the series joins the graph, one axis, no percentage/total text, and the headline flips to the routed-to-review framing", () => { const { container } = renderWithBackend(participationAtRevealThreshold); expect( screen.getByTestId("participation-graph-human-progress") @@ -176,7 +190,9 @@ describe("ParticipationGraph - threshold-gated human-progress series", () => { screen.getByTestId("participation-graph-human-progress-bar") ).toBeInTheDocument(); expect( - screen.getByText("People are turning that into progress") + screen.getByText( + "People are keeping up with what the machine routes to them" + ) ).toBeInTheDocument(); // Same hard constraints as the below-threshold guard above: still no "%" and still no // `total` rendered as literal text, even with the series visible. @@ -209,4 +225,92 @@ describe("ParticipationGraph - threshold-gated human-progress series", () => { screen.getByTestId("participation-graph-join-dot") ).toBeInTheDocument(); }); + + // 2026-07-29 consumer-swap directive, item 3 - the always-rises reward count beside the bar. + it("the revealed state shows the always-rising distinctCardsRoutedToReviewWithHumanVotes count as a plain count, never a percentage", () => { + const { container } = renderWithBackend(participationAtRevealThreshold); + expect( + screen.getByTestId("participation-graph-reviewed-count") + ).toHaveTextContent( + participationAtRevealThreshold.distinctCardsRoutedToReviewWithHumanVotes.toLocaleString() + ); + expect(container.textContent).not.toMatch(/%/); + }); +}); + +// 2026-07-29 directive - "the API will not have these fields yet". A real `1/catalogStats/` +// response can, and on merge day WILL, omit `distinctCardsWithHumanVotes`/ +// `distinctCardsRoutedToReview`/`distinctCardsRoutedToReviewWithHumanVotes` entirely, even though +// `Participation`'s TS type claims they're required (`store/api.ts` trusts the fetch response's +// shape directly, no runtime validation) - this is the guard for that guaranteed live-skew +// window. +describe("ParticipationGraph - live-skew guard: the three card-denominated fields absent entirely", () => { + it("renders the below-threshold design unchanged - no NaN, no undefined, no thrown error, no percentage", () => { + const participationWithoutCardFields = { + ...participationAtRevealThreshold, + } as Partial; + delete participationWithoutCardFields.distinctCardsWithHumanVotes; + delete participationWithoutCardFields.distinctCardsRoutedToReview; + delete participationWithoutCardFields.distinctCardsRoutedToReviewWithHumanVotes; + + const { container } = renderWithBackend( + participationWithoutCardFields as Participation + ); + + expect( + screen.getByText("The catalog needs human eyes") + ).toBeInTheDocument(); + expect( + screen.queryByTestId("participation-graph-human-progress") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("participation-graph-human-progress-bar") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("participation-graph-reviewed-count") + ).not.toBeInTheDocument(); + expect(container.textContent).not.toMatch(/NaN/); + expect(container.textContent).not.toMatch(/undefined/); + expect(container.textContent).not.toMatch(/%/); + }); +}); + +// 2026-07-29 directive item 4 - the dashed "you could be next" dot becomes a filled green dot +// plus a thank-you once THIS client has cast a vote in-session (sessionContributionSlice.ts). +describe("ParticipationGraph - in-session 'you contributed' dot", () => { + it("is dashed/hollow, with no thank-you, before this client has voted", () => { + renderWithBackend(participationCurrentRatio); + const joinDot = screen.getByTestId("participation-graph-join-dot"); + expect(joinDot).toHaveAttribute("stroke-dasharray", "3 2"); + expect(joinDot).toHaveAttribute("fill", "none"); + expect( + screen.queryByTestId("participation-graph-thank-you") + ).not.toBeInTheDocument(); + }); + + it("becomes a filled green dot with a thank-you once a vote is submitted in-session", () => { + const store = setupStore({ backend: localBackend }); + render( + + + + ); + + const joinDot = screen.getByTestId("participation-graph-join-dot"); + expect(joinDot).toHaveAttribute("fill", "none"); + + // Simulates QuestionFeed.tsx's bumpSessionCount() dispatching this on a successful vote - + // the SAME store instance this component tree reads from, no remount. + act(() => { + store.dispatch(recordSessionContribution()); + }); + + expect(joinDot).toHaveAttribute("fill", "var(--bs-success)"); + expect(joinDot).not.toHaveAttribute("stroke-dasharray"); + expect( + screen.getByTestId("participation-graph-thank-you") + ).toBeInTheDocument(); + // Green is never the only signal - the accessible name/title changes too. + expect(joinDot.querySelector("title")?.textContent).toMatch(/thank you/i); + }); }); diff --git a/frontend/src/features/stats/ParticipationGraph.tsx b/frontend/src/features/stats/ParticipationGraph.tsx index e693e6d44..47365643b 100644 --- a/frontend/src/features/stats/ParticipationGraph.tsx +++ b/frontend/src/features/stats/ParticipationGraph.tsx @@ -27,16 +27,38 @@ * real ratio. `humanVotes.total` rides along as a supporting number, not a fill level. * * 2026-07-29 owner ruling, GATED addition on top of the above (this task's own directive): bring - * the original fill-bar sketch back, but only once `humanProgressRatioPercent` (the same - * `humanVotes.total / total` ratio the paragraph above reasons about) clears + * the original fill-bar sketch back, but only once `humanProgressRatioPercent` clears * `HUMAN_VOTE_REVEAL_PERCENT` (`humanProgressReveal.ts`, adjustable there / via * `NEXT_PUBLIC_HUMAN_VOTE_REVEAL_PERCENT`). This does NOT relax either rule above: rule (1) is * satisfied because the ratio is only ever rendered as a bar WIDTH/colour, never as digits or a * "%" character - `HumanProgressBar` below labels itself with real counts, same as everywhere - * else on this page; rule (2) is satisfied because the fill amount is `humanVotes.total`, a human - * quantity. Below `HUMAN_VOTE_REVEAL_PERCENT` this graph is byte-for-byte the same component - * described above - see `useHumanProgressReveal`/`HumanProgressBar` and - * `humanProgressReveal.ts`'s own module comment for the gate itself. + * else on this page; rule (2) is satisfied because the fill amount tracks + * `distinctCardsRoutedToReviewWithHumanVotes`, a human quantity. + * + * 2026-07-29 CONSUMER SWAP (this task's own directive, items 1-3): the gate and the drawn series + * both moved off the votes-over-cards approximation above and onto the exact, card-denominated + * ratio `distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview` - see + * `humanProgressRatioPercent`'s own comment in `humanProgressReveal.ts`. The old + * `humanVotes.total / total` path (and its units-mismatch caveat) is gone entirely, not kept as + * a fallback. Three consequences that show up directly in this file: + * - `humanProgressRatioPercent` can now return `null` (the LIVE production API will not carry + * these three fields until it's redeployed past PR #566 - see this task's own PR description, + * "the API will not have these fields yet"). `null` is treated as unconditionally + * below-threshold by `shouldRevealHumanProgress` - this component never needs its own + * null-check because that guard already lives in the single accessor. + * - The revealed headline FLIPS framing (below threshold: the `confirmable` opportunity/CTA + * framing, unchanged from today; at/above threshold: a "cards the machine routed to people" + * framing - see the two `

`/`

` pairs below). This flip is driven by the same + * `humanProgressRevealed` boolean as the bar itself, which means it inherits that boolean's + * hysteresis but is NOT a one-way latch: `distinctCardsRoutedToReview` only ever grows (see + * its own doc comment in `common/schema_types.ts`), so the ratio CAN retreat below the + * hysteresis floor even while people are actively voting (denominator outrunning numerator) - + * if it does, the headline reverts to the below-threshold framing on the next load. Known, + * accepted limitation, not fixed this pass - tracked as orchestration issue #22 (a "once + * earned, stays earned" one-way latch was considered and deferred). + * - `ReviewedCardCount` below exists specifically because of that same denominator-growth + * property - see its own comment for why the bar alone would understate progress on a + * net-positive day. */ import Link from "next/link"; import React from "react"; @@ -50,6 +72,7 @@ import { humanProgressRatioPercent, shouldRevealHumanProgress, } from "@/features/stats/humanProgressReveal"; +import { useHasContributedThisSession } from "@/features/stats/sessionContributionSlice"; import { useRemoteBackendConfigured } from "@/store/slices/backendSlice"; const DOT_SIZE = 14; @@ -61,54 +84,88 @@ const DOT_GAP = 6; // circles into a fixed-height row. const MAX_INDIVIDUAL_DOTS = 24; +/** + * 2026-07-29 directive item 4 - the dashed "you could be next" mark becomes a filled, green, + * "you're one of them" dot once THIS browser tab has cast a vote this session + * (`hasContributedThisSession`, `sessionContributionSlice.ts`). Cross-session memory is + * explicitly out of scope, on purpose - see that slice's own module comment for the two rejected + * approaches (localStorage; a per-user "has this anonymous_id voted" endpoint). Colour is + * `var(--bs-success)` - the site's existing status-good token (`colors.ts`'s + * `STATUS_COLORS.completed`), not a new hue - and is never the ONLY signal: the accessible + * name/title changes too, and a short thank-you paragraph renders alongside it, so the state + * doesn't depend on colour perception alone. + */ function VoterDotMatrix({ distinctHumanVoters, + hasContributedThisSession, }: { distinctHumanVoters: number; + hasContributedThisSession: boolean; }) { const dotCount = Math.min(distinctHumanVoters, MAX_INDIVIDUAL_DOTS); const overflow = distinctHumanVoters - dotCount; const dots = Array.from({ length: dotCount }); const joinDotIndex = dotCount; // drawn one slot after the last real dot const totalSlots = joinDotIndex + 1; + const joinDotTitle = hasContributedThisSession + ? "You're one of them - thank you" + : overflow > 0 + ? `You could be next (+${overflow} more contributors not pictured)` + : "You could be next"; return ( - - {dots.map((_, i) => ( + <> + + {dots.map((_, i) => ( + + A ProxyPrints contributor + + ))} + {/* the "open spot" - dashed/hollow as an invitation until this client has voted, then a + filled green "you're one of them" dot (see this function's own comment). */} - A ProxyPrints contributor + {joinDotTitle} - ))} - {/* the dashed "open spot" - the invitation, not a real count */} - - - {overflow > 0 - ? `You could be next (+${overflow} more contributors not pictured)` - : "You could be next"} - - - + + {hasContributedThisSession && ( +

+ Thanks - the card you voted on just moved one step + closer to resolved. +

+ )} + ); } @@ -140,20 +197,45 @@ function opportunityRows(participation: Participation): BarRow[] { const HUMAN_PROGRESS_BAR_WIDTH = 420; // matches HorizontalBarChart's CHART_WIDTH - same rhythm const HUMAN_PROGRESS_BAR_HEIGHT = 22; +/** + * The always-rises reward count (2026-07-29 directive, item 3). `distinctCardsRoutedToReview` - + * `humanProgressRatioPercent`'s own denominator - GROWS every time the machine sweep routes more + * cards to review, so the ratio (and therefore `HumanProgressBar`'s width, right below this) can + * FALL even on a day people are actively voting, simply because the denominator outran the + * numerator. Read the bar's width alone on a day like that and the homepage would look like + * ground is being LOST. `count` here is `distinctCardsRoutedToReviewWithHumanVotes` - the same + * ratio's numerator - which never decreases (nothing un-votes a card), so it is the one number on + * this page guaranteed to tell the true "is work accumulating" story. Do NOT remove this as + * "redundant with the bar" - the bar and this count can legitimately move in opposite directions + * at the same time. + */ +function ReviewedCardCount({ count }: { count: number }) { + return ( +

+ + {count.toLocaleString()} + {" "} + routed cards carry a person's judgment now - a number that only ever + goes up. +

+ ); +} + /** * The gated fill-bar itself - one axis (x/width), one series, no legend (house rule: a single * series needs no legend, its own caption already names it). Colour is `var(--bs-primary)`, the * SAME token `VoterDotMatrix`'s dots and every CTA button on this page already use for "a human - * did this" - not a new hue. The fill amount is `humanVotes.total` (a human quantity, rule (2) in - * this file's own module comment); `ratioPercent` (from `humanProgressRatioPercent`, computed - * exactly once by the caller - see that function's own units caveat) only ever drives the bar's - * WIDTH, never rendered as digits or a "%" anywhere here or in its accessible name. + * did this" - not a new hue. The fill amount is `reviewedWithHumanVotes` + * (`distinctCardsRoutedToReviewWithHumanVotes`, a human quantity, rule (2) in this file's own + * module comment); `ratioPercent` (from `humanProgressRatioPercent`, computed exactly once by the + * caller - see that function's own comment) only ever drives the bar's WIDTH, never rendered as + * digits or a "%" anywhere here or in its accessible name. */ function HumanProgressBar({ - humanVotesTotal, + reviewedWithHumanVotes, ratioPercent, }: { - humanVotesTotal: number; + reviewedWithHumanVotes: number; ratioPercent: number; }) { const clampedPercent = Math.min(100, Math.max(0, ratioPercent)); @@ -161,7 +243,7 @@ function HumanProgressBar({ return ( - {humanVotesTotal.toLocaleString()} human confirmations logged + {reviewedWithHumanVotes.toLocaleString()} routed cards carry a human + vote @@ -202,9 +285,11 @@ function HumanProgressBar({ * not be persisted client-side across a "clear site data"/incognito test (see * `cardbackDefaultPreference.ts`'s own comment for that precedent); a value sitting in the * hysteresis band is a genuinely open question until the NEXT real data fetch settles it, not - * something this browser should remember on its own. + * something this browser should remember on its own. `ratioPercent === null` (the live-skew + * guard, see `humanProgressRatioPercent`'s own comment) flows straight through + * `shouldRevealHumanProgress` to `false`, with no special-casing needed here. */ -function useHumanProgressReveal(ratioPercent: number): boolean { +function useHumanProgressReveal(ratioPercent: number | null): boolean { const [revealed, setRevealed] = React.useState(() => shouldRevealHumanProgress(ratioPercent, false) ); @@ -249,20 +334,23 @@ export function ParticipationGraph({ // Computed exactly once, here, and threaded into both the gate below AND HumanProgressBar's // width - see humanProgressReveal.ts's own module comment for why that single-computation rule // matters (a mismatch is exactly how the series could unlock while still rendering as a - // hairline). + // hairline). May be `null` - the live-skew guard - if the API response doesn't (yet) carry the + // three card-denominated fields; `useHumanProgressReveal` treats that as unconditionally + // below-threshold, so nothing else in this component needs its own null-check. const ratioPercent = humanProgressRatioPercent(participation); const humanProgressRevealed = useHumanProgressReveal(ratioPercent); + const hasContributedThisSession = useHasContributedThisSession(); return (
{humanProgressRevealed ? ( <> -

People are turning that into progress

+

People are keeping up with what the machine routes to them

- The machine calculators still narrow every card down to a short - list, but a real, growing share of the catalog now carries an actual - person's judgment. Here's how far a small crew has taken - it - and what's still waiting on you. + When the machine calculators can't confidently place a card on + their own, the card gets routed to a person instead. Here's how + much of that queue already carries someone's judgment - + there's always more waiting on you.

) : ( @@ -281,17 +369,15 @@ export function ParticipationGraph({ legendKeys={["confirmable", "contested"]} emptyMessage="Nothing queued for review just yet." /> - {humanProgressRevealed && ( + {humanProgressRevealed && ratioPercent != null && (
-

- - {participation.humanVotes.total.toLocaleString()} - {" "} - human confirmations are on the board now, and it shows - growing - every time someone new joins in. -

+
@@ -310,6 +396,7 @@ export function ParticipationGraph({

diff --git a/frontend/src/features/stats/humanProgressReveal.test.ts b/frontend/src/features/stats/humanProgressReveal.test.ts index 8a5c9b7b4..84cab92f4 100644 --- a/frontend/src/features/stats/humanProgressReveal.test.ts +++ b/frontend/src/features/stats/humanProgressReveal.test.ts @@ -1,8 +1,9 @@ /** * Pure-function regression guard for `humanProgressReveal.ts` (2026-07-29 owner ruling - the - * gated homepage human-progress series). Covers the units-caveat accessor and the hysteresis rule - * in isolation, without a component tree - `ParticipationGraph.test.tsx` covers the same rule - * wired into the real component (third fixture, `participationAtRevealThreshold`). + * gated homepage human-progress series, moved onto the card-denominated ratio by the 2026-07-29 + * consumer-swap directive). Covers the units caveat-free accessor and the hysteresis rule in + * isolation, without a component tree - `ParticipationGraph.test.tsx` covers the same rule wired + * into the real component (third fixture, `participationAtRevealThreshold`). */ import { HUMAN_VOTE_REVEAL_HYSTERESIS_PP, @@ -17,16 +18,18 @@ import { } from "@/features/stats/testFixtures"; describe("humanProgressRatioPercent", () => { - it("is humanVotes.total / total, as a percentage - the single accessor both the gate and the drawn series read from", () => { + it("is distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview, as a percentage - the single accessor both the gate and the drawn series read from", () => { expect( humanProgressRatioPercent(participationAtRevealThreshold) ).toBeCloseTo(10); expect(humanProgressRatioPercent(participationCurrentRatio)).toBeCloseTo( - (237 / 230_770) * 100 + (participationCurrentRatio.distinctCardsRoutedToReviewWithHumanVotes / + participationCurrentRatio.distinctCardsRoutedToReview) * + 100 ); }); - it("holds flat between the current and post-sweep fixtures, since both share the same humanVotes/total inputs to THIS ratio (their difference is confirmable/contested, which this accessor never reads)", () => { + it("holds flat between the current and post-sweep fixtures, since both share the same distinctCardsRoutedToReview(WithHumanVotes) inputs to THIS ratio (their difference is confirmable/contested, which this accessor never reads)", () => { expect(humanProgressRatioPercent(participationCurrentRatio)).toEqual( humanProgressRatioPercent(participationPostSweep) ); @@ -34,9 +37,45 @@ describe("humanProgressRatioPercent", () => { it("never divides by zero - a zeroed/cache-miss participation reads as 0%, not NaN/Infinity", () => { expect( - humanProgressRatioPercent({ total: 0, humanVotes: { total: 0 } }) + humanProgressRatioPercent({ + distinctCardsRoutedToReview: 0, + distinctCardsRoutedToReviewWithHumanVotes: 0, + }) ).toEqual(0); }); + + // The live-skew guard (2026-07-29 directive, "the API will not have these fields yet"): a real + // `1/catalogStats/` response can genuinely omit these three fields until the backend deploys + // past PR #566, even though `Participation`'s TS type claims they're required. `null` (never + // NaN/undefined-propagated-into-a-number) is the only correct answer. + describe("the live-skew guard - fields absent or non-numeric", () => { + it("returns null, not NaN, when both fields are entirely absent", () => { + expect(humanProgressRatioPercent({})).toBeNull(); + }); + + it("returns null when only the denominator is present", () => { + expect( + humanProgressRatioPercent({ distinctCardsRoutedToReview: 1000 }) + ).toBeNull(); + }); + + it("returns null when only the numerator is present", () => { + expect( + humanProgressRatioPercent({ + distinctCardsRoutedToReviewWithHumanVotes: 100, + }) + ).toBeNull(); + }); + + it("returns null when a field is present but not a finite number (defensive against a malformed/truncated response)", () => { + expect( + humanProgressRatioPercent({ + distinctCardsRoutedToReview: Number.NaN, + distinctCardsRoutedToReviewWithHumanVotes: 100, + }) + ).toBeNull(); + }); + }); }); describe("shouldRevealHumanProgress - hysteresis around HUMAN_VOTE_REVEAL_PERCENT", () => { @@ -80,6 +119,13 @@ describe("shouldRevealHumanProgress - hysteresis around HUMAN_VOTE_REVEAL_PERCEN expect(shouldRevealHumanProgress(insideHysteresisBand, false)).toBe(false); expect(shouldRevealHumanProgress(atThreshold, false)).toBe(true); }); + + // The live-skew guard, at the hysteresis-rule level: `null` never sneaks through as "truthy + // enough" to stay revealed, even if the caller was previously revealed. + it("ratioPercent === null is always hidden, even if previously revealed", () => { + expect(shouldRevealHumanProgress(null, true)).toBe(false); + expect(shouldRevealHumanProgress(null, false)).toBe(false); + }); }); describe("HUMAN_VOTE_REVEAL_PERCENT - build-time override", () => { diff --git a/frontend/src/features/stats/humanProgressReveal.ts b/frontend/src/features/stats/humanProgressReveal.ts index 8149d44c3..e6b301cbf 100644 --- a/frontend/src/features/stats/humanProgressReveal.ts +++ b/frontend/src/features/stats/humanProgressReveal.ts @@ -1,3 +1,5 @@ +import { Participation } from "@/common/schema_types"; + /** * 2026-07-29 owner ruling on top of PR #558's ParticipationGraph.tsx (see that file's own module * comment for the full "why a literal fill-bar reads as a dead 0.74px stripe today" reasoning, @@ -12,6 +14,13 @@ * becomes legible without editing source. Next.js inlines `NEXT_PUBLIC_*` vars at build time, so * this is intentionally a module-level constant (read once, at import/build time) rather than a * function re-reading `process.env` on every call. + * + * 2026-07-29 consumer swap: this module's ratio moved from the votes-over-cards approximation + * (`humanVotes.total / total`) onto the card-denominated + * `distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview` - see + * `humanProgressRatioPercent`'s own comment below. `HUMAN_VOTE_REVEAL_PERCENT`/ + * `HUMAN_VOTE_REVEAL_HYSTERESIS_PP` keep their existing meaning and values unchanged - only what + * they're compared against moved. */ export const HUMAN_VOTE_REVEAL_PERCENT = Number( process.env.NEXT_PUBLIC_HUMAN_VOTE_REVEAL_PERCENT ?? 10 @@ -21,54 +30,91 @@ export const HUMAN_VOTE_REVEAL_PERCENT = Number( * Hysteresis band, in percentage points, around `HUMAN_VOTE_REVEAL_PERCENT`: once revealed, the * series only hides again after the ratio drops below `HUMAN_VOTE_REVEAL_PERCENT - * HUMAN_VOTE_REVEAL_HYSTERESIS_PP`. Without this, a ratio sitting right on the boundary (e.g. - * `total` ticking up or down slightly as the catalog is re-crawled) would flip the homepage - * layout on and off from one load to the next. See `shouldRevealHumanProgress` below for the - * actual rule, and `ParticipationGraph.tsx`'s `useHumanProgressReveal` for how the "previously - * revealed" side of that rule is tracked (in-memory only, per this repo's own standing rule - * against persisting client-side state that should be server-derived - see + * `distinctCardsRoutedToReview` ticking up as the sweep routes one more card) would flip the + * homepage layout on and off from one load to the next. See `shouldRevealHumanProgress` below + * for the actual rule, and `ParticipationGraph.tsx`'s `useHumanProgressReveal` for how the + * "previously revealed" side of that rule is tracked (in-memory only, per this repo's own + * standing rule against persisting client-side state that should be server-derived - see * `cardbackDefaultPreference.ts`'s own comment for that precedent). */ export const HUMAN_VOTE_REVEAL_HYSTERESIS_PP = 1; /** - * The single named accessor for "how much of the catalog has human judgment on it" - the ONE - * place this ratio is computed. Both the reveal gate (`shouldRevealHumanProgress`) and the drawn - * series (`ParticipationGraph.tsx`'s `HumanProgressBar`) must call this same function on the same - * `participation` object, never recompute it separately - otherwise the series could unlock - * while still rendering as a hairline, which is the exact failure this feature exists to prevent. + * The single named accessor for "how much of the reviewed-to-a-human-being-needed queue actually + * has a human's judgment on it" - the ONE place this ratio is computed. Both the reveal gate + * (`shouldRevealHumanProgress`) and the drawn series (`ParticipationGraph.tsx`'s + * `HumanProgressBar`) must call this same function on the same `participation` object, never + * recompute it separately - otherwise the series could unlock while still rendering as a + * hairline, which is the exact failure this feature exists to prevent. + * + * `distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview` - cards over cards, + * not votes over cards (the prior `humanVotes.total / total` approximation this replaces, and + * its units-mismatch caveat, are gone: the numerator here is a proper SUBSET of the denominator + * by construction - see `Participation.distinctCardsRoutedToReviewWithHumanVotes`'s own comment + * in `common/schema_types.ts` - so this ratio has no equivalent caveat to carry). * - * UNITS CAVEAT (deliberately not papered over): `participation.humanVotes.total` counts VOTES; - * `participation.total` counts CARDS. One card can carry several human votes (a printing tag, an - * artist vote, and a descriptor tag are all independent votes on the same card), so this ratio - * over-counts relative to "distinct cards with at least one human vote" - it is an approximation - * of catalog coverage, not an exact measure. Swapping to an exact backend field (distinct cards - * carrying >= 1 human vote) later is a one-line change to this function's body only - see this - * task's own report for the proposed field. + * Returns `null`, never `NaN`/a fabricated number, when the three card-denominated fields are + * not ALL present, finite numbers on the given object. This is not merely defensive - it is + * expected, guaranteed-to-happen input during this feature's own rollout window: + * `Participation`'s TS type declares these fields as required, but the LIVE production API + * (`GET 1/catalogStats/`) will not actually send them until the backend is redeployed past PR + * #566 - `frontend/src/store/api.ts`'s `getCatalogStats` endpoint trusts the fetch response's + * shape directly (no runtime validation), so a real response genuinely missing these fields + * reaches this function exactly as typed, just without the fields existing at runtime. `null` + * means "not revealed yet, the same truthful state as today" - callers (`shouldRevealHumanProgress`) + * must treat it as unconditionally below-threshold, never divide-by-undefined into NaN. */ -export function humanProgressRatioPercent(participation: { - total: number; - humanVotes: { total: number }; -}): number { - if (participation.total <= 0) { +export function humanProgressRatioPercent( + participation: Partial< + Pick< + Participation, + | "distinctCardsRoutedToReview" + | "distinctCardsRoutedToReviewWithHumanVotes" + > + > +): number | null { + const { + distinctCardsRoutedToReview, + distinctCardsRoutedToReviewWithHumanVotes, + } = participation; + if ( + typeof distinctCardsRoutedToReview !== "number" || + !Number.isFinite(distinctCardsRoutedToReview) || + typeof distinctCardsRoutedToReviewWithHumanVotes !== "number" || + !Number.isFinite(distinctCardsRoutedToReviewWithHumanVotes) + ) { + return null; + } + if (distinctCardsRoutedToReview <= 0) { return 0; } - return (participation.humanVotes.total / participation.total) * 100; + return ( + (distinctCardsRoutedToReviewWithHumanVotes / distinctCardsRoutedToReview) * + 100 + ); } /** * Pure hysteresis rule: reveal once `ratioPercent` reaches `HUMAN_VOTE_REVEAL_PERCENT`; once * revealed, only hide again once it falls below `HUMAN_VOTE_REVEAL_PERCENT - - * HUMAN_VOTE_REVEAL_HYSTERESIS_PP`. `wasPreviouslyRevealed` is the caller's own last decision for - * this same ratio source - a fresh evaluation with no prior state (the common case: this - * component's data query runs once, with no persisted cross-load memory) always starts from - * `false` and so applies the plain `>= HUMAN_VOTE_REVEAL_PERCENT` rule. Deterministic: calling - * this twice with the same two arguments always returns the same result, so a ratio sitting - * exactly at either boundary never flips between consecutive evaluations of identical inputs. + * HUMAN_VOTE_REVEAL_HYSTERESIS_PP`. `ratioPercent === null` (the live-skew guard - see + * `humanProgressRatioPercent`'s own comment) is unconditionally treated as hidden, regardless of + * `wasPreviouslyRevealed`: "the fields aren't there this load" is never a reason to keep showing + * a series computed from data that no longer exists. `wasPreviouslyRevealed` is the caller's own + * last decision for this same ratio source - a fresh evaluation with no prior state (the common + * case: this component's data query runs once, with no persisted cross-load memory) always + * starts from `false` and so applies the plain `>= HUMAN_VOTE_REVEAL_PERCENT` rule. + * Deterministic: calling this twice with the same two arguments always returns the same result, + * so a ratio sitting exactly at either boundary never flips between consecutive evaluations of + * identical inputs. */ export function shouldRevealHumanProgress( - ratioPercent: number, + ratioPercent: number | null, wasPreviouslyRevealed: boolean ): boolean { + if (ratioPercent == null) { + return false; + } if (wasPreviouslyRevealed) { return ( ratioPercent >= diff --git a/frontend/src/features/stats/sessionContributionSlice.ts b/frontend/src/features/stats/sessionContributionSlice.ts new file mode 100644 index 000000000..06dba9848 --- /dev/null +++ b/frontend/src/features/stats/sessionContributionSlice.ts @@ -0,0 +1,52 @@ +/** + * In-session (memory only, never persisted) record of whether THIS browser tab has cast at + * least one vote this session - drives `ParticipationGraph.tsx`'s dashed "you would be the Nth" + * dot turning into a filled, green "thank you" dot once true (2026-07-29 directive, item 4). + * + * Two rejected approaches, on purpose (the repo's standing rule against persisting + * server-derived state client-side - see `cardbackDefaultPreference.ts`'s own precedent): + * `localStorage` (would keep claiming credit for a vote across reloads/new tabs/days after the + * fact, well past the point the vote itself is just historical catalog state, not "you, right + * now"), and a "has this anonymous_id voted" backend endpoint (per-user, effectively + * uncacheable, and a standing way for anyone to probe whether an arbitrary anonymous_id has + * contributed - a privacy leak with no offsetting benefit over the one browser tab that actually + * cast the vote just remembering it locally for the rest of this session). + * + * Registered as a normal slice in the app's single Redux store (`store/store.ts`) - the same + * "existing Redux wiring" every other cross-cutting piece of client state in this app already + * uses (see `store/slices/*`) - so that a vote cast from the `/whatsthat` question feed + * (`QuestionFeed.tsx`'s `bumpSessionCount`, which now also dispatches + * `recordSessionContribution` alongside its existing per-vote counter) is visible back on the + * homepage without either page needing to know about the other directly. + */ +import { createAppSlice, useAppSelector } from "@/common/types"; +import { RootState } from "@/store/store"; + +interface SessionContributionState { + hasContributedThisSession: boolean; +} + +const initialState: SessionContributionState = { + hasContributedThisSession: false, +}; + +export const sessionContributionSlice = createAppSlice({ + name: "sessionContribution", + initialState, + reducers: { + // One-way for the session's lifetime (no "un-vote" case exists on the wire either) - once + // true, stays true until a real page reload resets the in-memory store. + recordSessionContribution: (state) => { + state.hasContributedThisSession = true; + }, + }, +}); + +export const { recordSessionContribution } = sessionContributionSlice.actions; +export default sessionContributionSlice.reducer; + +export const selectHasContributedThisSession = (state: RootState): boolean => + state.sessionContribution.hasContributedThisSession; + +export const useHasContributedThisSession = (): boolean => + useAppSelector(selectHasContributedThisSession); diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index c5a001b6b..2bc1e032f 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -7,6 +7,9 @@ import { } from "@reduxjs/toolkit"; import { clientSearchService } from "@/features/clientSearch/clientSearchService"; +// Homepage participation graph's in-session "you contributed" state - see +// features/stats/sessionContributionSlice.ts's own module comment. +import sessionContributionReducer from "@/features/stats/sessionContributionSlice"; import { api } from "@/store/api"; import { listenerMiddleware } from "@/store/listenerMiddleware"; import backendReducer, { @@ -48,6 +51,9 @@ const rootReducer = combineReducers({ fileDownloads: fileDownloadsReducer, favorites: favoritesReducer, savedDeckSession: savedDeckSessionReducer, + // Homepage participation graph's in-session "you contributed" state - see + // features/stats/sessionContributionSlice.ts's own module comment. + sessionContribution: sessionContributionReducer, }); //# region middleware diff --git a/frontend/tests/ParticipationGraph.spec.ts b/frontend/tests/ParticipationGraph.spec.ts index 5b15f953c..99c4987d3 100644 --- a/frontend/tests/ParticipationGraph.spec.ts +++ b/frontend/tests/ParticipationGraph.spec.ts @@ -102,7 +102,9 @@ test.describe("homepage participation graph", () => { page.getByTestId("participation-graph-human-progress-bar") ).toBeVisible(); await expect( - page.getByText("People are turning that into progress") + page.getByText( + "People are keeping up with what the machine routes to them" + ) ).toBeVisible(); });