fix(quests): tie the offer popup to the claim that earned it - #6588
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
rebelchris
left a comment
There was a problem hiding this comment.
Approach is right — moving the trigger onto the mutation that every claim surface already goes through, with questClaimed.ts mirroring the existing questRewardAnimation.ts event precedent, is a smaller and more honest model than the count-rise baseline in the first commit. One blocking correctness gap (the session flag is set once and never cleared, so the reported "popup with no apparent trigger" can still happen inside a long session), plus two non-blocking notes, left inline.
Reviewed by AI.
|
|
||
| const summary = useMemo(() => getDailyQuestSummary(dashboard), [dashboard]); | ||
|
|
||
| const [hasClaimedThisSession, setHasClaimedThisSession] = useState(false); |
There was a problem hiding this comment.
Blocking: hasClaimedThisSession latches to true and is never cleared, so it stops being "the claim moment" and becomes a session-scoped standing fact — the same shape of bug that was reported, just bounded by the tab's lifetime instead of by a visit.
Two concrete scenarios where the claim does not produce a popup but the flag stays set:
- No inventory / failed offers query. That path deliberately does not stamp the day (
onShownnever runs), and the flag staystrue. Any later refetch of the offers query in the same session that comes back with inventory now satisfiesshouldShowand opens the popup at an arbitrary moment, with no claim behind it. - Long-lived session. The webapp tab and the extension new tab routinely stay open past midnight.
isTodayStamp(lastSeen)flips back to false on the new day whilehasClaimedThisSessionis stilltruefrom yesterday's claim, so the popup can open the next day on no action at all.
A sibling popup owning MODAL_KEY at claim time is a milder version of the same thing: the trigger silently defers until that modal closes, which may be much later than the claim.
Suggested direction: treat the claim as a one-shot rather than a flag — consume it (clear it once the effect has decided, whether it opened, found no offers, or bailed), or use the short-lived "claimed just now" marker the description contemplates, so the popup can only open in a window that genuinely belongs to the claim.
Reviewed by AI.
| const view = renderLoaded(claimed()); | ||
|
|
||
| await act(async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 150)); |
There was a problem hiding this comment.
Non-blocking: the negative assertion now depends on a real 150ms wall-clock sleep, twice in this file. It is understood why a bare waitFor was not discriminating, but a fixed sleep is both slow and only probabilistically correct on a loaded CI box — if the entry-phase fetch ever takes longer than 150ms this test passes for the wrong reason and stops guarding the regression.
Can the settle be anchored on something deterministic instead — e.g. asserting the nock interceptor for the offers query was consumed (or explicitly not consumed, which is the actual claim being made on entry) — rather than on elapsed time?
Reviewed by AI.
|
Non-blocking (reviewability): the PR description still describes the first commit's approach — "the trigger now watches the claimed count rise", "whatever was already claimed when the dashboard first loaded is treated as history", "the baseline tracks downwards as well". Reviewed by AI. |
rebelchris
left a comment
There was a problem hiding this comment.
Re-reviewed 14fc596. The blocking finding is resolved, and resolved in the right place: the token is spent inside the open effect once the offers fetch has settled, whichever way it decided, so an empty treatment cannot be revived by a later refetch, and the 30s window closes the tab-open-past-midnight and never-settles cases. The eligibility effect is declared before the open effect and gates on the same offersEnabled && areOffersPending condition, so it still logs in the commit where the fetch settles — the denominator is not lost to the spend. Deferring rather than spending while a sibling holds MODAL_KEY keeps the earlier behaviour intact. The test rewrite is a genuine improvement: fetchStatus during the mount commit is a deterministic anchor, and both new tests were verified failing against the latching build.
Two non-blocking notes below. CI is green on this sha (only the storybook preview deploy is outstanding).
Reviewed by AI.
| } from '../../../graphql/offers'; | ||
| import { featureQuestOffers } from '../../../lib/featureManagement'; | ||
|
|
||
| const CLAIM_WINDOW_MS = 30_000; |
There was a problem hiding this comment.
Non-blocking: worth being explicit that 30s makes the sibling-deferral path a coin flip in practice — a boot popup or streak modal the user actually reads is often on screen longer than that, and the claim then expires unspent. The day stays unstamped so a later claim can still show, and the alternative (waiting indefinitely) is what we just removed, so this is the right trade for now. Just flag it as a number to revisit once there is data: if quest offers eligible fires materially more often than the modal is shown, this window is the first thing to look at.
Reviewed by AI.
|
|
||
| if (!offersEnabled || !offers?.length || !dashboard) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Non-blocking (nit): !dashboard here is now unreachable — shouldShow already includes !dashboard, and the top of the effect returns on !shouldShow. Fine to leave, but dropping it would make the remaining condition read as purely "control, or no inventory".
Reviewed by AI.
rebelchris
left a comment
There was a problem hiding this comment.
Re-reviewed 984dac8. The cache-as-bus refactor is the right call and lands cleanly: it mirrors useLazyModal exactly (disabled query + queryFn reading the cache), the writer and reader both derive the key from questClaimQueryKey, and there is no query-cache persistence in the repo, so the claim cannot survive a reload and revive the original bug. Dropping lib/questClaimed.ts, the CustomEvent typing and the listener effect is a net simplification, and the commit message answers the two alternatives (mutation callback, folding offers into the mutation) before I could ask.
My remaining concern is not the mechanism but abadd89, which is a product change riding inside a fix PR. Details inline. CI note: test_shared, lint_shared and test_webapp are still pending on this sha — they were green on 14fc596, and the spec harness changed materially since (every renderAndClaim is now awaited), so worth confirming before merge.
Reviewed by AI.
| // their own celebration in IntroQuestModal, and since that is a LazyModal | ||
| // this trigger would defer behind it and land a sponsored offer as | ||
| // someone's first-run experience. | ||
| const pendingClaim = |
There was a problem hiding this comment.
Blocking (scope + experiment integrity): broadening the trigger from daily to daily/weekly/milestone is a deliberate product change, and featureQuestOffers is a live experiment. Landing it inside a PR whose stated job is fixing the trigger has three consequences worth deciding on explicitly rather than inheriting:
- Enrolment population changes mid-flight.
shouldEvaluate: shouldShowmeans the set of users who ever enroll is now larger and differently composed — weekly and milestone claimers who never finish a daily quest are in for the first time. Data before and after this merge is not comparable, and the split itself shifts. - Treatment copy changes mid-flight.
/ 3 quests→/ 3 daily questsinQuestOfferCelebrationalters what the treatment arm sees. Copy fidelity inside a running arm is something we have been bitten on before; the change is defensible, but it should be a decision by whoever owns the experiment, not a side effect. - The eligibility denominator changes shape. Adding
questTypetoextramakes it segmentable going forward, but the historical rows have no such marker, so any analysis has to be cut at this deploy.
Suggested direction: land the trigger fix (0c5821c–14fc596) on its own, and take the broadening as its own PR with the experiment owner's sign-off on whether the current run is restarted or annotated. If the call is to keep them together, please say so in the description with that sign-off recorded, so the analysis is not read across a discontinuity nobody remembers.
Reviewed by AI.
| rather than as the day's outstanding quests. */} | ||
| <span className="text-text-tertiary typo-title3"> | ||
| {`/ ${summary.total} quests`} | ||
| {`/ ${summary.total} daily quests`} |
There was a problem hiding this comment.
Blocking (mobile): the label fix only landed on the split panel. QuestOfferCelebrationCompact further down renders `${summary.claimed}/${summary.total}` with no noun at all, so on the compact surface a weekly or milestone claim shows an unlabelled 0/3 directly above Quest complete — exactly the "reads as a bug" case this comment describes, on the narrower breakpoint where there is least context to infer it from. Worth giving both variants the same treatment, or deriving the label once so they cannot drift again.
Reviewed by AI.
| // arbitrary point with no claim behind it. Keyed on the claim itself, so a | ||
| // second claim refreshes the window rather than riding the first one's. | ||
| useEffect(() => { | ||
| if (!pendingClaim) { |
There was a problem hiding this comment.
Non-blocking (nit): an intro claim is written to the claim key by the mutation and then filtered out here, so pendingClaim is null and the expiry effect never runs — the intro claim sits in the cache until the next claim overwrites it. Harmless today, but it means the cache entry is "last claim" while the rest of the file reads it as "the pending moment". Filtering intro at the write side, or clearing the key when a claim is ignored, would keep the invariant true.
Reviewed by AI.
Reported: the popup appeared on app entry with no apparent trigger. That was the design working as specified — eligibility was the standing fact of having claimed today, so it stayed true on a later visit and the reward arrived detached from the action. The trigger now watches for the claimed count rising while the app is open. Whatever was already claimed when the dashboard first loaded is history, not a moment, so an entry can never be mistaken for a claim. Claiming always routes through useClaimQuestReward, which writes the dashboard cache this listener reads, and the listener is mounted for the whole session, so the rise is always observed. The baseline tracks downwards too, or a rotation reset would leave a high-water mark that swallowed the next claim. The persisted day stamp keeps doing its job: it is what holds a three-quest run to a single popup. Trade-off worth naming: the moment no longer survives a reload. Claim with Encore down and there is no second chance that day, where the state check would have retried on the next visit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the baseline-ref transition watch with what the claim already knows. `useClaimQuestReward` — the one mutation every claim surface goes through — dispatches QUEST_CLAIMED_EVENT once the dashboard cache is current, and the popup listens. Mirrors QUEST_REWARD_COUNTER_EVENT, which the profile button already consumes the same way. This drops the high-water-mark ref, the rise comparison and the rotation-reset edge case it needed, and replaces inferring a claim from a derived count with the claim itself. Weekly, milestone and intro claims share the mutation, so the listener filters on Daily. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses PR review. `hasClaimedThisSession` latched true and was never cleared, so it stopped being the claim moment and became a session-scoped standing fact — the reported bug again, bounded by the tab instead of the visit. Two ways it bit: a treatment that found no inventory left the flag set, so any later refetch that landed stock opened the popup at an arbitrary point; and a tab left open past midnight found `isTodayStamp(lastSeen)` cleared with the flag still set from yesterday, opening on no action at all. The flag is now a one-shot token. The open effect spends it once the offers fetch has settled, whichever way it decided, so an empty result cannot be revived. A sibling popup holding MODAL_KEY still defers rather than spending, so the claim can land when that closes — but a 30s window expires anything never acted on, which also covers a fetch that never settles. The token rises per claim so a second claim refreshes the window rather than riding the first one's. Also from review: the two 150ms wall-clock sleeps are gone. The entry assertion now reads whether the offers query went live, which React Query flips during the mount commit `render` already flushes — so it is synchronous and cannot pass by winning a race on a loaded CI box. Both new tests were verified against the latching build first: the spend case and the expiry case each fail without their fix. The expiry one needed a timer tick after closing the sibling, because fake timers stall React Query's notify batching and it passed for the wrong reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The daily-only filter was arbitrary: a weekly or milestone claim is the same reward moment, and gating on cadence only cost impressions. Intro quests stay excluded, for a reason that is not about cadence. They are the onboarding flow and already have their own celebration in IntroQuestModal — and because that is a LazyModal, this trigger would defer behind it and land a sponsored offer as someone's first-run experience. The celebration count is derived from the daily set, so a weekly claim can legitimately show none of it done. The label now says "daily quests", otherwise an unlabelled "0 / 5" beside "Quest complete" reads as a bug rather than as what is left today. The eligibility event carries questType so the denominator stays segmentable now that three cadences feed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops `lib/questClaimed.ts`, the CustomEvent typing and the addEventListener/removeEventListener effect. `useClaimQuestReward` publishes the claim under a RequestKey and the popup observes it, which is how `useLazyModal` already carries the open modal between components that never meet. A plain mutation callback cannot do this: the hook is instantiated in four claim surfaces and the listener is a fifth component, so a callback would mean every surface importing offer logic. Folding the offers logic into the mutation instead would evaluate the experiment flag whenever any claim surface mounts, rather than only at the eligible moment, which would dilute enrolment. Both components already hold the query client, so this needs no new module, no provider and no global. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses PR review. The label only landed on the split panel, so the compact carousel still rendered a bare "0/3" above "Quest complete" — the exact case the label exists to prevent, on the breakpoint with the least context to infer it from. Both variants now render one shared ProgressCount, so the noun cannot go missing on one of them again, with a test over both layouts. Also from review: an ignored claim is cleared from the cache instead of being left parked, so that key always means "the pending moment" rather than "the last claim". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7a0301b to
1d99a5a
Compare
rebelchris
left a comment
There was a problem hiding this comment.
Re-reviewed 1d99a5a. All three findings from the last pass are addressed, and the shared ProgressCount is the better fix than labelling both call sites — the noun can no longer go missing on one layout, and the it.each over split/carousel guards it. Clearing an ignored claim at the read side keeps the cache key honest as "the pending moment".
On the experiment concern: verified independently rather than taken on trust. quest_offers in production is defaultValue: false with a single team-targeted force rule and no experiment rule, so there is genuinely no traffic and no arm has seen the old copy — the broadening and the copy change are free today, as the description now says. The rewritten description also matches the shipped design, and the trade-offs section records the 30s window and the reload behaviour, which is what I asked for.
One non-blocking layout question inline. No blocking findings remain from me.
Reviewed by AI.
| <span className="font-bold tabular-nums typo-mega2"> | ||
| {`${summary.claimed}/${summary.total}`} | ||
| </span> | ||
| <ProgressCount summary={summary} claimedClassName="typo-mega2" /> |
There was a problem hiding this comment.
Non-blocking (layout): the compact header previously rendered a single typo-mega2 token (0/3) in a left-aligned column; it now renders a typo-mega2 number plus a typo-title3 / 3 daily quests in a justify-center row, next to the level ring, on the narrowest breakpoint. The new test asserts the text is present but not that it fits. Worth a quick look at the carousel at ~320-360px to confirm it does not wrap awkwardly or push the ring — and if justify-center inside the items-start column shifts the alignment, that is the place it would show.
Reviewed by AI.
Diagnostic. test_shared fails with xargs exit 123 while jest reports every suite passing and CircleCI records zero failed tests, so the real error is in the step log's truncated head — roughly 400KB of React console.error noise pushes it past CircleCI's output cap. --silent suppresses test console output so the head survives. Scoped to test_shared; test_webapp and test_extension keep the same command and both pass today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Changes
Follow-up to #6568. Reported after merge: the offers popup appeared on app entry with no apparent trigger.
That was the design working as specified rather than a bug. Eligibility was the standing fact of having claimed a quest today, so it stayed true on any later visit and the reward turned up detached from the action that earned it.
The trigger now reacts to the claim itself.
useClaimQuestReward— the one mutation every claim surface goes through — publishes the claim to the query cache underRequestKey.QuestClaim, and the popup observes it. That is howuseLazyModalalready carries the open modal between components that never meet, so it needs no new module, no provider and no global.A plain mutation callback cannot do this: the hook is instantiated in four claim surfaces (header panel, sidebar
CompactQuestList,/daily-quests, game center) and the listener is a fifth inMainLayout. Folding the offers logic into the mutation instead would evaluate the experiment flag whenever any claim surface mounts, rather than only at the eligible moment, which would dilute enrolment.The claim is a one-shot, not a flag. It is spent once the offers fetch settles, whichever way it decided, so an empty result cannot be revived by a later refetch. A sibling popup holding
MODAL_KEYdefers rather than spending, so the claim can still land when that closes — and a 30s window expires anything never acted on, which also covers a fetch that never settles.Any real claim qualifies — daily, weekly and milestone. Intro quests are excluded: they are onboarding, they already have their own celebration in
IntroQuestModal, and because that is aLazyModalthis trigger would defer behind it and land a sponsored offer as someone's first-run experience.Experiment
quest_offersis not enrolled — still 0%. That matters for reading this PR: the review raised that broadening the trigger and changing/ 3 queststo/ 3 daily questswould move the enrolment population and the treatment copy mid-flight. With no traffic there is no data to be discontinuous with and no arm has seen the old copy, so both are free changes today. Recording it here so nobody later hunts for a discontinuity that never existed.The trigger broadening does change who ever enrolls once it is switched on — weekly and milestone claimers who never finish a daily quest are now in scope. That is the intended product behaviour, not a side effect.
Trade-offs
quest offers eligiblefires materially more often than the modal is shown, this number is the first thing to look at.0 / 3reads as what is left today rather than as a bug.Events
No new events.
quest offers eligiblenow fires on the claim moment rather than on eligibility-by-state, which makes it a tighter denominator, and gainsquestTypeso it stays segmentable now that three cadences feed it. Historical rows have no such marker, but since the experiment has never run there are none that matter.Testing
Several of these tests needed more than one attempt to be worth anything, which is worth knowing when reviewing them:
waitForresolves at t=0, before the offers query can settle, so it passes against the unfixed code. This bit the entry test, the claim-spend test and the expiry test.isFetchingOffersis dependable only as a negative: the fetch can settle beforewaitFornext polls, so positives are anchored on the modal instead.Every behavioural test here was checked against the unfixed build: entry, claim-spend, expiry and the carousel label each fail without their fix.
shared 2716 / webapp 634 / extension 52 passing. The
numberFormatandworld/*failures also fail onmain.typecheck-strict-changedand eslint clean. Rebased onto currentmain.Manual Testing
🤖 Generated with Claude Code
Preview domain
https://fix-quest-offers-claim-trigger.preview.app.daily.dev