fix(functions): server-authoritative, mode-aware, deterministic daily question

Replace pickRandomQuestionId (empty pool → unresolvable q_default_daily fallback)
with pickDailyQuestionId(date): computes today's weekday mode (mirrors the client
DailyModeResolver.DOW_DEFAULTS, FROZEN) and deterministically indexes the free
daily pool by epochDay % poolSize — the SAME question the free client would pick,
now assigned server-side.

Fixes two things at the root:
- The daily 'questions' pool was empty, so every couple got q_default_daily, which
  the client can't resolve → each device fell back to its own local selection
  (the DQ-MISMATCH-001 class; the client half was pinned to the free pool in C1).
- Partners in different time zones computed different device-local weekdays and got
  different questions; a single server-assigned id makes both devices identical.

Firestore 'questions' seeded with the 75 free daily_fun_mc weekday questions (ids
match the client asset DB so getQuestionById resolves them). Pool fetched once and
filtered in memory — no composite index needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 21:57:22 -05:00
parent fda53175a9
commit f2321d3536
1 changed files with 43 additions and 12 deletions

View File

@ -23,9 +23,9 @@ export const assignDailyQuestion = functions.pubsub
const today = cstDateString()
const nextDay = nextCstDateString()
const questionId = await pickRandomQuestionId()
const questionId = await pickDailyQuestionId(today)
if (!questionId) {
console.error('[assignDailyQuestion] no active questions available')
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
return
}
@ -109,7 +109,7 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
}
const questionId = await pickRandomQuestionId()
const questionId = await pickDailyQuestionId(today)
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.')
}
@ -150,22 +150,53 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
async function pickRandomQuestionId(): Promise<string | null> {
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS: Record<number, string> = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest',// Saturday — Side Quest Saturday
}
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr: string): number {
const [y, m, d] = dateStr.split('-').map(Number)
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000)
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
const db = admin.firestore()
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get()
if (snapshot.empty) return null
if (snapshot.empty) {
// Rollout fallback: keep the feature working until the questions pool is seeded.
return 'q_default_daily'
}
const docs = snapshot.docs
const chosen = docs[Math.floor(Math.random() * docs.length)]
return chosen.id
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
const modeTag = WEEKDAY_MODE_TAGS[weekday]
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // wildcard/empty-mode fallback
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length
return pool[offset].id
}
/**