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