diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt index 95a05b60..24614a91 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -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) } } } diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index 58583a32..a6c692a0 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -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) } } diff --git a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt index 50f182c2..1c71276e 100644 --- a/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/DailyQuestionViewModel.kt @@ -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 = 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 { + private suspend fun loadCoupleAndQuestion(today: String): Pair { 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) } diff --git a/app/src/test/java/app/closer/data/remote/FirestoreAnswerDataSourceTest.kt b/app/src/test/java/app/closer/data/remote/FirestoreAnswerDataSourceTest.kt new file mode 100644 index 00000000..2ef054aa --- /dev/null +++ b/app/src/test/java/app/closer/data/remote/FirestoreAnswerDataSourceTest.kt @@ -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")) + ) + } +}