Quest Streaks

A roster of every contributor's daily-quest streak and milestone badges, a manual "Run today's mission check" trigger, a streak freeze ("grace day") action for a missed day, and an opt-in streak-lapse reminder banner — with the signed-in visitor's own freeze usage, reminder opt-in, and daily-mission-result history synced to their account.

  • Route: /cards/streaks
  • Nav: the Tools page's Community group
  • Package: debate-contributor-progress (package name debate-community; apps/debate-ai.com (/api/settings's questStreakSync field, the user_settings.quest_streak_sync D1 column).

What it shows

QuestStreaksPanel.tsx renders one row per contributor with at least one persisted daily-mission result or streak freeze: current streak, longest streak, last completed day, every milestone badge earned, a Streak freeze column, and a Reminder column. Above the table, a "Run today's mission check" form lets any contributor id compute and save that day's mission result on demand — nothing wires mission-result computation to the repo's cron trigger (worker/index.ts's scheduled export; see the Known gaps section below), so this manual trigger is the only way a day's result gets recorded. The signed-in visitor's own row (via an optional signedInContributorId prop, derived from a real session by debate-research-evidence's deriveContributorIdFromSessionIdentity) is highlighted with a "You" badge.

Streak freeze: when yesterday broke an in-progress streak (lib/gamified-quests.ts#findFreezableStreakGapDayKey), a "Use a grace day for …" button spends one of up to MAX_STREAK_FREEZES_PER_WINDOW (2) freezes per rolling STREAK_FREEZE_WINDOW_DAYS (30) window (state/streakFreezes.ts#applyPersistedStreakFreeze), bridging the streak instead of resetting it to zero. Denied attempts (future day, already complete, already frozen, no freezes left) show a message instead of writing anything.

Reminder: a per-contributor 🔔 toggle (state/streakLapseReminders.ts) that, once on, shows a warning banner on that row whenever the contributor's in-progress streak is at risk of lapsing today (lib/gamified-quests.ts#getStreakLapseRiskLength). There is no push-notification infrastructure either, so the "reminder" is this in-app banner, seen when the contributor visits the panel — not a real push notification.

Data flow

state/dailyMissionResults.ts    (localStorage: dailyMissionResults)
  → saveDailyMissionResult stamps id `${contributorId}::${dayKey}`
  → synced via debate-data-sync's TOOL_RECORD_COLLECTIONS (`dailyMissionResults`)
state/streakFreezes.ts          (localStorage: streakFreezes)
  → applyPersistedStreakFreeze / mergeRemoteStreakFreezeDayKeys
  → buildQuestStreakRosterWithFreezes(todayUtcDayKey())
state/streakLapseReminders.ts   (localStorage: streakLapseReminders)
  → setStreakLapseReminderEnabled / mergeRemoteStreakLapseReminderEnabled
  → getPersistedStreakLapseReminderInfo
panels/QuestStreaksPanel.tsx
  → useQuestStreakSync(signedInContributorId, refresh) — the freeze/reminder account sync below
apps/debate-ai.com/app/cards/streaks/page.tsx — mounts the panel as a route

Freeze/reminder account sync (signed-in visitor's own row only):
lib/quest-streak-sync.ts        (pure — no fetch, no localStorage)
  → QuestStreakSyncPayload          { lapseReminderEnabled, freezeDayKeys,
                                       missionResultDays? }
  → isValidQuestStreakSyncPayload   exactly these three keys; freezeDayKeys
                                       capped at MAX_QUEST_STREAK_FREEZE_DAY_KEYS
                                       (366) YYYY-MM-DD strings;
                                       missionResultDays optional (absent on
                                       any payload saved before this field
                                       existed), capped at
                                       MAX_QUEST_STREAK_MISSION_RESULT_DAYS
                                       (366) { dayKey, isComplete } entries
  → normalizeQuestStreakSyncPatch   validates an untrusted PUT body's
                                       `questStreakSync` field (null clears it)
  → normalizeQuestStreakFreezeOpPatch / applyQuestStreakFreezeOp
                                     validates/applies a single
                                       `recordStreakFreezeDayKey` op against
                                       the account's *current* stored payload
  → normalizeQuestStreakReminderOpPatch / applyQuestStreakReminderOp
                                     validates/applies a single
                                       `setLapseReminderEnabled` op the same way
  → normalizeQuestStreakMissionResultOpPatch / applyQuestStreakMissionResultOp
                                     validates/applies a single
                                       `recordMissionResultDay` op — upserted
                                       by dayKey rather than only appended,
                                       since a day's result can flip from
                                       incomplete to complete later the same
                                       day
  → serializeQuestStreakSync / parseQuestStreakSync
                                     the user_settings.quest_streak_sync
                                       column, JSON-stringified

lib/quest-streak-sync-client.ts (fetch only)
  → fetchQuestStreakSync            GET /api/settings (null on 401)
  → saveStreakFreezeDayKeyOp        PUT /api/settings { recordStreakFreezeDayKey }
  → saveLapseReminderEnabledOp      PUT /api/settings { setLapseReminderEnabled }
  → saveMissionResultDayOp          PUT /api/settings { recordMissionResultDay }
  → saveQuestStreakSync             PUT /api/settings { questStreakSync } — the
                                       whole-value replace, kept for a caller
                                       that genuinely needs one; no code path
                                       in this app sends one anymore

hooks/useQuestStreakSync.ts     (the glue QuestStreaksPanel calls)
  → on mount: fetches the account's payload, merges its freezeDayKeys/
      lapseReminderEnabled/missionResultDays into the local stores via
      mergeRemoteStreakFreezeDayKeys/mergeRemoteStreakLapseReminderEnabled/
      mergeRemoteMissionResultDays, calls the caller's onMerged() if
      anything actually changed
  → pushFreezeDayKey(dayKey)        called by QuestStreaksPanel right after a
      freeze is spent for the signed-in visitor's own row
  → pushLapseReminderEnabled(enabled)
                                     called by QuestStreaksPanel right after
      the signed-in visitor's own reminder opt-in is toggled
  → pushMissionResultDay({ dayKey, isComplete })
                                     called by QuestStreaksPanel right after
      the signed-in visitor's own mission result is recorded

apps/debate-ai.com
  → app/api/settings/route.ts       GET/PUT — one user_settings row per
                                       signed-in user; requires a session,
                                       401 without one

Account sync, in detail

Two separate syncs cover this panel, for two different reasons.

The day-by-day mission-result history (state/dailyMissionResults.ts, localStorage key dailyMissionResults) — the actual per-contributor, per-day record the streak/badge roster is computed from — syncs through Tool Data Sync's ordinary TOOL_RECORD_COLLECTIONS allowlist (dailyMissionResults, idField: "id"), the same as any other tool-record store. It didn't always: DailyMissionResultRecord used to be keyed by the pair (contributorId, dayKey) alone, with no single id field, so it couldn't join that allowlist — the same shape problem coachingSessions had. saveDailyMissionResult now stamps a deterministic ${contributorId}::${dayKey} id onto every record (mirroring coachingSessions' own fix), so a contributor's mission-result history — and the streak/badge state derived from it — now follows them to a second device, same as every other synced tool.

The signed-in visitor's own streakFreezes/streakLapseReminders preferences are the one part of this panel that still doesn't go through that allowlist — neither store fits its required shape (a JSON array of objects each carrying one stable string id field), and both are genuinely account-wide preferences rather than per-record history:

  • streakFreezes records are naturally keyed by the pair (contributorId, dayKey), not a single field.
  • streakLapseReminders isn't an array of records at all — it's a bare string[] of opted-in contributor ids.
  • dailyMissionResults is keyed by the same (contributorId, dayKey) pair as streakFreezes, for the same reason.

Only the signed-in visitor's own row's freeze/reminder preferences sync this second way — every other contributor's freeze/reminder state in the roster is local-only, same as before either sync existed. That matches the same "no contributor identity system, only a free-text contributor id" limit dailyMissionResults' own sync above is subject to too: there is no way to know whose account to write a different contributor's row to beyond the one real session in the room.

So this second sync instead bundles both preferences into one JSON value — QuestStreakSyncPayload, { lapseReminderEnabled, freezeDayKeys } — on the quest_streak_sync column of the same user_settings row every other per-user setting (theme, favorite tools, Research Progress's goal, Standings' point-weight table) already uses, mirroring research-progress-goal-sync.ts's split exactly: a framework-free validation/serialization module (lib/quest-streak-sync.ts), a fetch-only client (lib/quest-streak-sync-client.ts), and a React hook (hooks/useQuestStreakSync.ts) that wires the two together against the existing local stores. missionResultDays is optional rather than required, mirroring state/challengeWinEvents.ts's "leave a new field on a synced payload optional" convention, so a quest_streak_sync row saved before this field existed still parses and validates without it, instead of the whole row reading back as null.

The merge is additive-only, never a whole-value replace. mergeRemoteStreakFreezeDayKeys adds only the remote dayKeys this device doesn't already have (mirroring tool-data-sync.mdx's "union, never remove" merge convention) and deliberately bypasses canApplyStreakFreeze — re-validating a freeze that was already spent and synced from another device against this device's copy of the mission history could reject a freeze that's already real (e.g. if this device hasn't synced the mission result that justified it yet). mergeRemoteStreakLapseReminderEnabled only ever turns the local opt-in on when the remote says true; a remote false means "not yet synced from elsewhere," not "explicitly disabled" (there is no way to distinguish those from a single boolean), so it is never used to turn a locally-enabled reminder back off. mergeRemoteMissionResultDays adds only the remote days this device doesn't already have a local record for — a day already recorded locally is left alone rather than overwritten by the remote copy, since the remote value could itself be stale relative to a local recompute that hasn't synced up yet.

Local write always wins, sync is best-effort. applyPersistedStreakFreeze, setStreakLapseReminderEnabled, and computeAndSavePersistedDailyMissionResult write to localStorage synchronously and return before any network call. QuestStreaksPanel's handleUseFreeze/handleToggleReminder/handleRunCheck call pushFreezeDayKey/pushLapseReminderEnabled/pushMissionResultDay afterward, only when the row being changed is the signed-in visitor's own (id === signedInContributorId) — every other row's change never reaches the network, since there's no account to sync it to. All three pushes are a no-op until the mount-time fetch has resolved (remoteAvailableRef), so a push can't race ahead of the merge and re-upload a value the merge hasn't reconciled yet; each swallows its own rejection, matching every other synced store's "local apply is never blocked by a sync failure" rule.

The push is op-based, never a whole-value replace. Before this, pushLocalState read both of this device's local preferences and PUT the entire questStreakSync payload — the exact "client computes the whole next value and PUTs it" lost-update shape favoriteTools/outlineFilterPresets/ newsRead all had before their own op-based fixes. Two tabs or devices spending a streak freeze on different days close together could silently drop each other's freeze from the account — and since canApplyStreakFreeze validates against this same freezeDayKeys list, the dropped freeze could let a contributor "re-spend" one they'd already used. Worse, a reminder toggle on one device resent that device's entire (possibly stale) freezeDayKeys copy too, so it could revert a freeze another device had just spent, even though the reminder toggle never touched freezes at all. pushFreezeDayKey now sends a single recordStreakFreezeDayKey op, resolved server-side (quest-streak-sync.ts#applyQuestStreakFreezeOp) against the account's current stored freezeDayKeys; pushLapseReminderEnabled sends a single setLapseReminderEnabled op (applyQuestStreakReminderOp) that replaces only the reminder flag, leaving freezeDayKeys at its current stored value untouched. A plain whole-value questStreakSync replace is still accepted by the route for a caller that genuinely needs one, but useQuestStreakSync.ts no longer sends one.

Cross-tab live update

QuestStreaksPanel subscribes to the browser's storage event via state/live-update.ts's isQuestStreaksLiveUpdateStorageEvent and calls its refresh() closure when a daily mission result or streak freeze changes in another tab of the same browser — the storage event never fires in the tab that made the write. This covers other tabs on the same device only; a change synced in from a different device is picked up on this device's next mount (the account merge above), not live.

Tests

  • packages/debate-contributor-progress/test/streakFreezes.test.ts — applyPersistedStreakFreeze's denial reasons, mergeRemoteStreakFreezeDayKeys's additive-only merge (adds only new dayKeys, never removes one, reports whether anything actually changed), and the roster builders.
  • packages/debate-contributor-progress/test/streakLapseReminders.test.ts — the opt-in toggle's no-duplicate-write behavior and mergeRemoteStreakLapseReminderEnabled's "only ever turns on, never off" rule.
  • packages/debate-contributor-progress/test/dailyMissionResults.test.ts — mergeRemoteMissionResultDays's additive-only merge (adds only new days, never overwrites an already-recorded local day even when the remote isComplete differs, reports whether anything actually changed, and leaves other contributors' history untouched).
  • packages/debate-contributor-progress/test/quest-streak-sync.test.ts — isValidQuestStreakSyncPayload's full validation surface (the day-key format, the MAX_QUEST_STREAK_FREEZE_DAY_KEYS/MAX_QUEST_STREAK_MISSION_RESULT_DAYS caps, a payload with no missionResultDays field still validating, rejecting an unrecognized field such as a smuggled contributorId), normalizeQuestStreakSyncPatch's PUT-body handling, the serialize/parse round-trip (corrupt or invalid-shape JSON degrades to null rather than throwing, and a pre-existing stored value with no missionResultDays parses back unchanged), and normalizeQuestStreakFreezeOpPatch/applyQuestStreakFreezeOp/ normalizeQuestStreakReminderOpPatch/applyQuestStreakReminderOp/ normalizeQuestStreakMissionResultOpPatch/applyQuestStreakMissionResultOp (append, dedupe, at-capacity drop, an empty-payload default, two-concurrent-freezes resolving onto the same starting payload without dropping either, an explicit "a reminder toggle on one device never reverts a freeze another device just spent" case demonstrating the exact race this closes, and the mission-result op's upsert-by-dayKey behavior including that an already-recorded day's isComplete can still update even once the list is at capacity).
  • useQuestStreakSync and its wiring in QuestStreaksPanel are intentionally untested, matching this repo's existing convention for account-synced, localStorage-backed hooks and their UI (e.g. tool-data-sync.md's useToolRecordSync, judge-paradigm-selections.md's useJudgeDecisions).

Known gaps

  • Only the signed-in visitor's own freeze/reminder preferences sync to an account (via the bespoke quest_streak_sync column above) — every other contributor's freeze usage and reminder opt-in stays per-browser localStorage, the same "per-browser, not a shared team resource" gap Tool Data Sync notes for Judge Profiles and Opponent Team Profiles, since there is no contributor-identity system in this repo to sync anyone else's preferences to. The mission-result history itself is different: it syncs through the ordinary TOOL_RECORD_COLLECTIONS allowlist like any other Team collection, so every contributor's row this browser has locally computed or received follows the signed-in user to their other devices — not scoped to the visitor's own contributor id. It still can't produce a row for a contributor no signed-in device has ever computed a mission result for locally.
  • The account merge runs once, on mount, and on a manual freeze/reminder change; there is no push channel, so a freeze spent or a reminder toggled on another device shows up on this device's next page load rather than live. The mission-result sync above follows the same "next reload, not live" rule every TOOL_RECORD_COLLECTIONS store does.
  • The trigger for a day's mission result is a manual button click, not a scheduled job. The repo does now have one cron trigger (apps/debate-ai.com/wrangler.jsonc's triggers.crons, handled by worker/index.ts's scheduled export), but it only runs the weekly YouTube channel/view-count sync and the reuse-check-log purge — nothing wires mission-result computation to it, so the manual trigger stays the only way a day's result gets recorded. See the feature doc's Known gaps for the same point from a user's perspective.