feat: daily question date key uses local timezone instead of UTC (batch v0.2.15)

- Replace SimpleDateFormat/Calendar UTC date key with java.time LocalDate in device timezone
- FirestoreAnswerDataSource: todayLocalDateString() with injectable Clock, localDateString() helper
- DailyQuestionViewModel: pass date through submit flow so sync uses same date key
- AnswerRevealViewModel: use todayLocalDateString() for partner answer lookup
- Add FirestoreAnswerDataSourceTest: verifies timezone-aware date boundaries (Chicago vs Tokyo, LA vs London)
This commit is contained in:
null 2026-06-19 21:24:53 -05:00
parent 8b3281423b
commit b23196420f
4 changed files with 76 additions and 24 deletions

View File

@ -6,10 +6,11 @@ import app.closer.domain.model.LocalAnswer
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray import org.json.JSONArray
import java.text.SimpleDateFormat import java.time.Clock
import java.util.Calendar import java.time.Instant
import java.util.Locale import java.time.LocalDate
import java.util.TimeZone import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -47,9 +48,9 @@ class FirestoreAnswerDataSource @Inject constructor(
coupleId: String, coupleId: String,
questionId: String, questionId: String,
userId: String, userId: String,
answer: LocalAnswer answer: LocalAnswer,
date: String = todayLocalDateString()
): Unit = suspendCancellableCoroutine { cont -> ): Unit = suspendCancellableCoroutine { cont ->
val date = todayUtcString()
val aead = encryptionManager.requireAead(coupleId) val aead = encryptionManager.requireAead(coupleId)
val data = mapOf( val data = mapOf(
"userId" to userId, "userId" to userId,
@ -74,12 +75,12 @@ class FirestoreAnswerDataSource @Inject constructor(
} }
/** /**
* Fetches a partner's answer for the current UTC date. * Fetches a partner's answer for the current local date.
*/ */
suspend fun getAnswerForUser( suspend fun getAnswerForUser(
coupleId: String, coupleId: String,
userId: String, userId: String,
date: String = todayUtcString() date: String = todayLocalDateString()
): LocalAnswer? = suspendCancellableCoroutine { cont -> ): LocalAnswer? = suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId) answerRef(coupleId, date, userId)
.get() .get()
@ -97,7 +98,7 @@ class FirestoreAnswerDataSource @Inject constructor(
/** /**
* Reads the couple-scoped daily question assignment for today. * Reads the couple-scoped daily question assignment for today.
*/ */
suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayUtcString()): DailyQuestionAssignment? = suspend fun getDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): DailyQuestionAssignment? =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
dailyQuestionRef(coupleId, date) dailyQuestionRef(coupleId, date)
.get() .get()
@ -122,7 +123,7 @@ class FirestoreAnswerDataSource @Inject constructor(
* Calls the cloud function to assign a daily question for the couple immediately. * Calls the cloud function to assign a daily question for the couple immediately.
* Used when a couple is newly created and has no assignment yet. * Used when a couple is newly created and has no assignment yet.
*/ */
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayUtcString()): Unit = suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val functions = com.google.firebase.functions.FirebaseFunctions.getInstance() val functions = com.google.firebase.functions.FirebaseFunctions.getInstance()
functions functions
@ -180,12 +181,20 @@ class FirestoreAnswerDataSource @Inject constructor(
) )
companion object { companion object {
// UTC keeps both partners on the same date key regardless of where they are. private val dateKeyFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
// The Cloud Functions assignDailyQuestion must also use UTC when creating date docs.
fun todayUtcString(): String { /**
val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) * Daily-question document IDs follow the user's local calendar day.
fmt.timeZone = TimeZone.getTimeZone("UTC") *
return fmt.format(Calendar.getInstance(TimeZone.getTimeZone("UTC")).time) * Firestore timestamps remain absolute instants, but the `daily_question/{date}`
* key should match what the user experiences as "today" on their device.
*/
fun todayLocalDateString(clock: Clock = Clock.systemDefaultZone()): String {
return LocalDate.now(clock).format(dateKeyFormatter)
}
fun localDateString(instant: Instant, zoneId: ZoneId = ZoneId.systemDefault()): String {
return instant.atZone(zoneId).toLocalDate().format(dateKeyFormatter)
} }
} }
} }

View File

@ -62,7 +62,7 @@ class AnswerRevealViewModel @Inject constructor(
firestoreAnswerDataSource.getAnswerForUser( firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId, coupleId = coupleId,
userId = partnerId, userId = partnerId,
date = FirestoreAnswerDataSource.todayUtcString() date = FirestoreAnswerDataSource.todayLocalDateString()
) )
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null } else null
@ -115,7 +115,7 @@ class AnswerRevealViewModel @Inject constructor(
firestoreAnswerDataSource.getAnswerForUser( firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId, coupleId = coupleId,
userId = partnerId, userId = partnerId,
date = FirestoreAnswerDataSource.todayUtcString() date = FirestoreAnswerDataSource.todayLocalDateString()
) )
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
partnerAnswer?.let { _uiState.update { it.copy(partnerAnswer = partnerAnswer) } } partnerAnswer?.let { _uiState.update { it.copy(partnerAnswer = partnerAnswer) } }

View File

@ -24,6 +24,7 @@ data class LocalQuestionUiState(
val error: String? = null, val error: String? = null,
val question: Question? = null, val question: Question? = null,
val coupleId: String? = null, val coupleId: String? = null,
val dailyQuestionDate: String? = null,
val submitted: Boolean = false, val submitted: Boolean = false,
val pendingWrittenText: String = "", val pendingWrittenText: String = "",
val pendingSelectedOptionIds: List<String> = emptyList(), val pendingSelectedOptionIds: List<String> = emptyList(),
@ -51,12 +52,14 @@ class DailyQuestionViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true) _uiState.value = LocalQuestionUiState(isLoading = true)
try { try {
val (coupleId, question) = loadCoupleAndQuestion() val today = FirestoreAnswerDataSource.todayLocalDateString()
val (coupleId, question) = loadCoupleAndQuestion(today)
val answer = question?.let { localAnswerRepository.getAnswer(it.id) } val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
_uiState.value = LocalQuestionUiState( _uiState.value = LocalQuestionUiState(
isLoading = false, isLoading = false,
question = question, question = question,
coupleId = coupleId, coupleId = coupleId,
dailyQuestionDate = today,
pendingScaleValue = defaultScaleValue(question) pendingScaleValue = defaultScaleValue(question)
).withLocalAnswer(answer) ).withLocalAnswer(answer)
} catch (e: Exception) { } catch (e: Exception) {
@ -79,7 +82,7 @@ class DailyQuestionViewModel @Inject constructor(
* *
* For unpaired users, fall back to the local random question pool. * For unpaired users, fall back to the local random question pool.
*/ */
private suspend fun loadCoupleAndQuestion(): Pair<String?, Question?> { private suspend fun loadCoupleAndQuestion(today: String): Pair<String?, Question?> {
val couple = authRepository.currentUserId?.let { uid -> val couple = authRepository.currentUserId?.let { uid ->
runCatching { coupleRepository.getCoupleForUser(uid) } runCatching { coupleRepository.getCoupleForUser(uid) }
.onFailure { crashReporter.recordException(it) } .onFailure { crashReporter.recordException(it) }
@ -91,7 +94,6 @@ class DailyQuestionViewModel @Inject constructor(
} }
val coupleId = couple.id val coupleId = couple.id
val today = FirestoreAnswerDataSource.todayUtcString()
val assignment = runCatching { val assignment = runCatching {
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today) firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
@ -146,7 +148,7 @@ class DailyQuestionViewModel @Inject constructor(
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis()) val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
localAnswerRepository.saveAnswer(localAnswer) localAnswerRepository.saveAnswer(localAnswer)
_uiState.update { it.copy(submitted = true) } _uiState.update { it.copy(submitted = true) }
syncAnswerToFirestore(state.coupleId, question.id, localAnswer) syncAnswerToFirestore(state.coupleId, state.dailyQuestionDate, question.id, localAnswer)
} }
} }
@ -170,11 +172,17 @@ class DailyQuestionViewModel @Inject constructor(
* Syncs the local answer to Firestore so the partner can see it. Failures are * Syncs the local answer to Firestore so the partner can see it. Failures are
* non-blocking: the user's answer is already persisted locally. * non-blocking: the user's answer is already persisted locally.
*/ */
private suspend fun syncAnswerToFirestore(coupleId: String?, questionId: String, answer: LocalAnswer) { private suspend fun syncAnswerToFirestore(
coupleId: String?,
dailyQuestionDate: String?,
questionId: String,
answer: LocalAnswer
) {
val userId = authRepository.currentUserId ?: return val userId = authRepository.currentUserId ?: return
if (coupleId.isNullOrBlank()) return if (coupleId.isNullOrBlank()) return
if (dailyQuestionDate.isNullOrBlank()) return
runCatching { runCatching {
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer) firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
}.onFailure { }.onFailure {
crashReporter.recordException(it) crashReporter.recordException(it)
} }

View File

@ -0,0 +1,35 @@
package app.closer.data.remote
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import org.junit.Assert.assertEquals
import org.junit.Test
class FirestoreAnswerDataSourceTest {
@Test
fun `today local date string uses the clock timezone`() {
val instant = Instant.parse("2026-06-20T02:30:00Z")
val chicagoClock = Clock.fixed(instant, ZoneId.of("America/Chicago"))
val tokyoClock = Clock.fixed(instant, ZoneId.of("Asia/Tokyo"))
assertEquals("2026-06-19", FirestoreAnswerDataSource.todayLocalDateString(chicagoClock))
assertEquals("2026-06-20", FirestoreAnswerDataSource.todayLocalDateString(tokyoClock))
}
@Test
fun `local date string converts a UTC instant into the requested local zone`() {
val instant = Instant.parse("2026-01-01T00:30:00Z")
assertEquals(
"2025-12-31",
FirestoreAnswerDataSource.localDateString(instant, ZoneId.of("America/Los_Angeles"))
)
assertEquals(
"2026-01-01",
FirestoreAnswerDataSource.localDateString(instant, ZoneId.of("Europe/London"))
)
}
}