feat: Display most recent recording time
Add `recorded_at_iso` and `time_hms_utc` to `CasePageTemplate` to display the timestamp of the most recent recording. Introduce a new partial `partials/time_format.js` for consistent date and time formatting in the browser. Update templates to use the new partial for rendering timestamps and group headings.
This commit is contained in:
@@ -134,6 +134,12 @@ struct CasePageTemplate {
|
||||
case_id: String,
|
||||
case_id_short: String,
|
||||
oneliner: Option<String>,
|
||||
/// RFC3339 UTC timestamp of the most recent recording. `None` iff the
|
||||
/// case has no recordings at all. Drives the datetime header under the
|
||||
/// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34".
|
||||
recorded_at_iso: Option<String>,
|
||||
/// UTC `HH:MM` as a no-JS fallback inside the `<time>` element.
|
||||
time_hms_utc: String,
|
||||
/// Rendered document HTML (already passed through `md_to_html`). `Some`
|
||||
/// iff `document.md` exists at render time — template prioritises this
|
||||
/// over status/analyzing placeholders.
|
||||
@@ -315,10 +321,16 @@ pub async fn handle_case_page(
|
||||
let case_id_short = case_id_str.chars().take(8).collect();
|
||||
let is_admin = user.is_admin();
|
||||
|
||||
let most_recent = recordings.last().map(|r| r.filename.as_str());
|
||||
let recorded_at_iso = most_recent.map(recorded_at_iso_of).filter(|s| !s.is_empty());
|
||||
let time_hms_utc = most_recent.map(extract_hhmm_utc).unwrap_or_default();
|
||||
|
||||
CasePageTemplate {
|
||||
case_id: case_id_str,
|
||||
case_id_short,
|
||||
oneliner,
|
||||
recorded_at_iso,
|
||||
time_hms_utc,
|
||||
document_html,
|
||||
recordings_count,
|
||||
transcribed_count,
|
||||
|
||||
@@ -8,9 +8,10 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
.back { color: #4a90e2; text-decoration: none; }
|
||||
h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
||||
h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; margin-bottom: 0.2em; }
|
||||
h1 .title { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; flex: 1; min-width: 0; }
|
||||
h1 .delete-form { margin: 0 0 0 auto; }
|
||||
.case-time { color: #666; font-size: 1.05em; margin: 0 0 1em; }
|
||||
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
|
||||
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; }
|
||||
.status-badge.open { background: #4a90e2; }
|
||||
@@ -71,12 +72,14 @@ try {
|
||||
</span>
|
||||
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button></form>
|
||||
</h1>
|
||||
{% match recorded_at_iso %}
|
||||
{% when Some with (iso) %}<p class="case-time"><time datetime="{{ iso }}">{{ time_hms_utc }}</time></p>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
{% if analyzing %}
|
||||
<span class="analyzing">wird analysiert …</span>
|
||||
{% else if can_analyze && !has_document %}
|
||||
{% if can_analyze && !has_document %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">Analysieren</button></form>
|
||||
{% else if llm_missing && !has_document %}
|
||||
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
||||
@@ -146,6 +149,20 @@ try {
|
||||
<a class="recordings-link" href="/web/cases/{{ case_id }}/recordings">→ Aufnahmen anzeigen ({{ recordings_count }})</a>
|
||||
</div>
|
||||
<script>
|
||||
{% include "partials/time_format.js" %}
|
||||
</script>
|
||||
<script>
|
||||
// Format the case timestamp as "Heute HH:MM" / "Gestern HH:MM" / "DD.MM.YYYY HH:MM"
|
||||
// in the browser's local timezone and locale. Server-rendered text is only a
|
||||
// no-JS fallback (UTC HH:MM).
|
||||
(() => {
|
||||
const el = document.querySelector('.case-time time[datetime]');
|
||||
if (!el) return;
|
||||
const d = new Date(el.dateTime);
|
||||
if (isNaN(d.getTime())) return;
|
||||
el.textContent = TimeFmt.dateLabel(d) + ' - ' + TimeFmt.timeLabel(d);
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const cb = document.getElementById('admin-view-toggle');
|
||||
if (!cb) return;
|
||||
|
||||
@@ -8,7 +8,8 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
section { margin: 1.5em 0; }
|
||||
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
||||
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; display: flex; justify-content: space-between; align-items: baseline; gap: 1em; }
|
||||
section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
|
||||
.empty { color: #888; font-style: italic; }
|
||||
|
||||
.case-list { list-style: none; padding: 0; margin: 0; }
|
||||
@@ -77,7 +78,7 @@ try {
|
||||
<form method="post" action="/web/cases/bulk">
|
||||
{% for g in groups %}
|
||||
<section>
|
||||
<h2 class="date-group" data-iso="{{ g.label }}">{{ g.label }} ({{ g.cases.len() }})</h2>
|
||||
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{% if g.cases.len() == 1 %}1 Fall{% else %}{{ g.cases.len() }} Fälle{% endif %}</span></h2>
|
||||
<ul class="case-list">
|
||||
{% for case in g.cases %}
|
||||
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
|
||||
@@ -109,35 +110,21 @@ try {
|
||||
</form>
|
||||
{% endif %}
|
||||
<script>
|
||||
{% include "partials/time_format.js" %}
|
||||
</script>
|
||||
<script>
|
||||
// Render all UTC timestamps in the browser's local timezone, and relabel
|
||||
// date-group headings to Heute/Gestern based on the browser's current
|
||||
// UTC date. Server-side renders only UTC so timezone logic belongs here.
|
||||
(function () {
|
||||
document.querySelectorAll('time[datetime]').forEach(function (el) {
|
||||
var d = new Date(el.dateTime);
|
||||
if (!isNaN(d.getTime())) {
|
||||
el.textContent = d.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
// date-group headings to Heute/Gestern/DD.MM.YYYY.
|
||||
(() => {
|
||||
document.querySelectorAll('time[datetime]').forEach((el) => {
|
||||
const d = new Date(el.dateTime);
|
||||
if (!isNaN(d.getTime())) el.textContent = TimeFmt.timeLabel(d);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var todayISO = new Date().toISOString().slice(0, 10);
|
||||
var yesterday = new Date();
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
var yesterdayISO = yesterday.toISOString().slice(0, 10);
|
||||
|
||||
document.querySelectorAll('h2.date-group').forEach(function (h) {
|
||||
var iso = h.dataset.iso;
|
||||
document.querySelectorAll('h2.date-group').forEach((h) => {
|
||||
const iso = h.dataset.iso;
|
||||
if (!iso) return;
|
||||
var suffix = h.textContent.replace(iso, '').trim();
|
||||
var label;
|
||||
if (iso === todayISO) label = 'Heute';
|
||||
else if (iso === yesterdayISO) label = 'Gestern';
|
||||
else label = iso;
|
||||
h.textContent = label + ' ' + suffix;
|
||||
const labelEl = h.querySelector('.date-label');
|
||||
if (labelEl) labelEl.textContent = TimeFmt.dateLabelFromISO(iso);
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Inline helper module for timezone- and locale-aware date/time labels.
|
||||
// Included verbatim by templates; exposes `window.TimeFmt`.
|
||||
//
|
||||
// Heute/Gestern is decided in UTC (same as server-side grouping) to keep
|
||||
// client and server in lockstep. Locale date format follows the browser.
|
||||
(() => {
|
||||
const utcDayISO = (d) => d.toISOString().slice(0, 10);
|
||||
const todayISO = () => utcDayISO(new Date());
|
||||
const yesterdayISO = () => {
|
||||
const y = new Date();
|
||||
y.setUTCDate(y.getUTCDate() - 1);
|
||||
return utcDayISO(y);
|
||||
};
|
||||
const dateLabelFromISO = (iso) => {
|
||||
if (iso === todayISO()) return 'Heute';
|
||||
if (iso === yesterdayISO()) return 'Gestern';
|
||||
const parts = iso.split('-');
|
||||
if (parts.length !== 3) return iso;
|
||||
return new Date(Date.UTC(+parts[0], +parts[1] - 1, +parts[2]))
|
||||
.toLocaleDateString();
|
||||
};
|
||||
const dateLabel = (d) => dateLabelFromISO(utcDayISO(d));
|
||||
const timeLabel = (d) => d.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
window.TimeFmt = { dateLabelFromISO, dateLabel, timeLabel };
|
||||
})();
|
||||
Reference in New Issue
Block a user