Skip to content

feat(quests): offer partner gifts when the day's quests are done - #6568

Merged
rebelchris merged 11 commits into
mainfrom
quest-offers-experiment
Sep 2, 2026
Merged

feat(quests): offer partner gifts when the day's quests are done#6568
rebelchris merged 11 commits into
mainfrom
quest-offers-experiment

Conversation

@rebelchris

@rebelchris rebelchris commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Changes

The streak_milestone_offers experiment failed. This removes it and replaces it with quest_offers, which shows the same Encore partner offers at a different moment: the day's first claimed daily quest.

Cleanup

  • Deleted the flag, StreakOffersModal, and the whole components/streak/offers/ directory (including the streakTiers flame ladder)
  • StreakMilestonePopup is back to opening the classic NewStreak modal — its strict-TS fixes are kept
  • Removed LazyModal.StreakOffers, DismissStreakOffers, TargetType.StreakOffer

New experiment

  • QuestOffersPopup is mounted in MainLayout and gated like QuestUpdatesListener (logged in, settings loaded, not opted out of quests)
  • Fires on the first claimed daily quest of the day. Waiting for the whole set was too rare a moment to measure, and rarer still for Plus users whose unlocked Plus quests counted toward the total
  • Eligibility is a state check, not a transition watch, so the moment survives a reload. PersistentContextKeys.QuestOffersLastSeen keeps it to one showing per day
  • A claim is detected with the shared isQuestClaimed predicate (status === claimed || Boolean(claimedAt)). The API stamps claimedAt without reliably flipping status; QuestCard and CompactQuestList already guarded against this and now share one copy
  • The progress total counts locked Plus quests on purpose, so the denominator matches what the quest panel shows and frames the Plus track as still-to-do
  • Celebration panel retherned for quests: level ring, XP earned today, quest step strip, in cabbage/avocado rather than the streak flame

API: needs QUEST_COMPLETION on OfferPlacement — merged in dailydotdev/daily-api#4183.

Merge plan

  1. daily-api#4183 is merged, so this is unblocked. OfferPlacement.STREAK_MILESTONE was replaced rather than extended, so a webapp deploy ahead of the API would have sent an enum it did not know. In practice the flag defaults to control, so nothing queries userOffers until enrolment — but the ordering is now moot since the API shipped first.
  2. Merge this with quest_offers at control.
  3. Before enrolling, confirm Encore has inventory for placementId quest_completion and that remoteConfig.vars.encoreOffers.enabled is on with allowedCountries covering the target markets. That remote config is a single global gate shared with the deprecated streak placement — if it was switched off when the streak experiment was called a failure, this ships as a silent no-op. The new quest offers eligible event makes exactly that case visible.
  4. streak_milestone_offers can be archived in GrowthBook; it has no readers left.

Events

Did you introduce any new tracking events? Yes.

Type event_name value
New quest offers eligible target_type: 'quest offer', target_id: <claimed count>, extra: { enabled, offers, failed }
New impression target_type: 'quests completed', target_id: <claimed count>, extra: { variant, offers }
New impression target_type: 'quest offer', target_id: <impressionUid>, extra: { brand, questsCompleted, variant }
New click target_type: 'quest offer', target_id: <impressionUid>, extra: { brand, questsCompleted, variant }
New dismiss quest offers target_type: 'quest offer', target_id: <claimed count>, extra: { method: 'close' | 'decline', claimed, variant }
Removed dismiss streak offers replaced by the above

quest offers eligible is the experiment's denominator. Control renders nothing and so does a treatment that found no inventory, so without it both arms are invisible and a dud experiment cannot be told apart from missing Encore stock. It carries its own day stamp so every arm logs once per user per day, and extra separates the three outcomes: enabled:false (control), enabled:true, offers:0, failed:false (no inventory), enabled:true, offers:0, failed:true (query error — retry: false means a 5xx settles with no offers).

variant (split | carousel) rides every offer event because the two layouts deliver differently — the split renders all offers at once, the carousel one card per swipe — so impression counts are only comparable when segmented by it.

Every dismissal path funnels through one handler (desktop X, backdrop, escape; the drawer's own button, outside click and escape on mobile; and "No thanks"), guarded by a ref so it logs exactly once per modal.

Experiment

Did you introduce any new experiments? Yes — quest_offers, boolean, defaults to control.

Important

Please do not merge the PR until the experiment enrolment is approved.

Known limitations

  • The day stamps are global idb keys, not namespaced by user. The practical consequence is account switching: on a shared browser, a second account is suppressed for the rest of the day. ReadingReminderLastSeen has the same shape, so this is consistent with existing precedent rather than new behaviour — flagging it rather than changing it.
  • A free user can never reach claimed >= total now that locked quests count, so the "Daily quests complete" headline is Plus-only in practice. Free users always see "Quest complete", which is accurate and fits the still-to-do framing.

Manual Testing

On those affected packages:

  • Have you done sanity checks in the webapp?
  • Have you done sanity checks in the extension?
  • Does this not break anything in companion?

Did you test the modified components media queries?

  • MobileL (420px)
  • Tablet (656px)
  • Laptop (1020px)

The drawer/carousel engages below 656px and the split layout above it, so both need a look.

Automated: shared 2688 / webapp 626 / extension 52 passing. The numberFormat and world/* failures also fail on main. typecheck-strict-changed and eslint clean.

🤖 Generated with Claude Code

Preview domain

https://quest-offers-experiment.preview.app.daily.dev

The streak_milestone_offers experiment failed, so the streak milestone
popup goes back to the classic NewStreak modal and everything the
experiment added around it is removed.

quest_offers replaces it at a different moment: once every unlocked
daily quest is claimed, the same Encore offers are presented as the
reward. Eligibility is a state check rather than a transition watch, so
the moment survives a reload, and a persisted day stamp keeps it to one
showing per day. Locked Plus quests are excluded from the "day is done"
test, otherwise free users would sit permanently one quest short.

Neither the control group nor a treatment with no inventory stamps the
day: there is no classic popup to fall back to here, so a later visit
can still catch offers once they exist.

The offer primitives move from streak to quest and lose the flame ladder
for a level ring, XP earned, and a quest step strip.

Needs QUEST_COMPLETION on the API's OfferPlacement enum (daily-api#4183).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Sep 2, 2026 8:32am UTC
1 Skipped Deployment
Project Deployment Actions Updated
storybook Ignored Ignored Sep 2, 2026 8:32am UTC

Request Review

Adding a key to PersistentContextKeys pulled the file into the strict
migration guard, which surfaced two pre-existing errors: an empty cache
with no fallback resolves to null, but the hook's tuple claimed T.

The public signature is unchanged and `||` is kept over `??`, so no
caller's inferred types or runtime behaviour move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rebelchris and others added 2 commits September 1, 2026 15:49
Waiting for every daily quest to be claimed put the offer behind a
moment too rare to measure, and it was rarer still for Plus users, whose
unlocked Plus quests counted toward the total they had to clear.

The persisted day stamp already caps this at one showing, so the
threshold drops to the first claim of the day. Locked quests stay out of
the total so the progress denominator is one a free user can close, and
the panel only claims the day is complete once it actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rebelchris and others added 2 commits September 1, 2026 16:06
The popup never fired: getDailyQuestSummary keyed off
`status === claimed`, but the API stamps `claimedAt` without reliably
flipping status, so every claim counted as zero.

QuestCard and CompactQuestList already guarded against this with
`status === claimed || Boolean(claimedAt)`. That predicate is now
isQuestClaimed in graphql/quests.ts and all three sites share it, rather
than the summary carrying a third, subtly weaker copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The total dropped locked quests, so a free user with two regular and
three Plus dailies read "1/2" while the quest panel plainly showed five.
A denominator that disagrees with the screen is worse than one a free
user cannot close, and showing all five frames the locked Plus track as
something still to do today.

The trigger is unchanged: locked quests are never claimed, so the
claimed count this fires on does not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two blind spots in the experiment's tracking.

Control renders nothing, and so does a treatment that finds no Encore
inventory, so both arms were invisible in analytics — a dud experiment
looked exactly like missing stock, which is the confusion that cost us
several rounds of debugging. A `quest offers eligible` event now fires
when the moment is reached, carrying the arm and the offer count, on its
own day stamp so every arm logs once per user per day. That stamp is
deliberately separate from the modal's: an eligibility event logged by
control, or by a treatment with no stock, must not stop the modal
showing later that day once offers do turn up.

The split layout renders every offer at once while the carousel renders
one card per swipe, so impression counts are only comparable when
segmented. Every offer event now carries `variant`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the quest-offers experiment end to end (cleanup of streak_milestone_offers, the new trigger, the modal/celebration retheme, the shared isQuestClaimed predicate and the usePersistentContext typing). The cleanup is complete — no references to streakTiers, StreakOffer*, LazyModal.StreakOffers or STREAK_MILESTONE survive — CI is green, and the experiment is correctly conditional on eligibility with a control default of off. Test coverage on the trigger is genuinely good.

Two findings I'd like resolved before this goes to enrollment, both about the popup trigger rather than the UI, plus a few non-blocking ones inline.

Cross-cutting (no single line):

  1. The Events table in the description is missing the new quest offers eligible event, and the variant / offers extras added to the two impression events and to dismiss quest offers in the last commit. Since that table is what gets reviewed for taxonomy, please bring it up to date with what the code now emits.
  2. Deploy ordering: OfferPlacement.STREAK_MILESTONE is replaced rather than extended, so a webapp deploy ahead of dailydotdev/daily-api#4183 would send an enum the API does not know. In practice the flag defaults to off so nothing queries it — worth stating explicitly in the merge plan rather than leaving it implicit.
  3. The day stamp is a global idb key (quest_offers_last_seen), not namespaced by user. The description calls out per-device, but the sharper consequence is account switching: on a shared browser the second account is suppressed for the rest of the day. ReadingReminderLastSeen has the same shape so this is consistent with precedent — flagging it as a known limitation rather than asking for a change.

Reviewed by AI.

}

hasOpened.current = true;
setLastSeen(new Date().toISOString());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the day is stamped before we know the modal actually stuck, and !!modal cannot prevent a same-commit collision.

useLazyModal writes to a react-query key; setQueryData does not update modal synchronously, and effects in sibling components all run in the same commit. MainLayout now mounts three independent writers to MODAL_KEYBootPopups (which does not check modal at all), StreakMilestonePopup, and this trigger, in that order. A logged-in user who is due a streak-milestone popup and has claimed a quest today hits this on first paint: both effects read modal === undefined, both call openModal, and the last write wins.

Whichever loses is silently dropped, but its side effects already ran. In the case where this one loses, hasOpened.current is set and setLastSeen(now) has burned the day stamp, so the user sees no offers today, no impression fires, and confirmOffersDelivered never runs for offers we already fetched from Encore — which is exactly the outcome the "neither control nor an empty inventory stamps the day" design is trying to avoid.

Two directions: read the live value (queryClient.getQueryData(MODAL_KEY)) inside the effect immediately before opening rather than relying on the render snapshot, and move the setLastSeen stamp to the point the modal actually renders (alongside the existing useLogEventOnce impression in QuestOffersModal) so it can only be written for a showing that happened. Longer term the cleaner answer is an immediate boot popup via addImmediatePopup, which is the repo's existing arbiter for exactly this.

Reviewed by AI.

]);

useEffect(() => {
if (hasOpened.current || !shouldShow || isOffersFeatureLoading) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (analytics): this effect does not wait for isEligibleLogFetched, but the eligibility effect above does — and opening the modal flips shouldShow to false permanently for the session.

lastSeen and eligibleLoggedAt are two independent idb-keyval reads resolving in parallel. If isLastSeenFetched lands first, shouldShow becomes true and this effect can open the modal in that commit, while the eligibility effect is still short-circuiting on !isEligibleLogFetched. On the next render shouldShow is false, so quest offers eligible never fires for that user that day.

The bias is one-directional and lands on the arm you most need: control and no-inventory treatment never open a modal, so they keep re-evaluating until their stamp read resolves and always log. Only treatment-with-inventory can drop the event — so the denominator is under-counted precisely where the numerator lives.

Either add !isEligibleLogFetched to the shouldShow predicate, or log the eligibility event from this same effect immediately before openModal so the two can't be separated. Worth a spec case with the eligibility stamp resolving after the last-seen stamp — the current tests mock usePersistentContext synchronously, so this ordering isn't exercised.

Reviewed by AI.

target_id: summary.claimed.toString(),
extra: JSON.stringify({
enabled: offersEnabled,
offers: offers?.length ?? 0,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the event's stated purpose is telling a dud experiment apart from missing Encore stock, but a failed fetch is indistinguishable from empty inventory here.

userOffersQueryOptions sets retry: false, so a 5xx or a network blip resolves areOffersPending to false with offers still undefined, and this logs offers: 0 — the same payload as a healthy response with no inventory. That is the third state that cost the debugging rounds the commit message refers to.

Pulling isError (or status) off the query and adding it to extra makes the three cases separable at query time.

Reviewed by AI.

} from '../../../graphql/offers';
import { featureQuestOffers } from '../../../lib/featureManagement';

const hasSeenToday = (lastSeen: string | null): boolean => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this already exists as getHasSeenToday in hooks/notifications/useReadingReminderHero.ts:34 — same null guard, same Number.isNaN(getTime()) guard, same isToday, reading the same kind of ISO day stamp out of usePersistentContext.

That makes two copies of the once-per-day stamp idiom, and this file calls it against two separate keys. Worth lifting a single helper (a lib/dateFormat export, or a small useDayStamp-style hook that pairs the read with the write) and pointing both surfaces at it, rather than leaving the semantics of "seen today" defined in two places.

Reviewed by AI.

return [data ?? fallbackValue, updateValue, isFetched, isLoading];
// An empty cache with no fallback resolves to null, so callers that can hit
// that case pass a T which admits it (`usePersistentContext<string | null>`).
return [(data ?? fallbackValue) as T, updateValue, isFetched, isLoading];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the cast makes the signature less honest than it was, in the opposite direction from the commit's intent.

getAsyncCache now correctly returns T | null, but as T re-hides that at the boundary: any caller that passes neither valueWhenCacheEmpty nor fallbackValue still receives null typed as T, and the compiler will not tell them. The comment carries the contract instead of the types, so the next caller has to read the hook body to learn the rule.

Since the tuple type is already exported, encoding it — UserPersistentContextType<T | null> when no fallback is supplied, via an overload or a conditional on the argument — keeps the fix without the cast. Happy for this to be a follow-up given the PR's scope, but the cast plus comment is the thing this commit was trying to remove.

Reviewed by AI.

claimed: number;
className?: string;
}): ReactElement => (
<div className={classNames('flex items-center gap-1.5', className)}>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the strip has no wrap and no cap, and total grew in the last commit.

getDailyQuestSummary now counts locked Plus quests toward total on purpose, so this renders one 28px circle per daily quest, regular and Plus. In the split layout the panel is w-[19rem] with p-6, leaving roughly 256px of usable width — with gap-1.5 that fits about seven steps before the row overflows the panel it sits in. QuestOfferSplit only renders on desktop so a phone won't hit it, but the count is API-driven and nothing in the component defends against it.

flex-wrap on this container, or capping at N with a +n chip, keeps it safe whatever the daily set grows to.

Reviewed by AI.

…nominator

Addresses PR review.

`useLazyModal` reads MODAL_KEY through react-query, so `modal` is a
render snapshot, and MainLayout mounts three independent writers to that
key whose effects run in the same commit. A user due a streak milestone
who had also claimed a quest could have both write, last one wins, and
the loser was dropped silently with its side effects already run — in
our case the day stamp burned for offers that were never shown, never
logged an impression, and never confirmed back to Encore. The trigger
now reads the live value immediately before opening, and delegates the
day stamp to the modal's mount so it can only be written for a popup
that reached the screen.

The eligibility effect waited on its own stamp read while the modal
effect did not, so whichever idb read landed first decided the outcome:
opening the modal flips `shouldShow` false for the day, silently
dropping the event. The bias was one-directional and landed on
treatment-with-inventory, under-counting the denominator exactly where
the numerator lives. Both stamps are now gated in `shouldShow`.

Also from review: `retry: false` makes a failed offers fetch settle with
no offers, indistinguishable from empty inventory, so the eligibility
event carries `failed`; the step strip wraps now that locked quests
count toward a total the API controls; and the once-per-day stamp idiom
is a single `isTodayStamp` in lib/dateFormat rather than a copy here and
another in useReadingReminderHero.

usePersistentContext trades the `as T` cast for overloads, so an omitted
`valueWhenCacheEmpty` is typed `T | null` at the call site instead of
having the rule live in a comment. Every current caller passes one, so
nothing changes for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rebelchris
rebelchris merged commit 8a400bc into main Sep 2, 2026
12 checks passed
@rebelchris
rebelchris deleted the quest-offers-experiment branch September 2, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant