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:
null 2026-07-07 19:41:41 -05:00
parent 5013c2cd09
commit e5868bd6b1
11 changed files with 182 additions and 39 deletions

View File

@ -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)))
} }

View File

@ -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)))
} }

View File

@ -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)

View File

@ -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()
} }

View File

@ -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." }
} }

View File

@ -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,

View File

@ -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."
} }

View File

@ -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,

View File

@ -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,

View File

@ -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)
}
})
})

View File

@ -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)
} }