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 70bb0a346c
commit c1f7e6f7f9
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 kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@ -47,9 +48,9 @@ class FirestoreAnswerDataSource @Inject constructor(
coupleId: String,
questionId: String,
userId: String,
answer: LocalAnswer
answer: LocalAnswer,
date: String = todayLocalDateString()
): Unit = suspendCancellableCoroutine { cont ->
val date = todayUtcString()
val aead = encryptionManager.requireAead(coupleId)
val data = mapOf(
"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(
coupleId: String,
userId: String,
date: String = todayUtcString()
date: String = todayLocalDateString()
): LocalAnswer? = suspendCancellableCoroutine { cont ->
answerRef(coupleId, date, userId)
.get()
@ -97,7 +98,7 @@ class FirestoreAnswerDataSource @Inject constructor(
/**
* 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 ->
dailyQuestionRef(coupleId, date)
.get()
@ -122,7 +123,7 @@ class FirestoreAnswerDataSource @Inject constructor(
* 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.
*/
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayUtcString()): Unit =
suspend fun requestDailyQuestionAssignment(coupleId: String, date: String = todayLocalDateString()): Unit =
suspendCancellableCoroutine { cont ->
val functions = com.google.firebase.functions.FirebaseFunctions.getInstance()
functions
@ -180,12 +181,20 @@ class FirestoreAnswerDataSource @Inject constructor(
)
companion object {
// UTC keeps both partners on the same date key regardless of where they are.
// The Cloud Functions assignDailyQuestion must also use UTC when creating date docs.
fun todayUtcString(): String {
val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
fmt.timeZone = TimeZone.getTimeZone("UTC")
return fmt.format(Calendar.getInstance(TimeZone.getTimeZone("UTC")).time)
private val dateKeyFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
/**
* Daily-question document IDs follow the user's local calendar day.
*
* 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(
coupleId = coupleId,
userId = partnerId,
date = FirestoreAnswerDataSource.todayUtcString()
date = FirestoreAnswerDataSource.todayLocalDateString()
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null
@ -115,7 +115,7 @@ class AnswerRevealViewModel @Inject constructor(
firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId,
userId = partnerId,
date = FirestoreAnswerDataSource.todayUtcString()
date = FirestoreAnswerDataSource.todayLocalDateString()
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
partnerAnswer?.let { _uiState.update { it.copy(partnerAnswer = partnerAnswer) } }

View File

@ -24,6 +24,7 @@ data class LocalQuestionUiState(
val error: String? = null,
val question: Question? = null,
val coupleId: String? = null,
val dailyQuestionDate: String? = null,
val submitted: Boolean = false,
val pendingWrittenText: String = "",
val pendingSelectedOptionIds: List<String> = emptyList(),
@ -51,12 +52,14 @@ class DailyQuestionViewModel @Inject constructor(
viewModelScope.launch {
_uiState.value = LocalQuestionUiState(isLoading = true)
try {
val (coupleId, question) = loadCoupleAndQuestion()
val today = FirestoreAnswerDataSource.todayLocalDateString()
val (coupleId, question) = loadCoupleAndQuestion(today)
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
_uiState.value = LocalQuestionUiState(
isLoading = false,
question = question,
coupleId = coupleId,
dailyQuestionDate = today,
pendingScaleValue = defaultScaleValue(question)
).withLocalAnswer(answer)
} catch (e: Exception) {
@ -79,7 +82,7 @@ class DailyQuestionViewModel @Inject constructor(
*
* 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 ->
runCatching { coupleRepository.getCoupleForUser(uid) }
.onFailure { crashReporter.recordException(it) }
@ -91,7 +94,6 @@ class DailyQuestionViewModel @Inject constructor(
}
val coupleId = couple.id
val today = FirestoreAnswerDataSource.todayUtcString()
val assignment = runCatching {
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
@ -146,7 +148,7 @@ class DailyQuestionViewModel @Inject constructor(
val localAnswer = state.toLocalAnswer(question).copy(updatedAt = System.currentTimeMillis())
localAnswerRepository.saveAnswer(localAnswer)
_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
* 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
if (coupleId.isNullOrBlank()) return
if (dailyQuestionDate.isNullOrBlank()) return
runCatching {
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer)
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
}.onFailure {
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"))
)
}
}