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 today = cstDateString()
const nextDay = nextCstDateString() const nextDay = nextCstDateString()
const questionId = await pickRandomQuestionId() const questionId = await pickDailyQuestionId(today)
if (!questionId) { if (!questionId) {
console.error('[assignDailyQuestion] no active questions available') console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
return return
} }
@ -109,7 +109,7 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
if (date !== today) { if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for 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) { if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.') 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 * so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow. * 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() 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 const snapshot = await db
.collection('questions') .collection('questions')
.where('active', '==', true) .where('active', '==', true)
.where('isPremium', '==', false) .where('isPremium', '==', false)
.get() .get()
if (snapshot.empty) return null
if (snapshot.empty) { const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
// Rollout fallback: keep the feature working until the questions pool is seeded. const modeTag = WEEKDAY_MODE_TAGS[weekday]
return 'q_default_daily' 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 docs = snapshot.docs const pool = inMode.length > 0
const chosen = docs[Math.floor(Math.random() * docs.length)] ? inMode
return chosen.id : 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
} }
/** /**