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:
parent
70bb0a346c
commit
c1f7e6f7f9
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue