Refactor timestamp generation to use helper function

Introduces `PendingStore.nowRfc3339()` to consistently generate RFC3339
timestamps with second granularity. This avoids potential issues with
sub-second precision in filenames and aligns with common timestamp
formatting practices. The helper function is used in `CaseDetailScreen`
and `RecordingViewModel` to replace direct calls to
`Instant.now().toString()`. A new unit test verifies the expected
behavior of the helper function.
This commit is contained in:
2026-04-27 14:53:35 +02:00
parent c15590f3e0
commit 9cc0946f90
5 changed files with 33 additions and 4 deletions
+4
View File
@@ -19,6 +19,10 @@
* CRITICAL: "cargo clippy" hat immer Recht!
## Java
* Java Home ist in /opt/android-studio/jbr
## HTML
* Innerhalb von HTML-Seiten sind JS-Einbettungen ausdrücklich erlaubt und gewünscht, wenn sie für den modernen Look-And-Feel der Webseite erforderlich sind.
@@ -4,6 +4,8 @@ import android.util.Log
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.time.temporal.ChronoUnit
import org.json.JSONObject
/**
@@ -99,5 +101,15 @@ class PendingStore(private val dir: File) {
* `doctate-common::timestamp::recorded_at_to_filename_stem`. */
fun recordedAtToFilenameStem(recordedAt: String): String =
recordedAt.replace(':', '-')
/** Current UTC time as RFC3339 with second granularity, e.g.
* `2026-04-15T10:32:00Z`. 1:1 to
* `doctate-common::timestamp::now_rfc3339`. The truncation is
* load-bearing: the server's filename parser expects exactly
* `YYYY-MM-DDTHH-MM-SSZ` after the `:` → `-` substitution, and
* `Instant.now().toString()` would otherwise leak microseconds
* from the platform clock. */
fun nowRfc3339(): String =
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()
}
}
@@ -34,6 +34,7 @@ import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.input.RemoteInputIntentHelper
import com.doctate.watch.DoctateApp
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
@@ -79,7 +80,7 @@ fun CaseDetailScreen(
// Optimistic local update — UI flips to Manual immediately.
app.caseStore.setOnelinerLocal(
caseId,
OnelinerState.Manual(text, setAt = Instant.now().toString()),
OnelinerState.Manual(text, setAt = PendingStore.nowRfc3339()),
)
// Push the edit to the server. Fire-and-forget: the local
// state stays even if the network is down. Edits before
@@ -14,7 +14,6 @@ import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.sync.SyncKick
import com.doctate.watch.sync.SyncTrigger
import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -98,7 +97,7 @@ class RecordingViewModel(
private fun startRecordingFlow() {
finalizationStarted.set(false)
recordingJob = viewModelScope.launch {
val recordedAt = Instant.now().toString()
val recordedAt = PendingStore.nowRfc3339()
val target = pendingStore.audioPathFor(caseId, recordedAt)
try {
withContext(Dispatchers.IO) { recorder.start(target) }
@@ -140,7 +139,7 @@ class RecordingViewModel(
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
return
}
val recordedAt = pendingRecordedAt ?: Instant.now().toString()
val recordedAt = pendingRecordedAt ?: PendingStore.nowRfc3339()
pendingRecordedAt = null
try {
withContext(Dispatchers.IO) {
@@ -100,4 +100,17 @@ class PendingStoreTest {
assertThat(found.recordedAt).isEqualTo("2026-04-26T10:00:00Z")
assertThat(found.file.absolutePath).isEqualTo(audio.absolutePath)
}
/** Regression guard: subsecond precision must be stripped. A single
* microsecond leak would feed the server filename parser
* `YYYY-MM-DDTHH-MM-SS.NNNNNNZ.m4a`, which it treats as malformed —
* the resulting `<time datetime>` is empty and the day-grouping
* falls back to `today`, producing duplicate "Heute" headers.
* Mirrors `now_rfc3339_has_no_subsecond_component` in the Rust
* `doctate-common::timestamp` crate. */
@Test fun nowRfc3339_has_no_subsecond_component() {
val now = PendingStore.nowRfc3339()
assertThat(now).doesNotContain(".")
assertThat(now).endsWith("Z")
}
}