Files
doctate/doctate-common/src/timestamp.rs
T
Brummel cdfa5ae90e Refactor timestamp handling for activity sorting
Introduce `last_recording_at` to `OnelinerEntry` and use it as the
primary sort key for cases. This ensures that cases with recent
dictation activity are prioritized, even if their initial creation date
is older.

The logic for determining a case's activity has been updated to consider
the most recent `.m4a` file's modification time (`last_recording_at`),
falling back to `updated_at` or `created_at` if necessary.

This change also refactors the timestamp formatting and handling within
the web interface to correctly display and sort cases based on their
actual last activity time, improving user experience and data relevance.
The `now_rfc3339` function is updated to strip sub-second precision for
consistent filename generation.
2026-04-18 11:33:35 +02:00

92 lines
3.2 KiB
Rust

use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Current UTC time as an RFC3339 string with second precision, e.g.
/// `2026-04-15T10:32:00Z`. Subsecond fractions are deliberately stripped
/// so every caller produces the same filename shape — the server's
/// filename parser expects exactly `YYYY-MM-DDTHH-MM-SSZ` after the
/// `:` → `-` substitution, and dictation events don't need finer
/// resolution than one second.
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.replace_nanosecond(0)
.expect("0 is always a valid nanosecond value")
.format(&Rfc3339)
.expect("RFC3339 formatting of OffsetDateTime is infallible")
}
/// Map an RFC3339 timestamp to a filesystem-safe stem by replacing every
/// `:` with `-`. Intentionally naive — RFC3339 has no colons outside the
/// time/offset portion, so the result is bijective against
/// `filename_stem_to_recorded_at`.
///
/// Example: `"2026-04-15T10:32:00Z"` → `"2026-04-15T10-32-00Z"`.
pub fn recorded_at_to_filename_stem(recorded_at: &str) -> String {
recorded_at.replace(':', "-")
}
/// Reverse of `recorded_at_to_filename_stem`. Only undoes the mapping in
/// the time portion (after `T`) so date-part hyphens stay intact.
///
/// Example: `"2026-04-15T10-32-00Z"` → `"2026-04-15T10:32:00Z"`.
pub fn filename_stem_to_recorded_at(stem: &str) -> String {
match stem.split_once('T') {
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
None => stem.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_stem_roundtrip() {
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
"2026-04-15T10:32:00Z"
);
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
"2026-04-15T10:32:00.123Z"
);
}
#[test]
fn recorded_at_stem_roundtrip_utc() {
let original = "2026-04-15T10:32:00Z";
let stem = recorded_at_to_filename_stem(original);
assert_eq!(stem, "2026-04-15T10-32-00Z");
assert_eq!(filename_stem_to_recorded_at(&stem), original);
}
#[test]
fn recorded_at_stem_roundtrip_with_milliseconds() {
let original = "2026-04-15T10:32:00.123Z";
let stem = recorded_at_to_filename_stem(original);
assert_eq!(filename_stem_to_recorded_at(&stem), original);
}
#[test]
fn now_rfc3339_has_expected_shape() {
let now = now_rfc3339();
assert!(now.starts_with("20"), "unexpected year prefix: {now}");
assert!(now.contains('T'), "missing T separator: {now}");
}
/// Regression guard: subsecond precision must be stripped. A single
/// nanosecond leak would feed the server filename parser
/// `YYYY-MM-DDTHH-MM-SS.nnnnnnnnnZ.m4a`, which it treats as
/// malformed — the resulting `<time datetime>` is empty, breaking
/// browser-side TZ rendering.
#[test]
fn now_rfc3339_has_no_subsecond_component() {
let now = now_rfc3339();
assert!(
!now.contains('.'),
"now_rfc3339 must be second-granular, got {now}"
);
assert!(now.ends_with('Z'), "expected UTC 'Z' suffix, got {now}");
}
}