diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt index 8f25b5e5..3c4bf59d 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt @@ -62,7 +62,11 @@ class FirestoreDesireSyncDataSource @Inject constructor( fun observeAnswers(coupleId: String, sessionId: String): Flow = callbackFlow { val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> - if (err != null || snap == null) return@addSnapshotListener + // Close on error so collectors see it and can surface a retryable ERROR state — + // silently returning left the game stuck on WAITING forever (same class as the + // Memory Lane hang; matches the Wheel/Capsule sources' pattern). + if (err != null) { close(err); return@addSnapshotListener } + if (snap == null) return@addSnapshotListener val aead = encryptionManager.aeadFor(coupleId) trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId))) } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt index c8b93f5c..f6e690b3 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt @@ -72,7 +72,11 @@ class FirestoreHowWellDataSource @Inject constructor( fun observeAnswers(coupleId: String, sessionId: String): Flow = callbackFlow { val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> - if (err != null || snap == null) return@addSnapshotListener + // Close on error so collectors see it and can surface a retryable ERROR state — + // silently returning left the game stuck on WAITING forever (same class as the + // Memory Lane hang; matches the Wheel/Capsule sources' pattern). + if (err != null) { close(err); return@addSnapshotListener } + if (snap == null) return@addSnapshotListener val aead = encryptionManager.aeadFor(coupleId) trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId))) } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt index 633fd484..7ef7fd2d 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreThisOrThatDataSource.kt @@ -69,7 +69,11 @@ class FirestoreThisOrThatDataSource @Inject constructor( fun observeAnswers(coupleId: String, sessionId: String): Flow = callbackFlow { val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> - if (err != null || snap == null) return@addSnapshotListener + // Close on error so collectors see it and can surface a retryable ERROR state — + // silently returning left the game stuck on WAITING forever (same class as the + // Memory Lane hang; matches the Wheel/Capsule sources' pattern). + if (err != null) { close(err); return@addSnapshotListener } + if (snap == null) return@addSnapshotListener @Suppress("UNCHECKED_CAST") val raw = snap.get("answers") as? Map val byUser = parseAnswers(raw, coupleId) diff --git a/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt b/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt index e8d85d04..c01c9105 100644 --- a/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt +++ b/app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt @@ -61,9 +61,16 @@ class DailyQuestionResolver @Inject constructor( firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today) }.onFailure { crashReporter.recordException(it) }.getOrNull() + // PAIRED FALLBACK MUST BE PREMIUM-INDEPENDENT (DQ-MISMATCH-001). The premium flag flips + // the selection POOL, and each partner resolves it independently at a different moment + // (per-user entitlement doc, transient read failures default to false, mid-day unlocks). + // With the pool differing, the "deterministic, identical on both devices" promise breaks: + // partners answer DIFFERENT questions and the reveal cross-compares them (and renders the + // partner's raw option id, since it isn't in this device's question config). Always fall + // back to the free pool for a couple — it is the only pool both devices agree on. val question = if (assignment != null) { questionRepository.getQuestionById(assignment.questionId) - ?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium) + ?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false) ?: questionRepository.getDailyQuestion() } else { // No assignment yet — request one, but stay usable with the deterministic local @@ -74,7 +81,7 @@ class DailyQuestionResolver @Inject constructor( firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: "" ) }.onFailure { crashReporter.recordException(it) }.getOrNull() - ?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium) + ?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false) ?: questionRepository.getDailyQuestion() } diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index 23ea3f9d..dd828c6a 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -952,12 +952,19 @@ private fun WaitingForPartnerCard() { } } + +/** Last-resort readability for a raw option id ("a_comfort_show_vote" → "A comfort show vote") so a + * slug never renders verbatim when the id isn't found in this device's question config — the same + * guard How Well grew for HW-BREAKDOWN-001. */ +private fun humanizeOptionId(id: String): String = + id.replace('_', ' ').trim().replaceFirstChar { it.uppercase() } + fun LocalAnswer.revealSummary(): String { return when (answerType) { "written" -> writtenText.orEmpty() "scale" -> "You chose ${scaleValue ?: "-"}." "single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts - .ifEmpty { selectedOptionIds } + .ifEmpty { selectedOptionIds.map(::humanizeOptionId) } .joinToString() else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." } } @@ -978,7 +985,7 @@ private fun LocalAnswer.partnerRevealSummary(): String { "written" -> writtenText.orEmpty() "scale" -> "They chose ${scaleValue ?: "-"}." "single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts - .ifEmpty { selectedOptionIds } + .ifEmpty { selectedOptionIds.map(::humanizeOptionId) } .joinToString() else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." } } diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index fc6580dd..a1f1b00c 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -78,6 +78,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import app.closer.ui.components.CloserGlyphs @@ -102,6 +103,7 @@ data class DesireSyncUiState( val selectedLength: SessionLength = SessionLength.STANDARD, val error: String? = null, val submitFailed: Boolean = false, + val syncFailed: Boolean = false, val navigateTo: String? = null ) @@ -301,6 +303,18 @@ class DesireSyncViewModel @Inject constructor( // The observer flips WAITING → REVEAL once the partner's answers land. } + /** Re-attach the partner-answers listener after a sync failure. */ + fun retrySync() { + _uiState.update { + it.copy( + phase = if (submitted) DesireSyncPhase.WAITING else DesireSyncPhase.ANSWER, + error = null, + syncFailed = false + ) + } + observeReveal() + } + fun retrySubmit() { val answers = _uiState.value.myAnswers if (answers.isEmpty()) return @@ -314,7 +328,14 @@ class DesireSyncViewModel @Inject constructor( val sId = sessionId?.takeIf { it.isNotBlank() } ?: return observeJob?.cancel() observeJob = viewModelScope.launch { - dataSource.observeAnswers(cId, sId).collect { answers -> + dataSource.observeAnswers(cId, sId) + .catch { e -> + // Listener failed (offline/permissions): surface a retryable error instead of + // hanging on WAITING forever (pairs with the data source's close(err)). + Log.w(TAG, "observeAnswers failed", e) + _uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) } + } + .collect { answers -> val mine = userId?.let { answers.byUser[it] } val theirs = partnerId?.let { answers.byUser[it] } when { @@ -427,7 +448,7 @@ fun DesireSyncScreen( DesireSyncPhase.ERROR -> DSErrorScreen( message = state.error ?: "Something didn't load. Go back and try again.", onBack = viewModel::quit, - onRetry = if (state.submitFailed) viewModel::retrySubmit else null + onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null ) DesireSyncPhase.SETUP -> DSSetupScreen( selectedLength = state.selectedLength, diff --git a/app/src/main/java/app/closer/ui/games/GameCopy.kt b/app/src/main/java/app/closer/ui/games/GameCopy.kt index 56aa1ebf..e0ef4f4e 100644 --- a/app/src/main/java/app/closer/ui/games/GameCopy.kt +++ b/app/src/main/java/app/closer/ui/games/GameCopy.kt @@ -4,4 +4,7 @@ package app.closer.ui.games object GameCopy { /** Shown when submitting a player's answers fails (network/transient) — the action is retryable. */ const val SAVE_ANSWERS_ERROR = "Couldn't save your answers. Check your connection and try again." + + /** Shown when the live partner-answers listener fails (network/permissions) — retry re-attaches it. */ + const val SYNC_ERROR = "Couldn't check your partner's progress. Check your connection and try again." } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 91795fc6..feb86c4f 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -85,6 +85,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import app.closer.ui.components.CloserGlyphs @@ -149,6 +150,7 @@ data class HowWellUiState( val selectedLength: SessionLength = SessionLength.STANDARD, val error: String? = null, val submitFailed: Boolean = false, + val syncFailed: Boolean = false, val navigateTo: String? = null ) @@ -333,6 +335,18 @@ class HowWellViewModel @Inject constructor( // The observer flips WAITING → COMPLETE once the partner's answers land. } + /** Re-attach the partner-answers listener after a sync failure. */ + fun retrySync() { + _uiState.update { + it.copy( + phase = if (submitted) HowWellPhase.WAITING else HowWellPhase.ANSWER, + error = null, + syncFailed = false + ) + } + observeReveal() + } + fun retrySubmit() { if (myAnswers.isEmpty()) return _uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) } @@ -345,7 +359,14 @@ class HowWellViewModel @Inject constructor( val sId = sessionId?.takeIf { it.isNotBlank() } ?: return observeJob?.cancel() observeJob = viewModelScope.launch { - dataSource.observeAnswers(cId, sId).collect { answers -> + dataSource.observeAnswers(cId, sId) + .catch { e -> + // Listener failed (offline/permissions): surface a retryable error instead of + // hanging on WAITING forever (pairs with the data source's close(err)). + Log.w(TAG, "observeAnswers failed", e) + _uiState.update { it.copy(phase = HowWellPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) } + } + .collect { answers -> val mine = userId?.let { answers.byUser[it] } val theirs = partnerId?.let { answers.byUser[it] } when { @@ -468,7 +489,7 @@ fun HowWellScreen( HowWellPhase.ERROR -> HowWellErrorScreen( message = state.error ?: "Something didn't load. Go back and try again.", onBack = viewModel::quit, - onRetry = if (state.submitFailed) viewModel::retrySubmit else null + onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null ) HowWellPhase.SETUP -> HWSetupScreen( selectedLength = state.selectedLength, diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 82e1ae24..0f70eceb 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -96,6 +96,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -150,6 +151,7 @@ data class ThisOrThatUiState( val selectedLength: SessionLength = SessionLength.STANDARD, val error: String? = null, val submitFailed: Boolean = false, + val syncFailed: Boolean = false, val navigateTo: String? = null ) @@ -302,7 +304,14 @@ class ThisOrThatViewModel @Inject constructor( val sId = sessionId?.takeIf { it.isNotBlank() } ?: return observeJob?.cancel() observeJob = viewModelScope.launch { - dataSource.observeAnswers(cId, sId).collect { answers -> + dataSource.observeAnswers(cId, sId) + .catch { e -> + // Listener failed (offline/permissions): surface a retryable error instead of + // hanging on WAITING forever (pairs with the data source's close(err)). + Log.w(TAG, "observeAnswers failed", e) + _uiState.update { it.copy(phase = TotPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) } + } + .collect { answers -> val mine = userId?.let { answers.byUser[it] } val theirs = partnerId?.let { answers.byUser[it] } when { @@ -421,6 +430,18 @@ class ThisOrThatViewModel @Inject constructor( } } + /** Re-attach the partner-answers listener after a sync failure. */ + fun retrySync() { + _uiState.update { + it.copy( + phase = if (submitted) TotPhase.WAITING else TotPhase.PLAYING, + error = null, + syncFailed = false + ) + } + observeReveal() + } + fun retrySubmit() { val answers = _uiState.value.myAnswers if (answers.isEmpty()) return @@ -478,7 +499,7 @@ fun ThisOrThatScreen( TotPhase.ERROR -> ErrorState( message = state.error ?: "Something didn't load. Go back and try again.", onBack = viewModel::quit, - onRetry = if (state.submitFailed) viewModel::retrySubmit else null + onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null ) TotPhase.WAITING -> WaitingForRevealScreen( partnerName = state.partnerName, diff --git a/functions/src/questions/assignDailyQuestion.test.ts b/functions/src/questions/assignDailyQuestion.test.ts new file mode 100644 index 00000000..089bdfe3 --- /dev/null +++ b/functions/src/questions/assignDailyQuestion.test.ts @@ -0,0 +1,40 @@ +import { formatCstDate, parseCstDate, timestampAt6PmCst } from './assignDailyQuestion' + +// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset +// mislabeled dates and shifted reveal times during the ~8 CDT months). +describe('Chicago date helpers (DST-safe)', () => { + it('labels a summer (CDT, UTC-5) evening with the correct local date', () => { + // 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too, + // but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge: + expect(formatCstDate(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07') + // 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date, + // but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date. + expect(formatCstDate(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08') + }) + + it('labels a winter (CST, UTC-6) instant correctly', () => { + // 2026-01-08T05:30Z == 2026-01-07 23:30 CST + expect(formatCstDate(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07') + expect(formatCstDate(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08') + }) + + it('parses midnight Chicago with the seasonal offset', () => { + // Summer: midnight CDT == 05:00Z + expect(parseCstDate('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z') + // Winter: midnight CST == 06:00Z + expect(parseCstDate('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z') + }) + + it('puts the 6 PM reveal at 6 PM local in both seasons', () => { + // Summer: 18:00 CDT == 23:00Z + expect(timestampAt6PmCst('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z') + // Winter: 18:00 CST == 00:00Z next day + expect(timestampAt6PmCst('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z') + }) + + it('round-trips format(parse(d)) across the spring-forward boundary', () => { + for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) { + expect(formatCstDate(parseCstDate(day))).toBe(day) + } + }) +}) diff --git a/functions/src/questions/assignDailyQuestion.ts b/functions/src/questions/assignDailyQuestion.ts index bb5fd2d7..9b8a3abf 100644 --- a/functions/src/questions/assignDailyQuestion.ts +++ b/functions/src/questions/assignDailyQuestion.ts @@ -1,7 +1,6 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -const CST_OFFSET_HOURS = -6 /** * Scheduled function that assigns one daily question per couple every day. @@ -185,38 +184,50 @@ function nextCstDateString(from?: string): string { d.setUTCDate(d.getUTCDate() + 1) return formatCstDate(d) } - const d = new Date() - const cst = applyCstOffset(d) - cst.setUTCDate(cst.getUTCDate() + 1) - return formatCstDate(cst) + // Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h. + const todayChicago = parseCstDate(formatCstDate(new Date())) + todayChicago.setUTCDate(todayChicago.getUTCDate() + 1) + return formatCstDate(todayChicago) } -function applyCstOffset(d: Date): Date { - const utcMs = d.getTime() - const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000 - return new Date(cstMs) +/** + * UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST). + * DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted + * reveals by an hour for the ~8 months of the year Chicago observes daylight time). + * Pattern matches streakReminder.ts's Intl-based chicagoDateKey. + */ +function chicagoOffsetHours(atUtcMs: number): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Chicago', + hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }).formatToParts(new Date(atUtcMs)) + const get = (t: string): number => Number(parts.find((p) => p.type === t)?.value) + const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute')) + return Math.round((atUtcMs - localAsUtcMs) / 3_600_000) } -function formatCstDate(d: Date): string { - const cst = applyCstOffset(d) - const y = cst.getUTCFullYear() - const m = String(cst.getUTCMonth() + 1).padStart(2, '0') - const day = String(cst.getUTCDate()).padStart(2, '0') - return `${y}-${m}-${day}` +/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */ +export function formatCstDate(d: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Chicago', + year: 'numeric', month: '2-digit', day: '2-digit', + }).format(d) } -function parseCstDate(dateStr: string): Date { +/** The UTC instant of midnight America/Chicago on the given calendar date. */ +export function parseCstDate(dateStr: string): Date { const [y, m, day] = dateStr.split('-').map(Number) - // Build a UTC timestamp that represents midnight CST, then reverse the offset. - const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0) - return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000) + // Guess with the standard offset, then correct with the real offset at that instant. + const guessMs = Date.UTC(y, m - 1, day, 6) + const offset = chicagoOffsetHours(guessMs) + return new Date(Date.UTC(y, m - 1, day, offset)) } -function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp { - const d = parseCstDate(dateStr) - const utcMs = d.getTime() - // 6:00 PM CST == 18:00 CST == 00:00 UTC next day. - // We already have midnight CST in UTC form; add 18 hours. - const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000 - return admin.firestore.Timestamp.fromMillis(at6pmCstMs) +/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */ +export function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp { + const [y, m, day] = dateStr.split('-').map(Number) + const guessMs = Date.UTC(y, m - 1, day, 18 + 6) + const offset = chicagoOffsetHours(guessMs) + return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset)) }