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.
This commit is contained in:
2026-04-18 11:33:35 +02:00
parent 7637596598
commit cdfa5ae90e
11 changed files with 286 additions and 56 deletions
+19 -2
View File
@@ -183,6 +183,23 @@ async fn main() {
.layer(TraceLayer::new_for_http())
.layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)); // 50 MB
let listener = TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
eprintln!(
"Port {} is already in use. Is another doctate-server instance running?",
config.server_port
);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to bind to {addr}: {e}");
std::process::exit(1);
}
};
if let Err(e) = axum::serve(listener, app).await {
eprintln!("Server runtime error: {e}");
std::process::exit(1);
}
}
+36 -11
View File
@@ -91,9 +91,10 @@ fn not_modified(etag: &str) -> Response {
response
}
/// Scan `<user_data_dir>/<case_id>/` for cases whose earliest recording
/// Scan `<user_data_dir>/<case_id>/` for cases whose latest recording
/// sits inside the `[since, now]` window. Returns entries sorted by
/// `created_at` descending (newest first). Soft-deleted cases and
/// `last_recording_at` descending (most recently active first); cases
/// without recordings fall back to `created_at`. Soft-deleted cases and
/// non-UUID directories are filtered out.
async fn enumerate_recent_oneliners(
user_data_dir: &Path,
@@ -118,11 +119,15 @@ async fn enumerate_recent_oneliners(
continue;
}
let Some(created_mtime) = earliest_m4a_mtime(&case_dir).await else {
let Some((earliest, latest)) = m4a_mtime_range(&case_dir).await else {
continue;
};
let created_at = OffsetDateTime::from(created_mtime);
if created_at < since {
let created_at = OffsetDateTime::from(earliest);
let last_recording_at = OffsetDateTime::from(latest);
// Window check on the *latest* recording: a case that got a
// follow-up dictation today must show up even if it was first
// created years ago.
if last_recording_at < since {
continue;
}
@@ -131,25 +136,37 @@ async fn enumerate_recent_oneliners(
warn!(case = %case_dir.display(), "format created_at failed");
continue;
};
let last_recording_at_str = last_recording_at.format(&Rfc3339).ok();
let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok());
entries.push(OnelinerEntry {
case_id: case_id.to_owned(),
oneliner: oneliner_text,
created_at: created_at_str,
last_recording_at: last_recording_at_str,
updated_at: updated_at_str,
});
}
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
// Primary sort: last_recording_at desc (most recent dictation on top).
// Fallback for entries without recordings (shouldn't happen today):
// use created_at. Strings sort in the same order as RFC3339 UTC
// timestamps because they are lexicographically monotonic.
entries.sort_by(|a, b| {
let a_key = a.last_recording_at.as_deref().unwrap_or(&a.created_at);
let b_key = b.last_recording_at.as_deref().unwrap_or(&b.created_at);
b_key.cmp(a_key)
});
entries
}
/// Return the earliest `.m4a` mtime in `case_dir`. `.m4a.failed` counts —
/// a failed recording still marks the birth of the case. Returns None on
/// read errors or empty directories.
async fn earliest_m4a_mtime(case_dir: &Path) -> Option<SystemTime> {
/// Return `(earliest, latest)` `.m4a` mtime pair in `case_dir`.
/// `.m4a.failed` entries count — a failed recording still marks activity.
/// `None` on empty directory or read error. A single recording yields
/// `(mtime, mtime)`.
async fn m4a_mtime_range(case_dir: &Path) -> Option<(SystemTime, SystemTime)> {
let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
let mut earliest: Option<SystemTime> = None;
let mut latest: Option<SystemTime> = None;
while let Ok(Some(entry)) = files.next_entry().await {
let path: PathBuf = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
@@ -169,8 +186,16 @@ async fn earliest_m4a_mtime(case_dir: &Path) -> Option<SystemTime> {
Some(e) if mtime < e => Some(mtime),
other => other,
};
latest = match latest {
None => Some(mtime),
Some(l) if mtime > l => Some(mtime),
other => other,
};
}
match (earliest, latest) {
(Some(e), Some(l)) => Some((e, l)),
_ => None,
}
earliest
}
/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file
+42 -34
View File
@@ -7,7 +7,7 @@ use axum::extract::{Path as AxumPath, State};
use axum::response::Html;
use tracing::{info, warn};
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime, UtcOffset};
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime};
use crate::analyze::{recovery as analyze_recovery, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser;
@@ -20,9 +20,15 @@ use crate::{AnalyzeBusy, TranscribeBusy};
struct UserCaseView {
case_id: String,
most_recent: String,
/// HH:MM extracted from `most_recent` filename (e.g. "10:32"),
/// empty string if filename does not match the expected timestamp form.
time_hms: String,
/// HH:MM of the most recent recording in UTC — no-JS fallback.
/// Browser script replaces the displayed text with the browser-local
/// equivalent via the parallel `recorded_at_iso` field.
time_hms_utc: String,
/// Full RFC3339 UTC timestamp of the most recent recording. Fed into
/// the HTML `<time datetime="...">` attribute so browser JS can
/// render it in the doctor's actual timezone (which may differ from
/// the server's, e.g. server on Bahamas, doctor in Germany).
recorded_at_iso: String,
oneliner: Option<String>,
recordings_count: usize,
analyzing: bool,
@@ -33,9 +39,9 @@ struct UserCaseView {
can_analyze: bool,
}
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` and return
/// the date in the given offset. Returns `None` on any mismatch.
fn local_date_of(filename: &str, offset: UtcOffset) -> Option<Date> {
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
/// and return `(Date, Rfc3339String)`. None on malformed input.
fn utc_date_and_iso_of(filename: &str) -> Option<(Date, String)> {
let s = filename.get(..20)?;
if s.as_bytes().get(19) != Some(&b'Z') {
return None;
@@ -47,33 +53,23 @@ fn local_date_of(filename: &str, offset: UtcOffset) -> Option<Date> {
buf[16] = b':';
let fixed = std::str::from_utf8(&buf).ok()?;
let dt = OffsetDateTime::parse(fixed, &Rfc3339).ok()?;
Some(dt.to_offset(offset).date())
Some((dt.date(), fixed.to_owned()))
}
/// Best-effort local offset. Falls back to UTC if the runtime can't resolve
/// it (e.g. multi-threaded tokio on Unix without `TZ` set). Near-midnight
/// labels may drift in that case — acceptable tradeoff vs. caching at startup.
fn local_offset_or_utc() -> UtcOffset {
UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)
}
/// Group cases (already sorted newest-first) into date buckets. Label is
/// "Heute" / "Gestern" / ISO date.
fn group_by_local_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
let offset = local_offset_or_utc();
let today = OffsetDateTime::now_utc().to_offset(offset).date();
let yesterday = today.previous_day();
/// Group cases (already sorted newest-first) into UTC-date buckets. The
/// label is the raw ISO date string (e.g. `2026-04-18`); browser JS
/// re-labels the current/previous UTC date to "Heute"/"Gestern". Note:
/// grouping is by UTC day, which may drift from the doctor's local-day
/// perception around midnight — acceptable tradeoff; full Browser-TZ
/// grouping would require a larger client-side restructure.
fn group_by_utc_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
let today = OffsetDateTime::now_utc().date();
let mut groups: Vec<DateGroup> = Vec::new();
for case in cases {
let d = local_date_of(&case.most_recent, offset).unwrap_or(today);
let label = if d == today {
"Heute".to_string()
} else if Some(d) == yesterday {
"Gestern".to_string()
} else {
d.to_string()
};
let d = utc_date_and_iso_of(&case.most_recent)
.map(|(d, _)| d)
.unwrap_or(today);
let label = d.to_string();
match groups.last_mut() {
Some(g) if g.label == label => g.cases.push(case),
_ => groups.push(DateGroup {
@@ -87,7 +83,9 @@ fn group_by_local_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
/// Extract `HH:MM` from a recording filename of the form
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
fn extract_hhmm(filename: &str) -> String {
/// Used only as a no-JS fallback — the canonical value rendered in the
/// browser comes from the `<time datetime>`-based script.
fn extract_hhmm_utc(filename: &str) -> String {
let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else {
return String::new();
};
@@ -98,6 +96,14 @@ fn extract_hhmm(filename: &str) -> String {
}
}
/// Build an RFC3339 UTC timestamp string from a recording filename.
/// Returns empty string on malformed input.
fn recorded_at_iso_of(filename: &str) -> String {
utc_date_and_iso_of(filename)
.map(|(_, iso)| iso)
.unwrap_or_default()
}
struct DateGroup {
/// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
label: String,
@@ -232,7 +238,7 @@ pub async fn handle_my_cases(
let busy = analyze_busy.0.load(Ordering::Acquire);
let cases = scan_user_cases(&config, &user.slug, busy).await;
let total = cases.len();
let groups = group_by_local_date(cases);
let groups = group_by_utc_date(cases);
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
.await
.map(|(_, n)| n)
@@ -387,11 +393,13 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
!has_document && worker_busy && any_analysis_input_exists(&case_path).await;
let can_analyze = !analyzing && all_transcribed && llm_configured;
let time_hms = extract_hhmm(&most_recent);
let time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
cases.push(UserCaseView {
case_id,
most_recent,
time_hms,
time_hms_utc,
recorded_at_iso,
oneliner,
recordings_count,
analyzing,