Skip to content

feat(streak-freeze): freeze packs bought with Cores, auto-applied on missed days#4011

Merged
rebelchris merged 10 commits into
mainfrom
feat/auto-streak-freeze
Jul 21, 2026
Merged

feat(streak-freeze): freeze packs bought with Cores, auto-applied on missed days#4011
rebelchris merged 10 commits into
mainfrom
feat/auto-streak-freeze

Conversation

@rebelchris

Copy link
Copy Markdown
Contributor

What

Backend for streak freezes: users buy freeze packs with Cores (cap 5); the streak cron consumes a freeze instead of resetting when a day is missed (grace-first), with an opt-out setting.

  • UserStreak.freezesAvailable column + migration, UserStreakAction freeze records, seed products
  • Purchase mutation + products/freeze-dates queries; freezesAvailable exposed on userStreak
  • updateCurrentStreak cron auto-consumes a freeze on miss instead of resetting
  • Settings.optOutStreakFreeze + migration
  • Notifications: streak_freeze_used and streak_freeze_depleted (in-app subscribed, email muted)

Frontend

dailydotdev/apps#6340

🤖 Generated with Claude Code

rebelchris and others added 3 commits July 16, 2026 20:02
…ery GraphQL surface

Adds the data model and GraphQL surface for automated streak freeze: a
freezesAvailable counter on user_streak, a streak_freeze Product type
(seeded with 3 placeholder-priced packs), a freeze UserStreakAction type,
and an optOutStreakFreeze setting. Exposes streakFreezeProducts,
userStreakFreezeDates, and purchaseStreakFreeze (Cores purchase with a
5-freeze stockpile cap) following the existing recoverStreak/award patterns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires freeze consumption into both reset paths (the hourly cron and the
lazy checkAndClearUserStreak read path) so behavior is identical: when a
reset would occur, compute the missed days beyond the existing grace
period and, if the user's balance covers all of them (has Cores access,
hasn't opted out), consume one freeze per missed day and emit one
api.v1.user-streak-updated event per frozen day instead of clearing the
streak. Otherwise the existing reset-and-recover flow is unchanged.

The gap calculation now folds in the most recent freeze action (in
addition to the existing recover action) so a freeze consumed in one
cron run advances the "last covered day" and the next run does not
re-freeze or double-count the same missed day.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds streak_freeze_used and streak_freeze_depleted notification types,
title/builder entries, and a worker subscribed to the existing
api.v1.user-streak-updated topic that reacts only to the freeze
discriminator payload (ignores the regular CDC-driven updates other
subscribers on that topic care about). Emits StreakFreezeUsed per frozen
day, plus StreakFreezeDepleted when no freezes remain; uniqueKey is the
frozen day so repeated cron runs stay idempotent at the notification
layer. No email template yet, so email defaults to muted like other
placeholder notification types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pulumi

pulumi Bot commented Jul 20, 2026

Copy link
Copy Markdown

🍹 The Update (preview) for dailydotdev/api/prod (at 21cf0bb) was successful.

Resource Changes

    Name                                                       Type                           Operation
~   vpc-native-daily-digest-cron                               kubernetes:batch/v1:CronJob    update
~   vpc-native-temporal-deployment                             kubernetes:apps/v1:Deployment  update
~   vpc-native-user-profile-updated-sync-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-rotate-daily-quests-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-update-views-cron                               kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-users-cron                         kubernetes:batch/v1:CronJob    update
~   vpc-native-bg-deployment                                   kubernetes:apps/v1:Deployment  update
~   vpc-native-update-achievement-rarity-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-validate-active-users-cron                      kubernetes:batch/v1:CronJob    update
~   vpc-native-user-posts-analytics-refresh-cron               kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-expired-better-auth-sessions-cron         kubernetes:batch/v1:CronJob    update
~   vpc-native-private-deployment                              kubernetes:apps/v1:Deployment  update
~   vpc-native-update-tags-str-cron                            kubernetes:batch/v1:CronJob    update
~   vpc-native-update-current-streak-cron                      kubernetes:batch/v1:CronJob    update
+   vpc-native-api-db-migration-0c0a6e9c                       kubernetes:batch/v1:Job        create
~   vpc-native-check-analytics-report-cron                     kubernetes:batch/v1:CronJob    update
~   vpc-native-rotate-weekly-quests-cron                       kubernetes:batch/v1:CronJob    update
~   vpc-native-channel-highlights-cron                         kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-user-companies-cron                kubernetes:batch/v1:CronJob    update
~   vpc-native-update-source-public-threshold-cron             kubernetes:batch/v1:CronJob    update
~   vpc-native-channel-digests-cron                            kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-channel-highlights-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-stale-user-transactions-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-expire-super-agent-trial-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-sync-subscription-with-cio-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-old-notifications-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-images-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-update-trending-cron                            kubernetes:batch/v1:CronJob    update
~   vpc-native-generic-referral-reminder-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-user-profile-analytics-history-clickhouse-cron  kubernetes:batch/v1:CronJob    update
-   vpc-native-api-db-migration-84decd9a                       kubernetes:batch/v1:Job        delete
~   vpc-native-materialize-monthly-best-post-archives-cron     kubernetes:batch/v1:CronJob    update
~   vpc-native-deployment                                      kubernetes:apps/v1:Deployment  update
-   vpc-native-api-clickhouse-migration-84decd9a               kubernetes:batch/v1:Job        delete
~   vpc-native-clean-zombie-opportunities-cron                 kubernetes:batch/v1:CronJob    update
~   vpc-native-squad-posts-analytics-refresh-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-personalized-digest-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-hourly-notification-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-post-analytics-history-day-clickhouse-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-user-profile-analytics-clickhouse-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-subscription-anniversary-achievements-cron      kubernetes:batch/v1:CronJob    update
... and 17 other changes

# Conflicts:
#	src/workers/newNotificationV2Mail.ts
Comment thread src/common/users.ts
// Attempts to consume one streak freeze per missed day. Returns true when the
// streak was frozen (and therefore should NOT be reset), false otherwise (the
// caller is expected to fall back to resetting the streak).
export const tryConsumeStreakFreeze = async (

@capJavert capJavert Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should this iterate over multiple days instead of restoring at specific point in time, can you even miss multiple days, it happens day by day no?

So i would expect each day when streak is reset, we try and consume single streak freeze, no multiple ones via missedDays.

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.

In the healthy-cron case it is one day at a time — the hourly cron triggers on the first day beyond grace, so missedDays has length 1 and one freeze is consumed per day. The array form only kicks in on the catch-up paths (lazy checkAndClearUserStreak when the cron didn't get there, cron downtime), where the gap can span several days at once. There, freezing a single day wouldn't keep the streak alive — the remaining gap still fails checkUserStreak on the next evaluation — so we either cover the whole gap or consume nothing and fall back to reset/restore. Each consumed freeze is still recorded as a per-day dated UserStreakAction so lastFreezeAt bookkeeping stays day-accurate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What happens if user bought 3 resets but is missing 5 days? Do we consume or leave since user loses streak either way?

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.

We leave them. In tryConsumeStreakFreeze (src/common/users.ts), the guard freezesAvailable < missedDays.length returns false before touching anything — no
transaction runs, no UserStreakAction rows are written, no events fire. So with 3 freezes and 5 missed days: the user keeps all 3 freezes, the streak
resets, and the recover flow (100 Cores restore) becomes their fallback — after which the 3 freezes are still banked for next time.

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.

But yeah worth noting it likely never would happen (only if user buys streak freeze after already loosing so many basically)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ok, sounds good then, lets see how it goes

…agsSchema

The updateNotificationSettings zod schema strips unknown keys, so the
new streak_freeze_used/streak_freeze_depleted preferences were silently
dropped on write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rebelchris
rebelchris requested a review from capJavert July 20, 2026 14:13
@capJavert

Copy link
Copy Markdown
Contributor

@claude review

@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @capJavert's task in 5m 36s —— View job


Claude Code Review

Reviewed the full diff (2c6bbef^…eff95fb) across entities, migrations, the purchase mutation, the cron/consumption logic, and notifications. Overall this is a well-structured, cohesive change — the grace-first refactor (getStreakGraceDays / getStreakResetContext) is clean, the cap check is correctly ordered before charging Cores, and the reset-notification worker is correctly not triggered by a freeze. Notes below, roughly by importance.

🟡 Worth a look

1. Notifications are published inside the cron's DB transactionsrc/common/users.ts (tryConsumeStreakFreeze)
The for (… of missedDays) triggerTypedEvent(…) loop runs after the inner con.transaction(...) but, on the cron path, still inside the outer cron transaction (updateCurrentStreak wraps everything in con.transaction). The inner transaction reuses the same query runner as a SAVEPOINT, so writes are correct — but the events fire before the outer transaction commits. If a later user in the loop or clearUserStreak throws and the outer transaction rolls back, the freeze writes are rolled back yet the "freeze used" notifications were already emitted. Consider collecting events and publishing them after the outer transaction commits (the existing streak-reset flow has the same shape, so this is pre-existing risk, not a regression).

2. Multi-day catch-up emits one notification per daysrc/common/users.ts (tryConsumeStreakFreeze) + src/workers/notifications/streakFreezeNotification.ts
On the catch-up paths (cron downtime / lazy checkAndClearUserStreak), a single call publishes N user-streak-updated events, producing N separate in-app StreakFreezeUsed notifications at once (plus a StreakFreezeDepleted). This matches the per-day bookkeeping you described, but from a UX angle a user opening the app to 3–4 identical "freeze used" notifications may feel spammy. Worth confirming with design whether these should be coalesced into a single "N freezes used" notification while still writing per-day UserStreakAction rows.

3. purchaseStreakFreeze doesn't exclude restricted productssrc/schema/users.ts (purchaseStreakFreeze)
streakFreezeProducts filters flags->>'restricted' IS DISTINCT FROM 'true', but the purchase mutation only validates product.type === ProductType.StreakFreeze. A client passing a restricted streak-freeze product id directly could still buy it. No restricted streak_freeze products are seeded today, so it's not currently exploitable, but the asymmetry is a latent gap — consider rejecting restricted products in the mutation too.

🟢 Nits

  • Non-null assertion violates CLAUDE.mdsrc/schema/users.ts:4326 uses transfer.senderBalance!.newBalance. The repo guideline is to avoid !. It matches the existing pattern at line ~4434, so this is consistency-neutral, but ideally guard explicitly.
  • product.flags?.quantity || 1src/schema/users.ts (purchaseStreakFreeze). || would coerce a legitimate 0 to 1; ?? is safer here even though 0 shouldn't occur in seeds.

❓ Question — missed-day dates around weekends

In getMissedStreakDays, the recorded days are subDays(today, missedCount - index), i.e. the most recent calendar days, regardless of which days were actually weekdays vs the free weekend. Example (Monday start, grace = 3 on Monday): user last read Thursday, difference = 4, missedCount = 1 → the recorded frozen day is Sunday, whereas the day that actually broke the streak is Friday. Since combineLastActionDates uses max(...), recording the later date (Sunday) is safe for subsequent reset math, but the date shown to the user via userStreakFreezeDates / the notification may land on a weekend day that wasn't really the missed reading day. Is that acceptable for the UI, or should the recorded date reflect the actual missed weekday?


Nothing here is blocking; #1#3 are the ones I'd weigh before merge. Nice work on the test coverage (cron, purchase, and worker paths are all exercised).
· branch feat/auto-streak-freeze

rebelchris and others added 2 commits July 21, 2026 09:43
…ent, reject restricted products

- tryConsumeStreakFreeze now returns freeze events instead of publishing
  them, so the cron publishes after its transaction commits — a rollback
  can no longer emit notifications for freezes that were never consumed
- the freeze decrement is a guarded atomic UPDATE instead of writing a
  snapshot-derived value, so concurrent purchases aren't clobbered
- purchaseStreakFreeze rejects restricted products, matching the
  streakFreezeProducts query filter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rebelchris

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 21e985b7 (rebased: see latest):

  • chore: add cloudbuild support #1 (events inside cron transaction): tryConsumeStreakFreeze no longer publishes — it returns the freeze events and the cron publishes them after its transaction commits, so a rollback can't emit notifications for freezes that were never consumed. The lazy checkAndClearUserStreak path publishes immediately as before. While in there, the freeze decrement is now a guarded atomic UPDATE … SET "freezesAvailable" = "freezesAvailable" - :count WHERE "freezesAvailable" >= :count instead of writing a snapshot-derived value, so a concurrent purchase can't be clobbered mid-cron.
  • Send Pub/Sub message on every event of publication request #3 (restricted products): purchaseStreakFreeze now rejects restricted products, matching the streakFreezeProducts filter — with a test.
  • Nit: quantity || 1quantity ?? 1.
  • chore: add helm chart #2 (N notifications on catch-up): leaving as designed — one notification per frozen day was the product decision, and the multi-day case only occurs on catch-up paths (cron downtime / lazy check), which should be rare. Can coalesce later if it proves spammy.
  • ❓ (weekend-dated freezes): acknowledged — the later date is deliberate since combineLastActionDates uses max(); re-dating to the actual missed weekday would move lastActionTime backwards and risk re-triggering resets. Cosmetic impact only (calendar may mark a weekend day as frozen).
  • The ! nit at users.ts:4326 is in the preceding resolver, not this PR's diff.

🤖 Generated with Claude Code

@rebelchris
rebelchris merged commit 81be39f into main Jul 21, 2026
9 checks passed
@rebelchris
rebelchris deleted the feat/auto-streak-freeze branch July 21, 2026 11:56
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.

2 participants