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 namedebate-community;apps/debate-ai.com(/api/settings'squestStreakSyncfield, theuser_settings.quest_streak_syncD1 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 oneAccount 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:
streakFreezesrecords are naturally keyed by the pair(contributorId, dayKey), not a single field.streakLapseRemindersisn't an array of records at all — it's a barestring[]of opted-in contributor ids.dailyMissionResultsis keyed by the same(contributorId, dayKey)pair asstreakFreezes, 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 andmergeRemoteStreakLapseReminderEnabled'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 remoteisCompletediffers, 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, theMAX_QUEST_STREAK_FREEZE_DAY_KEYS/MAX_QUEST_STREAK_MISSION_RESULT_DAYScaps, a payload with nomissionResultDaysfield still validating, rejecting an unrecognized field such as a smuggledcontributorId),normalizeQuestStreakSyncPatch's PUT-body handling, the serialize/parse round-trip (corrupt or invalid-shape JSON degrades tonullrather than throwing, and a pre-existing stored value with nomissionResultDaysparses back unchanged), andnormalizeQuestStreakFreezeOpPatch/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'sisCompletecan still update even once the list is at capacity).useQuestStreakSyncand its wiring inQuestStreaksPanelare intentionally untested, matching this repo's existing convention for account-synced,localStorage-backed hooks and their UI (e.g.tool-data-sync.md'suseToolRecordSync,judge-paradigm-selections.md'suseJudgeDecisions).
Known gaps
- Only the signed-in visitor's own freeze/reminder preferences sync to
an account (via the bespoke
quest_streak_synccolumn above) — every other contributor's freeze usage and reminder opt-in stays per-browserlocalStorage, 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 ordinaryTOOL_RECORD_COLLECTIONSallowlist 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_COLLECTIONSstore 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'striggers.crons, handled byworker/index.ts'sscheduledexport), 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.
