diff --git a/functions/src/questions/assignDailyQuestion.ts b/functions/src/questions/assignDailyQuestion.ts index 9b8a3abf..8aac15f0 100644 --- a/functions/src/questions/assignDailyQuestion.ts +++ b/functions/src/questions/assignDailyQuestion.ts @@ -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 { +// 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 = { + 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 { 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 } /**