fix(games,daily): correctness batch from games review
- Game answer listeners (ToT/HowWell/DesireSync): close(err) instead of swallowing snapshot errors, + .catch in each VM's observeReveal surfacing a retryable ERROR (GameCopy.SYNC_ERROR + retrySync re-attach) — games no longer hang on WAITING forever on listener failure (GAME-HANG-001; matches the Wheel/Capsule sources' established pattern). - Daily question: paired fallback pool is now premium-INDEPENDENT so both partners always resolve the same deterministic question; viewer-premium pools broke the couple contract when entitlement state differed or flipped mid-day (DQ-MISMATCH-001, reproduced live: partners answered different questions). - Daily reveal: humanize raw option-id fallbacks so slugs never render (DQ-SLUG-001, e.g. 'fake_awards_should_be_mandatory'). - assignDailyQuestion.ts: replace hardcoded CST_OFFSET_HOURS=-6 with DST-safe Intl America/Chicago helpers (DST-001) + 5 regression tests (CDT/CST labeling, 6PM reveal instant, spring-forward round-trip). Verified live 2-device: identical daily question on both partners post-fix; ToT full loop 5/5 reveal regression clean. Unit 244 + functions 58 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5013c2cd09
commit
e5868bd6b1
|
|
@ -62,7 +62,11 @@ class FirestoreDesireSyncDataSource @Inject constructor(
|
||||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
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)
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ class FirestoreHowWellDataSource @Inject constructor(
|
||||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
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)
|
val aead = encryptionManager.aeadFor(coupleId)
|
||||||
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,11 @@ class FirestoreThisOrThatDataSource @Inject constructor(
|
||||||
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
|
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val raw = snap.get("answers") as? Map<String, *>
|
val raw = snap.get("answers") as? Map<String, *>
|
||||||
val byUser = parseAnswers(raw, coupleId)
|
val byUser = parseAnswers(raw, coupleId)
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,16 @@ class DailyQuestionResolver @Inject constructor(
|
||||||
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.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) {
|
val question = if (assignment != null) {
|
||||||
questionRepository.getQuestionById(assignment.questionId)
|
questionRepository.getQuestionById(assignment.questionId)
|
||||||
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
|
||||||
?: questionRepository.getDailyQuestion()
|
?: questionRepository.getDailyQuestion()
|
||||||
} else {
|
} else {
|
||||||
// No assignment yet — request one, but stay usable with the deterministic local
|
// 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 ?: ""
|
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
|
||||||
)
|
)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
|
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
|
||||||
?: questionRepository.getDailyQuestion()
|
?: questionRepository.getDailyQuestion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
fun LocalAnswer.revealSummary(): String {
|
||||||
return when (answerType) {
|
return when (answerType) {
|
||||||
"written" -> writtenText.orEmpty()
|
"written" -> writtenText.orEmpty()
|
||||||
"scale" -> "You chose ${scaleValue ?: "-"}."
|
"scale" -> "You chose ${scaleValue ?: "-"}."
|
||||||
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
||||||
.ifEmpty { selectedOptionIds }
|
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
|
||||||
.joinToString()
|
.joinToString()
|
||||||
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
||||||
}
|
}
|
||||||
|
|
@ -978,7 +985,7 @@ private fun LocalAnswer.partnerRevealSummary(): String {
|
||||||
"written" -> writtenText.orEmpty()
|
"written" -> writtenText.orEmpty()
|
||||||
"scale" -> "They chose ${scaleValue ?: "-"}."
|
"scale" -> "They chose ${scaleValue ?: "-"}."
|
||||||
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
||||||
.ifEmpty { selectedOptionIds }
|
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
|
||||||
.joinToString()
|
.joinToString()
|
||||||
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
@ -102,6 +103,7 @@ data class DesireSyncUiState(
|
||||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val submitFailed: Boolean = false,
|
val submitFailed: Boolean = false,
|
||||||
|
val syncFailed: Boolean = false,
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -301,6 +303,18 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
// The observer flips WAITING → REVEAL once the partner's answers land.
|
// 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() {
|
fun retrySubmit() {
|
||||||
val answers = _uiState.value.myAnswers
|
val answers = _uiState.value.myAnswers
|
||||||
if (answers.isEmpty()) return
|
if (answers.isEmpty()) return
|
||||||
|
|
@ -314,7 +328,14 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||||
observeJob?.cancel()
|
observeJob?.cancel()
|
||||||
observeJob = viewModelScope.launch {
|
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 mine = userId?.let { answers.byUser[it] }
|
||||||
val theirs = partnerId?.let { answers.byUser[it] }
|
val theirs = partnerId?.let { answers.byUser[it] }
|
||||||
when {
|
when {
|
||||||
|
|
@ -427,7 +448,7 @@ fun DesireSyncScreen(
|
||||||
DesireSyncPhase.ERROR -> DSErrorScreen(
|
DesireSyncPhase.ERROR -> DSErrorScreen(
|
||||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||||
onBack = viewModel::quit,
|
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(
|
DesireSyncPhase.SETUP -> DSSetupScreen(
|
||||||
selectedLength = state.selectedLength,
|
selectedLength = state.selectedLength,
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,7 @@ package app.closer.ui.games
|
||||||
object GameCopy {
|
object GameCopy {
|
||||||
/** Shown when submitting a player's answers fails (network/transient) — the action is retryable. */
|
/** 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."
|
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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import app.closer.ui.components.CloserGlyphs
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
|
@ -149,6 +150,7 @@ data class HowWellUiState(
|
||||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val submitFailed: Boolean = false,
|
val submitFailed: Boolean = false,
|
||||||
|
val syncFailed: Boolean = false,
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -333,6 +335,18 @@ class HowWellViewModel @Inject constructor(
|
||||||
// The observer flips WAITING → COMPLETE once the partner's answers land.
|
// 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() {
|
fun retrySubmit() {
|
||||||
if (myAnswers.isEmpty()) return
|
if (myAnswers.isEmpty()) return
|
||||||
_uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) }
|
_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
|
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||||
observeJob?.cancel()
|
observeJob?.cancel()
|
||||||
observeJob = viewModelScope.launch {
|
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 mine = userId?.let { answers.byUser[it] }
|
||||||
val theirs = partnerId?.let { answers.byUser[it] }
|
val theirs = partnerId?.let { answers.byUser[it] }
|
||||||
when {
|
when {
|
||||||
|
|
@ -468,7 +489,7 @@ fun HowWellScreen(
|
||||||
HowWellPhase.ERROR -> HowWellErrorScreen(
|
HowWellPhase.ERROR -> HowWellErrorScreen(
|
||||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||||
onBack = viewModel::quit,
|
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(
|
HowWellPhase.SETUP -> HWSetupScreen(
|
||||||
selectedLength = state.selectedLength,
|
selectedLength = state.selectedLength,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -150,6 +151,7 @@ data class ThisOrThatUiState(
|
||||||
val selectedLength: SessionLength = SessionLength.STANDARD,
|
val selectedLength: SessionLength = SessionLength.STANDARD,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val submitFailed: Boolean = false,
|
val submitFailed: Boolean = false,
|
||||||
|
val syncFailed: Boolean = false,
|
||||||
val navigateTo: String? = null
|
val navigateTo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -302,7 +304,14 @@ class ThisOrThatViewModel @Inject constructor(
|
||||||
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||||
observeJob?.cancel()
|
observeJob?.cancel()
|
||||||
observeJob = viewModelScope.launch {
|
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 mine = userId?.let { answers.byUser[it] }
|
||||||
val theirs = partnerId?.let { answers.byUser[it] }
|
val theirs = partnerId?.let { answers.byUser[it] }
|
||||||
when {
|
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() {
|
fun retrySubmit() {
|
||||||
val answers = _uiState.value.myAnswers
|
val answers = _uiState.value.myAnswers
|
||||||
if (answers.isEmpty()) return
|
if (answers.isEmpty()) return
|
||||||
|
|
@ -478,7 +499,7 @@ fun ThisOrThatScreen(
|
||||||
TotPhase.ERROR -> ErrorState(
|
TotPhase.ERROR -> ErrorState(
|
||||||
message = state.error ?: "Something didn't load. Go back and try again.",
|
message = state.error ?: "Something didn't load. Go back and try again.",
|
||||||
onBack = viewModel::quit,
|
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(
|
TotPhase.WAITING -> WaitingForRevealScreen(
|
||||||
partnerName = state.partnerName,
|
partnerName = state.partnerName,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
const CST_OFFSET_HOURS = -6
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled function that assigns one daily question per couple every day.
|
* 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)
|
d.setUTCDate(d.getUTCDate() + 1)
|
||||||
return formatCstDate(d)
|
return formatCstDate(d)
|
||||||
}
|
}
|
||||||
const d = new Date()
|
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
|
||||||
const cst = applyCstOffset(d)
|
const todayChicago = parseCstDate(formatCstDate(new Date()))
|
||||||
cst.setUTCDate(cst.getUTCDate() + 1)
|
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1)
|
||||||
return formatCstDate(cst)
|
return formatCstDate(todayChicago)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCstOffset(d: Date): Date {
|
/**
|
||||||
const utcMs = d.getTime()
|
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
|
||||||
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000
|
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
|
||||||
return new Date(cstMs)
|
* 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 {
|
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
|
||||||
const cst = applyCstOffset(d)
|
export function formatCstDate(d: Date): string {
|
||||||
const y = cst.getUTCFullYear()
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
const m = String(cst.getUTCMonth() + 1).padStart(2, '0')
|
timeZone: 'America/Chicago',
|
||||||
const day = String(cst.getUTCDate()).padStart(2, '0')
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
return `${y}-${m}-${day}`
|
}).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)
|
const [y, m, day] = dateStr.split('-').map(Number)
|
||||||
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
|
// Guess with the standard offset, then correct with the real offset at that instant.
|
||||||
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0)
|
const guessMs = Date.UTC(y, m - 1, day, 6)
|
||||||
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000)
|
const offset = chicagoOffsetHours(guessMs)
|
||||||
|
return new Date(Date.UTC(y, m - 1, day, offset))
|
||||||
}
|
}
|
||||||
|
|
||||||
function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
|
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
|
||||||
const d = parseCstDate(dateStr)
|
export function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
|
||||||
const utcMs = d.getTime()
|
const [y, m, day] = dateStr.split('-').map(Number)
|
||||||
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
|
const guessMs = Date.UTC(y, m - 1, day, 18 + 6)
|
||||||
// We already have midnight CST in UTC form; add 18 hours.
|
const offset = chicagoOffsetHours(guessMs)
|
||||||
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000
|
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset))
|
||||||
return admin.firestore.Timestamp.fromMillis(at6pmCstMs)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue