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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user