Wire real OpenCode selectors and URLs from captured pages

Go page (/workspace/{id}/go): parse three [data-slot=usage-item] frames
(Rolling/Weekly/Monthly) into usage percentages + computed resets_at from the
relative reset-duration text. Zen page (/workspace/{id}): parse
[data-slot=balance] b into remaining + currency unit. Add an offline unit test
against tmp/ captures. Fix pretty renderer (percent display, money format,
day-aware relative times). Update DESIGN.md, README, examples and config
template to the real wrk_ workspace ids.
This commit is contained in:
ocd 2026-07-30 01:16:36 +03:00
parent 3b1d2a4389
commit 41f35aec34
10 changed files with 257 additions and 104 deletions

View File

@ -3,8 +3,9 @@
`ocd` is a console application. It reads a config file. The file lists opencode `ocd` is a console application. It reads a config file. The file lists opencode
accounts and auth cookies. `ocd` scrapes the opencode Zen and Go pages. It accounts and auth cookies. `ocd` scrapes the opencode Zen and Go pages. It
returns limits and balance data. Limits cover three time frames: 5 hours, week, returns limits and balance data. Limits cover three time frames: 5 hours, week,
and month. The program reports usage, totals, reset times, and the next update and month. The Go page reports usage as a percentage per frame and a relative
time. It also reports remaining account balance. reset time. `ocd` stores the percentage and computes an absolute reset time.
The Zen page reports remaining balance. `ocd` prints all of it.
## Goals ## Goals
@ -43,17 +44,20 @@ per-workspace. So config carries one `default_workspace` plus an
`default_workspace` is a required id. The program fetches it by default. `default_workspace` is a required id. The program fetches it by default.
`extra_workspaces` is optional. The program fetches all listed workspaces. `extra_workspaces` is optional. The program fetches all listed workspaces.
The program cannot auto-discover the default workspace. That path is not The program does not auto-discover the default workspace. That path is not
reliable. So the user supplies the id. A future `ocd workspace list` command reliable. The user supplies the id. Workspace ids look like
`wrk_01KYNTSGXF7GFXMAMT36N3GH21`. A future `ocd workspace list` command
can add discovery later. can add discovery later.
## Two pages per workspace ## Two pages per workspace
Each workspace needs two HTTP requests. Each workspace needs two HTTP requests. Each request puts the workspace id
in the path.
1. The Zen page is `https://opencode.ai/workspace/{id}`. It returns balance.
2. The Go page is `https://opencode.ai/workspace/{id}/go`. It returns limits.
1. The Zen page returns balance.
2. The Go page returns limits.
The program fires both at once per workspace. It uses `tokio::join`. Then it The program fires both at once per workspace. It uses `tokio::join`. Then it
waits about 500 ms. Then it moves to the next workspace. The workspace walk is waits about 500 ms. Then it moves to the next workspace. The workspace walk is
@ -69,9 +73,10 @@ Each time frame resets on its own rule.
- Week: calendar based. It resets every Monday. - Week: calendar based. It resets every Monday.
- Month: calendar based. It resets 31 days from the subscription date. - Month: calendar based. It resets 31 days from the subscription date.
The model keeps one `resets_at` field per frame. The scrape step reads this from The Go page gives a relative reset string such as "Resets in 30 days 7
the page when present. Calendar resets can fall back to a computed value when the hours". The scrape step parses that into seconds and adds it to the fetch
scrape lacks the time but knows the rule. time. The result is an absolute `resets_at` datetime per frame. This is how a
widget gets a sortable time even though the page only states a duration.
## Config format ## Config format
@ -84,13 +89,13 @@ default_ttl_secs = 180
[[account]] [[account]]
name = "work" name = "work"
cookie = "${OCD_COOKIE_WORK}" cookie = "${OCD_COOKIE_WORK}"
default_workspace = "ws_123" default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21"
extra_workspaces = ["ws_456"] extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"]
[[account]] [[account]]
name = "personal" name = "personal"
cookie = "ocd_session=...." cookie = "ocd_session=...."
default_workspace = "ws_789" default_workspace = "wrk_03KYNTSGXF7GFXMAMT36N3GH21"
``` ```
Config load order: `-c` flag, then `$OCD_CONFIG`, then `./ocd.toml`, then Config load order: `-c` flag, then `$OCD_CONFIG`, then `./ocd.toml`, then
@ -249,9 +254,12 @@ Color zones. Color is off when output is not a terminal.
- 70 to 90 per cent: amber. - 70 to 90 per cent: amber.
- Over 90 per cent: red. - Over 90 per cent: red.
Each row shows name, `used / total unit`, percentage, and `resets in 3h12m`. Each row shows percentage and `resets in 3h12m`. The opencode Go page exposes
only a percentage per frame, not an absolute count, so the bar is the percentage.
The next-update row shows `2025-08-30 17:00 UTC (in 47m)`. The next-update row shows `2025-08-30 17:00 UTC (in 47m)` when the page ever
reports one. Today the opencode page has no such banner, so the field stays
empty in the model.
`--ascii` or a non-UTF-8 locale prints `#` repeats. `--ascii` or a non-UTF-8 locale prints `#` repeats.
@ -260,6 +268,7 @@ The next-update row shows `2025-08-30 17:00 UTC (in 47m)`.
- Crate name: `ocd`. - Crate name: `ocd`.
- Live watch mode for `htop`-like refresh. A later task. - Live watch mode for `htop`-like refresh. A later task.
- Confirm the Zen and Go page URLs and selectors. Fill `scrape.rs`. - Send `If-Modified-Since` and `If-None-Match`. The cache already stores the
- Confirm conditional GET support. OpenCode may not send ETag. Cache still works slots. The HTTP layer needs to attach the headers once we confirm OpenCode
emits ETag or Last-Modified. The TTL cache keeps politeness intact without it.
from TTL alone. from TTL alone.

View File

@ -27,8 +27,8 @@ Each account section carries `default_workspace` and optional `extra_workspaces`
[[account]] [[account]]
name = "work" name = "work"
cookie = "${OCD_COOKIE_WORK}" cookie = "${OCD_COOKIE_WORK}"
default_workspace = "ws_123" default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21"
extra_workspaces = ["ws_456"] extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"]
``` ```
`${VAR}` expands from the environment so cookies stay out of the file. `${VAR}` expands from the environment so cookies stay out of the file.
@ -41,7 +41,7 @@ ocd raw json # one JSON object for widgets and agent plugins
ocd raw plain # TSV-ish lines for shell and awk ocd raw plain # TSV-ish lines for shell and awk
ocd fetch # update cache, print nothing; for cron ocd fetch # update cache, print nothing; for cron
ocd cache ls | clear ocd cache ls | clear
ocd -a work -w ws_456 pretty # one account, one workspace ocd -a work -w wrk_02KYNTSGXF7GFXMAMT36N3GH21 pretty # one account, one workspace
``` ```
## Politeness ## Politeness
@ -51,6 +51,9 @@ workspaces. Cache TTL defaults to 180 s. Use `--force-refresh` to bypass.
## Status ## Status
This is a scaffold. The HTTP, cache, pacing, and render pipeline work. The The HTTP client, cache, pacing, and renderer work. The `src/scrape.rs`
opencode HTML selectors in `src/scrape.rs` are placeholders until the real Zen selectors target the real Zen balance element and the Go usage frames, and
and Go pages are captured. See `DESIGN.md`. parse correctly against captured pages (see the `scrape::tests` unit test).
Live fetching needs a valid account cookie and a workspace id. Conditional GET
headers are not yet sent; the TTL cache handles politeness alone. See
`DESIGN.md`.

View File

@ -6,10 +6,10 @@ default_ttl_secs = 180
[[account]] [[account]]
name = "work" name = "work"
cookie = "${OCD_COOKIE_WORK}" cookie = "${OCD_COOKIE_WORK}"
default_workspace = "ws_REPLACE_ME" default_workspace = "wrk_REPLACE_ME"
extra_workspaces = [] extra_workspaces = []
[[account]] [[account]]
name = "personal" name = "personal"
cookie = "ocd_session=...." cookie = "ocd_session=...."
default_workspace = "ws_REPLACE_ME" default_workspace = "wrk_REPLACE_ME"

View File

@ -8,12 +8,11 @@ use std::time::Duration;
use crate::error::FetchError; use crate::error::FetchError;
/// Balance comes from the Zen page. Limits come from the Go page. /// Balance comes from the Zen page at the workspace root. Limits come from
/// /// the Go page at `/workspace/{id}/go`. The `{workspace}` slot is the
/// TODO: Confirm the real base URLs and the `{workspace}` path. Replace these /// workspace id such as `wrk_01KYNTSGXF7GFXMAMT36N3GH21`.
/// constants once the pages are captured. pub const ZEN_BASE_URL: &str = "https://opencode.ai/workspace/{workspace}";
pub const ZEN_BASE_URL: &str = "https://opencode.ai/zen/{workspace}"; pub const GO_BASE_URL: &str = "https://opencode.ai/workspace/{workspace}/go";
pub const GO_BASE_URL: &str = "https://opencode.ai/go/{workspace}";
pub fn zen_url(workspace: &str) -> String { pub fn zen_url(workspace: &str) -> String {
ZEN_BASE_URL.replace("{workspace}", workspace) ZEN_BASE_URL.replace("{workspace}", workspace)

View File

@ -139,11 +139,11 @@ default_ttl_secs = 180
[[account]] [[account]]
name = "work" name = "work"
cookie = "${OCD_COOKIE_WORK}" cookie = "${OCD_COOKIE_WORK}"
default_workspace = "ws_REPLACE_ME" default_workspace = "wrk_REPLACE_ME"
extra_workspaces = [] extra_workspaces = []
[[account]] [[account]]
name = "personal" name = "personal"
cookie = "ocd_session=...." cookie = "ocd_session=...."
default_workspace = "ws_REPLACE_ME" default_workspace = "wrk_REPLACE_ME"
"#; "#;

View File

@ -55,9 +55,6 @@ impl FetchError {
/// Failures while turning a fetched HTML page into the data model. /// Failures while turning a fetched HTML page into the data model.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ScrapeError { pub enum ScrapeError {
#[error("opencode HTML selectors are not configured yet; see scrape.rs: {hint}")]
NotConfigured { hint: String },
#[error("could not parse page: {0}")] #[error("could not parse page: {0}")]
Parse(String), Parse(String),
} }

View File

@ -93,7 +93,8 @@ async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> A
http.get_text(&go_url, &tgt.cookie), http.get_text(&go_url, &tgt.cookie),
); );
let fetched_at = Some(Utc::now().to_rfc3339()); let now = Utc::now();
let fetched_at = Some(now.to_rfc3339());
let balance = match &zen_res { let balance = match &zen_res {
Ok(html) => match scrape::parse_zen_balance(html) { Ok(html) => match scrape::parse_zen_balance(html) {
Ok(b) => Some(b), Ok(b) => Some(b),
@ -108,7 +109,7 @@ async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> A
} }
}; };
let limits = match &go_res { let limits = match &go_res {
Ok(html) => match scrape::parse_go_limits(html) { Ok(html) => match scrape::parse_go_limits(html, now) {
Ok(l) => Some(l), Ok(l) => Some(l),
Err(e) => { Err(e) => {
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go parse failed"); tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go parse failed");

View File

@ -55,9 +55,8 @@ fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> S
if let Some(b) = &r.balance { if let Some(b) = &r.balance {
out.push_str(&format!( out.push_str(&format!(
" balance {} {} remaining\n", " balance {}{:.2} remaining\n",
fmt_num(b.remaining), b.unit, b.remaining
b.unit,
)); ));
} else { } else {
out.push_str(" balance unavailable\n"); out.push_str(" balance unavailable\n");
@ -74,7 +73,9 @@ fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> S
fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String { fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String {
let pct = frame.pct(); let pct = frame.pct();
let bar = colored_bar(pct, width, ascii, no_color); let bar = colored_bar(pct, width, ascii, no_color);
let usage = if frame.total > 0.0 { let usage = if frame.unit == "%" {
format!("{:.0}%", pct)
} else if frame.total > 0.0 {
format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit) format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit)
} else { } else {
format!("{} {}", fmt_num(frame.used), frame.unit) format!("{} {}", fmt_num(frame.used), frame.unit)
@ -82,7 +83,7 @@ fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_colo
let resets = frame let resets = frame
.resets_at .resets_at
.as_deref() .as_deref()
.map(|t| format!("resets in {}", timefmt::relative(t))) .map(|t| format!("resets {}", timefmt::relative(t)))
.unwrap_or_default(); .unwrap_or_default();
format!( format!(
" {:<7} {} {:<22} {:>5.1}% {}\n", " {:<7} {} {:<22} {:>5.1}% {}\n",

View File

@ -1,60 +1,197 @@
//! HTML to model. The ONLY module coupled to the opencode page shape. //! HTML to model. The ONLY module coupled to the opencode page shape.
//! //!
//! When the page changes, edit selectors here. Transport, cache, and render do //! When the page changes, edit selectors here. Transport, cache, and render do
//! not change. Each function returns the data for one page: //! not change. The opencode app renders with React and exposes stable
//! `data-slot` attributes, which makes the scrape robust to class churn.
//! //!
//! - `parse_go_limits` reads the Go page. It returns `Limits`. //! - `parse_go_limits` reads the Go page at `/workspace/{id}/go`.
//! - `parse_zen_balance` reads the Zen page. It returns `Balance`. //! It returns `Limits` from three `[data-slot="usage-item"]` blocks:
//! //! Rolling Usage, Weekly Usage, Monthly Usage. Each gives a percentage and a
//! Both live in `scraper`. CSS selectors target small subtrees. The code below //! relative reset time. We store `used = pct`, `total = 100`, `unit = "%"`
//! is wired but the selectors are placeholders. Fill them from a captured page. //! and compute `resets_at` from the relative duration.
//! Until then the functions return `ScrapeError::NotConfigured`. //! - `parse_zen_balance` reads the Zen page at `/workspace/{id}`.
//! It returns `Balance` from `[data-slot="balance"] b`.
use scraper::{Html, Selector}; use chrono::{DateTime, Duration, Utc};
use scraper::{ElementRef, Html, Selector};
use crate::error::ScrapeError; use crate::error::ScrapeError;
use crate::model::{Balance, Limits, UsageFrame}; use crate::model::{Balance, Limits, UsageFrame};
/// Parse the Go page HTML into limits for the three frames plus next-update. /// Parse the Go page HTML into limits for the three frames.
/// pub fn parse_go_limits(html: &str, now: DateTime<Utc>) -> Result<Limits, ScrapeError> {
/// Plan once selectors are known: let document = Html::parse_document(html);
/// 1. Selector the headline count for the 5h, week, and month frames. let item_sel =
/// 2. Read `used`, `total`, and `unit` from each frame block. Selector::parse(r#"[data-slot="usage-item"]"#).map_err(|e| ScrapeError::Parse(e.to_string()))?;
/// 3. Read `resets_at` per frame. The 5h frame is activity based, so the time
/// must come from the page. The week and month frames may be computed from
/// "every Monday" and "31 days from subscription" when absent.
/// 4. Read the "next update" banner into `next_update`.
pub fn parse_go_limits(html: &str) -> Result<Limits, ScrapeError> {
// `Html::parse_document` validates the HTML pipeline. Replace the rest.
let _document = Html::parse_document(html);
let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?;
Err(ScrapeError::NotConfigured { let mut rolling = None;
hint: "fill selectors for the 5h, week, and month frames in parse_go_limits" let mut weekly = None;
.to_string(), let mut monthly = None;
for item in document.select(&item_sel) {
let label = first_text(item, r#"[data-slot="usage-label"]"#).unwrap_or_default();
let value = first_text(item, r#"[data-slot="usage-value"]"#).unwrap_or_default();
let reset = first_text(item, r#"[data-slot="reset-time"]"#);
let frame = build_frame(&value, reset.as_deref(), now);
let lower = label.to_lowercase();
if lower.contains("rolling") {
rolling = Some(frame);
} else if lower.contains("weekly") || lower.contains("week") {
weekly = Some(frame);
} else if lower.contains("monthly") || lower.contains("month") {
monthly = Some(frame);
}
}
let blank = || UsageFrame {
used: 0.0,
total: 100.0,
unit: "%".to_string(),
resets_at: None,
};
Ok(Limits {
usage_5h: rolling.unwrap_or_else(blank),
usage_week: weekly.unwrap_or_else(blank),
usage_month: monthly.unwrap_or_else(blank),
// The opencode page has no separate "next update" banner. Reset times
// per frame are the relevant data. Kept None for forward compatibility.
next_update: None,
}) })
} }
/// Parse the Zen page HTML into remaining balance. /// Parse the Zen page HTML into remaining balance.
///
/// Plan once selectors are known: selector the balance figure, the currency
/// unit, and optional time-of-fetch stamp.
pub fn parse_zen_balance(html: &str) -> Result<Balance, ScrapeError> { pub fn parse_zen_balance(html: &str) -> Result<Balance, ScrapeError> {
let _document = Html::parse_document(html); let document = Html::parse_document(html);
let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?; let sel =
Selector::parse(r#"[data-slot="balance"] b"#).map_err(|e| ScrapeError::Parse(e.to_string()))?;
Err(ScrapeError::NotConfigured { let text = document
hint: "fill selectors for the balance figure and unit in parse_zen_balance".to_string(), .select(&sel)
}) .next()
.map(|e| ElementRef::text(&e).collect::<String>())
.ok_or_else(|| ScrapeError::Parse("balance element not found".to_string()))?
.trim()
.to_string();
let (remaining, unit) = parse_money(&text)
.ok_or_else(|| ScrapeError::Parse(format!("could not parse balance value from {text:?}")))?;
Ok(Balance { remaining, unit })
} }
/// Helper for future use: parse a frame block once the DOM is known. fn build_frame(value_text: &str, reset_text: Option<&str>, now: DateTime<Utc>) -> UsageFrame {
#[allow(dead_code)] let pct = parse_percent(value_text).unwrap_or(0.0);
fn frame(used: f64, total: f64, unit: &str, resets_at: Option<String>) -> UsageFrame { let resets_at = reset_text.and_then(|t| parse_reset_at(t, now)).map(|dt| dt.to_rfc3339());
UsageFrame { UsageFrame {
used, used: pct,
total, total: 100.0,
unit: unit.to_string(), unit: "%".to_string(),
resets_at, resets_at,
} }
}
/// Parse a percentage string such as "0%" or "61%". Whitespace and the trailing
/// percent sign are ignored.
fn parse_percent(s: &str) -> Option<f64> {
let cleaned: String = s.trim().chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | ',')).collect();
let normalized = cleaned.replace(',', ".");
normalized.parse::<f64>().ok()
}
/// Parse a money string such as "$0.00" into (amount, unit). The unit is the
/// leading non-numeric prefix (currency symbol).
fn parse_money(s: &str) -> Option<(f64, String)> {
let s = s.trim();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len()
&& !(bytes[i].is_ascii_digit() || bytes[i] == b'.' || bytes[i] == b'-' || bytes[i] == b',')
{
i += 1;
}
let unit = s[..i].trim().to_string();
let num: String = s[i..]
.chars()
.filter(|c| c.is_ascii_digit() || matches!(c, '.' | ',' | '-'))
.collect();
let num = num.replace(',', ".");
let amount = num.parse::<f64>().ok()?;
Some((amount, unit))
}
/// Parse a relative "Resets in N days N hours N minutes" string into an
/// absolute datetime `now` plus the stated duration.
fn parse_reset_at(text: &str, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
let lower = text.to_lowercase();
let tokens: Vec<&str> = lower.split_whitespace().collect();
let mut total_secs: i64 = 0;
let mut i = 0;
while i < tokens.len() {
if let Ok(n) = tokens[i].parse::<i64>() {
let unit = tokens.get(i + 1).unwrap_or(&"");
let mult = if unit.starts_with("day") {
86_400
} else if unit.starts_with("hour") || unit.starts_with("hr") {
3_600
} else if unit.starts_with("min") {
60
} else if unit.starts_with("sec") {
1
} else {
0
};
total_secs += n * mult;
i += 2;
} else {
i += 1;
}
}
if total_secs == 0 {
return None;
}
now.checked_add_signed(Duration::seconds(total_secs))
}
/// First descendant element matching `sel`, with its combined text trimmed.
fn first_text(el: ElementRef, sel: &str) -> Option<String> {
let s = Selector::parse(sel).ok()?;
el.select(&s)
.next()
.map(|e| ElementRef::text(&e).collect::<String>().trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn read(p: &str) -> Option<String> {
std::fs::read_to_string(p).ok()
}
/// Offline parse of captured pages in tmp/. This test is a no-op when
/// those files are absent, so CI without captures still passes.
#[test]
fn parse_captured_pages() {
let Some(go) = read("tmp/opencode.html") else {
eprintln!("skip: tmp/opencode.html missing");
return;
};
let Some(zen) = read("tmp/zen.html") else {
eprintln!("skip: tmp/zen.html missing");
return;
};
let now = Utc::now();
let limits = parse_go_limits(&go, now).expect("go limits parse");
assert_eq!(limits.usage_5h.used, 0.0, "rolling pct");
assert!((limits.usage_week.used - 61.0).abs() < 1.0, "week pct");
assert!((limits.usage_month.used - 30.0).abs() < 1.0, "month pct");
assert!(limits.usage_5h.resets_at.is_some(), "5h reset computed");
let bal = parse_zen_balance(&zen).expect("zen balance parse");
assert!(bal.remaining >= 0.0, "balance non-negative");
assert_eq!(bal.unit, "$", "balance unit");
}
} }

View File

@ -5,8 +5,8 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
/// Relative phrasing of a future or past timestamp. Example: "in 3h12m" or /// Relative phrasing of a future or past timestamp. Examples: "in 30d7h",
/// "47m ago". Falls back to the input on parse failure. /// "in 3h12m", "in 47m", "12m ago". Falls back to the input on parse failure.
pub fn relative(iso: &str) -> String { pub fn relative(iso: &str) -> String {
let Some(t) = parse(iso) else { let Some(t) = parse(iso) else {
return iso.to_string(); return iso.to_string();
@ -14,27 +14,9 @@ pub fn relative(iso: &str) -> String {
let now = Utc::now(); let now = Utc::now();
let d = t.signed_duration_since(now); let d = t.signed_duration_since(now);
let secs = d.num_seconds(); let secs = d.num_seconds();
let sign = if secs >= 0 { "in " } else { " ago" };
if secs.abs() < 60 { let body = dur_words(secs.abs());
if secs >= 0 { format!("{sign}{body}")
"in <1m".to_string()
} else {
"<1m ago".to_string()
}
} else {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let core = match (h.abs() > 0, m != 0) {
(true, true) => format!("{}h{}m", h.abs(), m.abs()),
(true, false) => format!("{}h", h.abs()),
(false, _) => format!("{}m", m.abs()),
};
if secs >= 0 {
format!("in {core}")
} else {
format!("{core} ago")
}
}
} }
/// Absolute UTC string like "2025-08-31 17:00 UTC". /// Absolute UTC string like "2025-08-31 17:00 UTC".
@ -45,6 +27,30 @@ pub fn absolute(iso: &str) -> String {
t.format("%Y-%m-%d %H:%M UTC").to_string() t.format("%Y-%m-%d %H:%M UTC").to_string()
} }
/// Compact duration. Starts with days when the span is at least one day.
fn dur_words(total: i64) -> String {
let s = total.max(0);
if s == 0 {
return "<1m".to_string();
}
let days = s / 86_400;
let rem = s % 86_400;
if days > 0 {
let hours = rem / 3_600;
if hours > 0 {
return format!("{days}d{hours}h");
}
return format!("{days}d");
}
let hours = s / 3_600;
let mins = (s % 3_600) / 60;
if hours > 0 {
return format!("{hours}h{m}m", m = if mins > 0 { mins.to_string() } else { String::new() });
}
let mins = (s / 60).max(1);
format!("{mins}m")
}
fn parse(iso: &str) -> Option<DateTime<Utc>> { fn parse(iso: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(iso) DateTime::parse_from_rfc3339(iso)
.ok() .ok()