use topcoat::{
Result,
context::Cx,
router::{page, query_params},
runtime::{connected, shard, signal},
view::{Child, View, component, emit, live, view},
};
use crate::{
app::is_interactive,
components::{brand, dashboard_body, filters, live_filters, theme_style},
data::{REFRESH_EVERY, load},
params::{UsageParams, parse, parse_metric, parse_selection, pick_currency},
theme,
};
use tokenburn_core::settings;
#[component]
pub async fn document(
interactive: bool,
theme: usize,
title: String,
child: Child<'_>,
) -> Result<impl View> {
Ok(view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
if !interactive {
<meta http-equiv="refresh" content="10">
}
<title>(title)</title>
<link rel="stylesheet" href="/style.css">
theme_style(index: theme)
topcoat::dev::script()
if interactive {
topcoat::runtime::script()
}
</head>
<body><main>(child)</main></body>
</html>
})
}
pub fn page_base(query: &tokenburn_core::Query, metric: tokenburn_core::Metric) -> String {
format!(
"/?tool={}&bucket={}&window={}&metric={}",
query.tool.as_str(),
query.bucket.label(),
query.window.label(),
metric.label(),
)
}
fn effective_currency(requested: Option<&str>) -> tokenburn_core::Currency {
let st = settings::load();
let saved = tokenburn_core::features::currency::resolve(&st.currency, &st.rates);
let currency = pick_currency(requested, saved, &st.rates);
if currency.code != saved.code {
let _ = settings::save_currency(currency.code);
}
currency
}
#[page("/")]
async fn home(cx: &Cx) -> Result<impl View> {
let params = query_params::<UsageParams>(cx)?;
let interactive = is_interactive(cx);
let metric = parse_metric(params.metric.as_deref());
let saved = settings::theme_index();
let theme_index = theme::resolve(params.theme.as_deref(), saved);
if theme_index != saved {
let _ = settings::save_theme(theme_index);
}
let currency = effective_currency(params.currency.as_deref());
let selection = params.selection();
let snapshot = if interactive {
None
} else {
Some(load(params.to_query(), &selection, currency).await)
};
let base = snapshot
.as_ref()
.map(|d| page_base(&d.query, metric))
.unwrap_or_default();
let tool = params.tool.clone().unwrap_or_else(|| "all".into());
let bucket = params.bucket.clone().unwrap_or_else(|| "total".into());
let window = params.window.clone().unwrap_or_else(|| "auto".into());
Ok(view! {
document(interactive: interactive, theme: theme_index, title: format!("{} — AI token usage", tokenburn_core::appinfo::title()),
if let Some(d) = &snapshot {
<header class="top">
brand(active: "dashboard".to_string())
filters(query: d.query, theme: theme_index, metric: metric, currency: currency)
</header>
dashboard_body(d: d, live: false, metric: metric, base: &base, click_bucket: None, click_project: None)
} else {
<header class="top">brand(active: "dashboard".to_string())</header>
live_dashboard(tool: tool, bucket: bucket, window: window, theme: theme::names()[theme_index].to_string(), metric: metric.label().to_string(), currency: currency.code.to_string(), sel_bucket: selection.bucket.clone().unwrap_or_default(), sel_project: selection.project.clone().unwrap_or_default())
}
)
})
}
fn saved_theme_and_currency() -> (usize, tokenburn_core::Currency) {
(settings::theme_index(), settings::currency())
}
#[page("/about")]
async fn about_page(cx: &Cx) -> Result<impl View> {
let _ = cx;
let (theme_index, _) = saved_theme_and_currency();
let rows = tokenburn_core::appinfo::about_rows();
Ok(view! {
document(interactive: false, theme: theme_index, title: format!("About — {}", tokenburn_core::appinfo::title()),
<header class="top">brand(active: "about".to_string())</header>
<section>
<h2>(tokenburn_core::appinfo::title())</h2>
<p class="dim">(tokenburn_core::appinfo::TAGLINE)</p>
<table class="kv"><tbody>
for (k, v) in rows {
<tr>
<th>(k)</th>
<td>
if tokenburn_core::appinfo::is_link(&v) {
<a href=(v.clone()) target="_blank" rel="noopener">(v.clone())</a>
} else {
(v.clone())
}
</td>
</tr>
}
</tbody></table>
</section>
)
})
}
#[page("/settings")]
async fn settings_page(cx: &Cx) -> Result<impl View> {
let _ = cx;
let (theme_index, currency) = saved_theme_and_currency();
Ok(view! {
document(interactive: false, theme: theme_index, title: format!("Settings — {}", tokenburn_core::appinfo::title()),
<header class="top">brand(active: "settings".to_string())</header>
<section>
<h2>"Settings"</h2>
<p class="dim">"Saved to " (tokenburn_core::appinfo::settings_file()) " and shared with the TUI and GUI."</p>
<form class="filters" method="get" action="/">
<label>"currency "
<select name="currency">
for cur in tokenburn_core::CURRENCIES {
<option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
}
</select>
</label>
<label>"theme "
<select name="theme">
for (i, n) in theme::names().iter().enumerate() {
<option value=(*n) selected=(i == theme_index)>(*n)</option>
}
</select>
</label>
<button type="submit">"Save"</button>
</form>
</section>
)
})
}
/// The live dashboard. Filter values are signals; because this shard reads
/// them on the server, changing a select re-renders only this shard. While a
/// connection is open the data region re-reads the logs every few seconds.
#[shard]
async fn live_dashboard(
cx: &Cx,
tool: String,
bucket: String,
window: String,
theme: String,
metric: String,
currency: String,
sel_bucket: String,
sel_project: String,
) -> Result<impl View> {
let tool = signal(cx, move || tool);
let bucket = signal(cx, move || bucket);
let window = signal(cx, move || window);
let theme = signal(cx, move || theme);
let metric = signal(cx, move || metric);
let currency_sig = signal(cx, move || currency);
// What the user clicked (empty = nothing). Untrusted like every signal: the
// core only ever *compares* these against real labels, and drops stale ones.
let sel_bucket = signal(cx, move || sel_bucket);
let sel_project = signal(cx, move || sel_project);
// Signal values come back from the browser: treat them as untrusted.
// `parse` ignores anything that is not a known choice.
let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
let chart_metric = parse_metric(Some(&metric.get()));
// Signal values are untrusted: only a known code counts, and a change is remembered.
let currency = effective_currency(Some(¤cy_sig.get()));
let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
// Same for the theme; an unknown name keeps the saved one. A change is remembered.
let saved = settings::theme_index();
let theme_index = theme::resolve(Some(&theme.get()), saved);
if theme_index != saved {
let _ = settings::save_theme(theme_index);
}
Ok(view! {
// Re-emitted on every theme change; later in the document than the <head> one.
theme_style(index: theme_index)
live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: ¤cy_sig)
(live! {
loop {
let d = load(query, &selection, currency).await;
let is_connected = connected(cx);
let token = emit! { dashboard_body(d: &d, live: is_connected, metric: chart_metric, base: "", click_bucket: Some(&sel_bucket), click_project: Some(&sel_project)) }?;
if !is_connected {
// HTTP render: emit once and finish.
break Ok(token);
}
tokio::time::sleep(REFRESH_EVERY).await;
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use tokenburn_core::{Bucket, Metric, Query, ToolFilter, Window};
#[test]
fn the_link_base_carries_the_view_but_not_the_theme_or_a_selection() {
let q = Query {
tool: ToolFilter::PI,
bucket: Bucket::Day,
window: Window::All,
};
assert_eq!(
page_base(&q, Metric::Cost),
"/?tool=pi&bucket=daily&window=all&metric=cost"
);
assert!(!page_base(&q, Metric::Tokens).contains("theme"));
}
}