Move formatting into template struct methods, add timestamp hover
Template structs now carry raw data (state, sha, ms timestamps) instead of pre-formatted strings. Formatting lives in methods on the structs. Timestamps render as <time> elements with ISO values on hover via title attributes.
change psrvluxmruyolvrqklrxnqrksnztzvmz
commit 49fe39d41fea78b569a19f75cbd2b989a0b0d0c9
author Alpha Chen <alpha@kejadlen.dev>
date
parent mruokutn
diff --git a/src/quire/web.rs b/src/quire/web.rs
index dc67dfb..8e096dd 100644
--- a/src/quire/web.rs
+++ b/src/quire/web.rs
@@ -1,8 +1,8 @@
 //! Read-only CI web view.
 //!
 //! Two pages:
-//! - `GET /repo/<name>/ci` — most-recent runs for a repo.
-//! - `GET /repo/<name>/ci/<run-id>` — per-run detail with jobs and logs.
+//! - `GET /<name>/ci` — most-recent runs for a repo.
+//! - `GET /<name>/ci/<run-id>` — per-run detail with jobs and logs.
 //!
 //! Server-rendered HTML via Askama templates. JavaScript-optional.
 
@@ -29,11 +29,42 @@ struct RunListTemplate {
 
 struct RunListRow {
     id: String,
-    state_color: String,
-    sha_short: String,
-    ref_short: String,
-    queued: String,
-    duration: String,
+    state: String,
+    sha: String,
+    ref_name: String,
+    queued_at_ms: i64,
+    started_at_ms: Option<i64>,
+    finished_at_ms: Option<i64>,
+}
+
+impl RunListRow {
+    fn state_class(&self) -> &str {
+        match self.state.as_str() {
+            "complete" => "c-ok",
+            "failed" => "c-bad",
+            _ => "c-muted",
+        }
+    }
+
+    fn sha_short(&self) -> &str {
+        &self.sha[..self.sha.len().min(8)]
+    }
+
+    fn ref_short(&self) -> &str {
+        self.ref_name.trim_start_matches("refs/heads/")
+    }
+
+    fn queued_relative(&self) -> String {
+        format_timestamp_relative(self.queued_at_ms)
+    }
+
+    fn queued_iso(&self) -> String {
+        format_timestamp_iso(self.queued_at_ms)
+    }
+
+    fn duration_display(&self) -> String {
+        format_duration(self.started_at_ms, self.finished_at_ms)
+    }
 }
 
 #[derive(askama::Template)]
@@ -47,32 +78,127 @@ struct RunDetailTemplate {
 
 struct DetailRun {
     state: String,
-    state_color: String,
-    sha_short: String,
-    ref_short: String,
-    queued: String,
-    started: String,
-    finished: String,
-    duration: String,
+    sha: String,
+    ref_name: String,
+    queued_at_ms: i64,
+    started_at_ms: Option<i64>,
+    finished_at_ms: Option<i64>,
+}
+
+impl DetailRun {
+    fn state_class(&self) -> &str {
+        match self.state.as_str() {
+            "complete" => "c-ok",
+            "failed" => "c-bad",
+            _ => "c-muted",
+        }
+    }
+
+    fn sha_short(&self) -> &str {
+        &self.sha[..self.sha.len().min(8)]
+    }
+
+    fn ref_short(&self) -> &str {
+        self.ref_name.trim_start_matches("refs/heads/")
+    }
+
+    fn queued_relative(&self) -> String {
+        format_timestamp_relative(self.queued_at_ms)
+    }
+
+    fn queued_iso(&self) -> String {
+        format_timestamp_iso(self.queued_at_ms)
+    }
+
+    fn started_display(&self) -> String {
+        self.started_at_ms
+            .map(format_timestamp_relative)
+            .unwrap_or_else(|| "—".to_string())
+    }
+
+    fn started_iso(&self) -> String {
+        self.started_at_ms
+            .map(format_timestamp_iso)
+            .unwrap_or_default()
+    }
+
+    fn has_started(&self) -> bool {
+        self.started_at_ms.is_some()
+    }
+
+    fn finished_display(&self) -> String {
+        self.finished_at_ms
+            .map(format_timestamp_relative)
+            .unwrap_or_else(|| "—".to_string())
+    }
+
+    fn finished_iso(&self) -> String {
+        self.finished_at_ms
+            .map(format_timestamp_iso)
+            .unwrap_or_default()
+    }
+
+    fn has_finished(&self) -> bool {
+        self.finished_at_ms.is_some()
+    }
+
+    fn duration_display(&self) -> String {
+        format_duration(self.started_at_ms, self.finished_at_ms)
+    }
 }
 
 struct DetailJob {
     job_id: String,
     state: String,
-    state_color: String,
-    duration: String,
-    exit_str: String,
+    exit_code: Option<i32>,
+    started_at_ms: Option<i64>,
+    finished_at_ms: Option<i64>,
     sh_events: Vec<DetailShEvent>,
 }
 
+impl DetailJob {
+    fn state_class(&self) -> &str {
+        match self.state.as_str() {
+            "complete" => "c-ok",
+            "failed" => "c-bad",
+            _ => "c-muted",
+        }
+    }
+
+    fn duration_display(&self) -> String {
+        format_duration(self.started_at_ms, self.finished_at_ms)
+    }
+
+    fn exit_display(&self) -> String {
+        self.exit_code
+            .map(|c| format!(" · exit {c}"))
+            .unwrap_or_default()
+    }
+}
+
 struct DetailShEvent {
     index: usize,
-    duration: String,
+    started_at_ms: i64,
+    finished_at_ms: i64,
     exit_code: i32,
-    cmd_display: String,
+    cmd: String,
     log_content: String,
 }
 
+impl DetailShEvent {
+    fn duration_display(&self) -> String {
+        format_duration_exact(self.started_at_ms, self.finished_at_ms)
+    }
+
+    fn cmd_display(&self) -> &str {
+        if self.cmd.len() > 120 {
+            &self.cmd[..120]
+        } else {
+            &self.cmd
+        }
+    }
+}
+
 #[derive(askama::Template)]
 #[template(path = "error.html")]
 struct ErrorTemplate {
@@ -144,15 +270,9 @@ impl<S: Send + Sync> FromRequestParts<S> for RemoteUser {
     }
 }
 
-fn state_color(state: &str) -> &'static str {
-    match state {
-        "complete" => "c-ok",
-        "failed" => "c-bad",
-        _ => "c-muted",
-    }
-}
+// ── Formatting helpers ─────────────────────────────────────────────
 
-fn format_timestamp(ms: i64) -> String {
+fn format_timestamp_relative(ms: i64) -> String {
     match Timestamp::from_millisecond(ms) {
         Ok(ts) => {
             let now = Timestamp::now();
@@ -175,6 +295,12 @@ fn format_timestamp(ms: i64) -> String {
     }
 }
 
+fn format_timestamp_iso(ms: i64) -> String {
+    Timestamp::from_millisecond(ms)
+        .map(|ts| ts.to_string())
+        .unwrap_or_else(|_| format!("{ms}ms"))
+}
+
 fn format_duration(start: Option<i64>, end: Option<i64>) -> String {
     match (start, end) {
         (Some(s), Some(e)) => {
@@ -341,7 +467,7 @@ pub async fn run_list(
         Err(e) => {
             tracing::error!(repo = %repo, error = %e, "failed to load runs");
             let tmpl = ErrorTemplate {
-                repo: repo_display.clone(),
+                repo: repo_display,
                 page: "error".to_string(),
                 title: "Failed to load runs".to_string(),
                 detail: e,
@@ -351,14 +477,15 @@ pub async fn run_list(
     };
 
     let template_runs: Vec<RunListRow> = runs
-        .iter()
+        .into_iter()
         .map(|r| RunListRow {
-            id: r.id.clone(),
-            state_color: state_color(&r.state).to_string(),
-            sha_short: r.sha[..r.sha.len().min(8)].to_string(),
-            ref_short: r.ref_name.trim_start_matches("refs/heads/").to_string(),
-            queued: format_timestamp(r.queued_at_ms),
-            duration: format_duration(r.started_at_ms, r.finished_at_ms),
+            id: r.id,
+            state: r.state,
+            sha: r.sha,
+            ref_name: r.ref_name,
+            queued_at_ms: r.queued_at_ms,
+            started_at_ms: r.started_at_ms,
+            finished_at_ms: r.finished_at_ms,
         })
         .collect();
 
@@ -385,7 +512,7 @@ pub async fn run_detail(
         Err(e) => {
             tracing::error!(repo = %repo, run_id = %run_id, error = %e, "failed to load run detail");
             let tmpl = ErrorTemplate {
-                repo: repo_display.clone(),
+                repo: repo_display,
                 page: "error".to_string(),
                 title: "Failed to load run".to_string(),
                 detail: e,
@@ -394,16 +521,13 @@ pub async fn run_detail(
         }
     };
 
-    let sha_short = run.sha[..run.sha.len().min(8)].to_string();
     let detail_run = DetailRun {
-        state: run.state.clone(),
-        state_color: state_color(&run.state).to_string(),
-        sha_short: sha_short.clone(),
-        ref_short: run.ref_name.trim_start_matches("refs/heads/").to_string(),
-        queued: format_timestamp(run.queued_at_ms),
-        started: run.started_at_ms.map_or("—".to_string(), format_timestamp),
-        finished: run.finished_at_ms.map_or("—".to_string(), format_timestamp),
-        duration: format_duration(run.started_at_ms, run.finished_at_ms),
+        state: run.state,
+        sha: run.sha,
+        ref_name: run.ref_name,
+        queued_at_ms: run.queued_at_ms,
+        started_at_ms: run.started_at_ms,
+        finished_at_ms: run.finished_at_ms,
     };
 
     // Load CRI log contents for each sh event.
@@ -444,22 +568,18 @@ pub async fn run_detail(
         let mut detail_sh_events: Vec<DetailShEvent> = Vec::new();
         for (global_idx, ev) in &job_shs {
             let sh_n = sh_index_for_event(&sh_events, &ev.job_id, *global_idx);
-            let cmd_display = if ev.cmd.len() > 120 {
-                &ev.cmd[..120]
-            } else {
-                &ev.cmd
-            };
 
             let log = log_contents
                 .get(&(ev.job_id.clone(), sh_n))
-                .map(|s| s.to_string())
+                .cloned()
                 .unwrap_or_default();
 
             detail_sh_events.push(DetailShEvent {
                 index: sh_n,
-                duration: format_duration_exact(ev.started_at_ms, ev.finished_at_ms),
+                started_at_ms: ev.started_at_ms,
+                finished_at_ms: ev.finished_at_ms,
                 exit_code: ev.exit_code,
-                cmd_display: cmd_display.to_string(),
+                cmd: ev.cmd.clone(),
                 log_content: log,
             });
         }
@@ -467,19 +587,16 @@ pub async fn run_detail(
         detail_jobs.push(DetailJob {
             job_id: job.job_id.clone(),
             state: job.state.clone(),
-            state_color: state_color(&job.state).to_string(),
-            duration: format_duration(job.started_at_ms, job.finished_at_ms),
-            exit_str: job
-                .exit_code
-                .map(|c| format!(" · exit {c}"))
-                .unwrap_or_default(),
+            exit_code: job.exit_code,
+            started_at_ms: job.started_at_ms,
+            finished_at_ms: job.finished_at_ms,
             sh_events: detail_sh_events,
         });
     }
 
     let tmpl = RunDetailTemplate {
         repo: repo_display,
-        page: format!("ci · {sha_short}"),
+        page: format!("ci · {}", detail_run.sha_short()),
         run: detail_run,
         jobs: detail_jobs,
     };
@@ -521,33 +638,34 @@ mod tests {
     #[test]
     fn run_list_template_renders_empty() {
         let tmpl = RunListTemplate {
-            repo: "test.git".to_string(),
+            repo: "test".to_string(),
             page: "ci".to_string(),
             runs: vec![],
         };
         let html = tmpl.render().unwrap();
         assert!(html.contains("no runs yet"));
-        assert!(html.contains("ci · test.git"));
+        assert!(html.contains("ci · test"));
     }
 
     #[test]
     fn run_list_template_renders_runs() {
         let tmpl = RunListTemplate {
-            repo: "test.git".to_string(),
+            repo: "test".to_string(),
             page: "ci".to_string(),
             runs: vec![RunListRow {
                 id: "abc123".to_string(),
-                state_color: "c-ok".to_string(),
-                sha_short: "deadbeef".to_string(),
-                ref_short: "main".to_string(),
-                queued: "just now".to_string(),
-                duration: "1s".to_string(),
+                state: "complete".to_string(),
+                sha: "deadbeef".to_string(),
+                ref_name: "refs/heads/main".to_string(),
+                queued_at_ms: 1000,
+                started_at_ms: Some(2000),
+                finished_at_ms: Some(3000),
             }],
         };
         let html = tmpl.render().unwrap();
         assert!(html.contains("deadbeef"));
         assert!(html.contains("main"));
-        assert!(html.contains("/test.git/ci/abc123"));
+        assert!(html.contains("/test/ci/abc123"));
     }
 
     #[test]
diff --git a/templates/ci/run_detail.html b/templates/ci/run_detail.html
index 42c0bde..70285ed 100644
--- a/templates/ci/run_detail.html
+++ b/templates/ci/run_detail.html
@@ -1,6 +1,6 @@
 {% extends "_base.html" %}
 
-{% block title %}ci · {{ repo }} · {{ run.sha_short }}{% endblock %}
+{% block title %}ci · {{ repo }} · {{ run.sha_short() }}{% endblock %}
 
 {% block nav %}
 {% include "_nav.html" %}
@@ -9,27 +9,30 @@
 {% block content %}
 <div class="ci-meta">
   <div class="ci-meta-primary">
-    <span class="{{ run.state_color }}">{{ run.state }}</span>
-    · <span class="c-accent">{{ run.sha_short }}</span>
-    · {{ run.ref_short }}
+    <span class="{{ run.state_class() }}">{{ run.state }}</span>
+    · <span class="c-accent">{{ run.sha_short() }}</span>
+    · {{ run.ref_short() }}
   </div>
   <div class="ci-meta-secondary">
-    queued {{ run.queued }} · started {{ run.started }} · finished {{ run.finished }} · {{ run.duration }}
+    queued <time title="{{ run.queued_iso() }}">{{ run.queued_relative() }}</time>
+    · started {% if run.has_started() %}<time title="{{ run.started_iso() }}">{{ run.started_display() }}</time>{% else %}{{ run.started_display() }}{% endif %}
+    · finished {% if run.has_finished() %}<time title="{{ run.finished_iso() }}">{{ run.finished_display() }}</time>{% else %}{{ run.finished_display() }}{% endif %}
+    · {{ run.duration_display() }}
   </div>
 </div>
 
 {% for job in jobs %}
 <div class="ci-job">
   <div class="ci-job-header">
-    <span class="{{ job.state_color }}">{{ job.state }}</span>
-    · {{ job.job_id }} · {{ job.duration }}{{ job.exit_str }}
+    <span class="{{ job.state_class() }}">{{ job.state }}</span>
+    · {{ job.job_id }} · {{ job.duration_display() }}{{ job.exit_display() }}
   </div>
   {% for sh in job.sh_events %}
   <div class="ci-sh">
     <div class="ci-sh-meta">
-      sh-{{ sh.index }} · {{ sh.duration }} · exit {{ sh.exit_code }}
+      sh-{{ sh.index }} · {{ sh.duration_display() }} · exit {{ sh.exit_code }}
     </div>
-    <div class="ci-sh-cmd">{{ sh.cmd_display }}</div>
+    <div class="ci-sh-cmd">{{ sh.cmd_display() }}</div>
     {% if !sh.log_content.is_empty() %}
     <pre class="ci-sh-log">{{ sh.log_content }}</pre>
     {% endif %}
diff --git a/templates/ci/run_list.html b/templates/ci/run_list.html
index cbd0ee6..4bdb03d 100644
--- a/templates/ci/run_list.html
+++ b/templates/ci/run_list.html
@@ -21,11 +21,11 @@
   <tbody>
   {% for run in runs %}
     <tr>
-      <td><span class="ci-status-dot {{ run.state_color }}"></span></td>
-      <td><a href="/{{ repo }}/ci/{{ run.id }}" class="ci-sha-link">{{ run.sha_short }}</a></td>
-      <td>{{ run.ref_short }}</td>
-      <td>{{ run.queued }}</td>
-      <td>{{ run.duration }}</td>
+      <td><span class="ci-status-dot {{ run.state_class() }}"></span></td>
+      <td><a href="/{{ repo }}/ci/{{ run.id }}" class="ci-sha-link">{{ run.sha_short() }}</a></td>
+      <td>{{ run.ref_short() }}</td>
+      <td><time title="{{ run.queued_iso() }}">{{ run.queued_relative() }}</time></td>
+      <td>{{ run.duration_display() }}</td>
     </tr>
   {% else %}
     <tr><td colspan="5" class="empty">no runs yet</td></tr>