Skip to main content

tokenburn_web/
pages.rs

1//! Pages: the document shell, the home page and the live dashboard shard.
2
3use topcoat::{
4    Result,
5    context::Cx,
6    router::{page, query_params},
7    runtime::{connected, shard, signal},
8    view::{Child, View, component, emit, live, view},
9};
10
11use crate::{
12    app::is_interactive,
13    components::{dashboard_body, filters, live_filters, theme_style},
14    data::{REFRESH_EVERY, load},
15    params::{UsageParams, parse, parse_metric, parse_selection, pick_currency},
16    theme,
17};
18
19use tokenburn_core::settings;
20
21/// HTML shell shared by every page.
22#[component]
23pub async fn document(interactive: bool, theme: usize, child: Child<'_>) -> Result<impl View> {
24    Ok(view! {
25        <!DOCTYPE html>
26        <html lang="en">
27            <head>
28                <meta charset="utf-8">
29                <meta name="viewport" content="width=device-width, initial-scale=1">
30                if !interactive {
31                    // Static fallback: no runtime, so reload every 10 s (the live mode pushes updates).
32                    <meta http-equiv="refresh" content="10">
33                }
34                <title>"TokenBurn — AI token usage"</title>
35                <link rel="stylesheet" href="/style.css">
36                // The theme's CSS variables override the stylesheet's defaults.
37                theme_style(index: theme)
38                topcoat::dev::script()
39                if interactive {
40                    topcoat::runtime::script()
41                }
42            </head>
43            <body><main>(child)</main></body>
44        </html>
45    })
46}
47
48/// The dashboard URL for `query`/`metric` *without* a selection — what the static
49/// page's bar and slice links build on. The theme is left out on purpose: the saved
50/// one applies, so following a link never re-asserts (and re-saves) a stale choice.
51pub fn page_base(query: &tokenburn_core::Query, metric: tokenburn_core::Metric) -> String {
52    format!(
53        "/?tool={}&bucket={}&window={}&metric={}",
54        query.tool.as_str(),
55        query.bucket.label(),
56        query.window.label(),
57        metric.label(),
58    )
59}
60
61/// The currency to show for an optional explicit code: a valid one wins and is saved
62/// (shared with the TUI, GUI and CLI), anything else keeps the saved one.
63fn effective_currency(requested: Option<&str>) -> tokenburn_core::Currency {
64    let st = settings::load();
65    let saved = tokenburn_core::features::currency::resolve(&st.currency, &st.rates);
66    let currency = pick_currency(requested, saved, &st.rates);
67    if currency.code != saved.code {
68        let _ = settings::save_currency(currency.code);
69    }
70    currency
71}
72
73#[page("/")]
74async fn home(cx: &Cx) -> Result<impl View> {
75    let params = query_params::<UsageParams>(cx)?;
76    let interactive = is_interactive(cx);
77    let metric = parse_metric(params.metric.as_deref());
78
79    // An explicit, valid `?theme=` wins and is remembered (shared with the TUI and GUI).
80    let saved = settings::theme_index();
81    let theme_index = theme::resolve(params.theme.as_deref(), saved);
82    if theme_index != saved {
83        let _ = settings::save_theme(theme_index);
84    }
85
86    // Likewise `?currency=`: explicit and valid wins and is remembered.
87    let currency = effective_currency(params.currency.as_deref());
88
89    // Static fallback loads once here; the live path loads inside the shard.
90    let selection = params.selection();
91    let snapshot = if interactive {
92        None
93    } else {
94        Some(load(params.to_query(), &selection, currency).await)
95    };
96    let base = snapshot
97        .as_ref()
98        .map(|d| page_base(&d.query, metric))
99        .unwrap_or_default();
100
101    // Initial control values come from the URL; signals take over afterwards.
102    let tool = params.tool.clone().unwrap_or_else(|| "all".into());
103    let bucket = params.bucket.clone().unwrap_or_else(|| "total".into());
104    let window = params.window.clone().unwrap_or_else(|| "auto".into());
105
106    Ok(view! {
107        document(interactive: interactive, theme: theme_index,
108            if let Some(d) = &snapshot {
109                <header class="top">
110                    <h1>"🔥 TokenBurn"</h1>
111                    filters(query: d.query, theme: theme_index, metric: metric, currency: currency)
112                </header>
113                dashboard_body(d: d, live: false, metric: metric, base: &base, click_bucket: None, click_project: None)
114            } else {
115                <header class="top"><h1>"🔥 TokenBurn"</h1></header>
116                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())
117            }
118        )
119    })
120}
121
122/// The live dashboard. Filter values are signals; because this shard reads
123/// them on the server, changing a select re-renders only this shard. While a
124/// connection is open the data region re-reads the logs every few seconds.
125#[shard]
126async fn live_dashboard(
127    cx: &Cx,
128    tool: String,
129    bucket: String,
130    window: String,
131    theme: String,
132    metric: String,
133    currency: String,
134    sel_bucket: String,
135    sel_project: String,
136) -> Result<impl View> {
137    let tool = signal(cx, move || tool);
138    let bucket = signal(cx, move || bucket);
139    let window = signal(cx, move || window);
140    let theme = signal(cx, move || theme);
141    let metric = signal(cx, move || metric);
142    let currency_sig = signal(cx, move || currency);
143    // What the user clicked (empty = nothing). Untrusted like every signal: the
144    // core only ever *compares* these against real labels, and drops stale ones.
145    let sel_bucket = signal(cx, move || sel_bucket);
146    let sel_project = signal(cx, move || sel_project);
147
148    // Signal values come back from the browser: treat them as untrusted.
149    // `parse` ignores anything that is not a known choice.
150    let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
151    let chart_metric = parse_metric(Some(&metric.get()));
152    // Signal values are untrusted: only a known code counts, and a change is remembered.
153    let currency = effective_currency(Some(&currency_sig.get()));
154    let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
155    // Same for the theme; an unknown name keeps the saved one. A change is remembered.
156    let saved = settings::theme_index();
157    let theme_index = theme::resolve(Some(&theme.get()), saved);
158    if theme_index != saved {
159        let _ = settings::save_theme(theme_index);
160    }
161
162    Ok(view! {
163        // Re-emitted on every theme change; later in the document than the <head> one.
164        theme_style(index: theme_index)
165        live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: &currency_sig)
166        (live! {
167            loop {
168                let d = load(query, &selection, currency).await;
169                let is_connected = connected(cx);
170                let token = emit! { dashboard_body(d: &d, live: is_connected, metric: chart_metric, base: "", click_bucket: Some(&sel_bucket), click_project: Some(&sel_project)) }?;
171                if !is_connected {
172                    // HTTP render: emit once and finish.
173                    break Ok(token);
174                }
175                tokio::time::sleep(REFRESH_EVERY).await;
176            }
177        })
178    })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use tokenburn_core::{Bucket, Metric, Query, ToolFilter, Window};
185
186    #[test]
187    fn the_link_base_carries_the_view_but_not_the_theme_or_a_selection() {
188        let q = Query {
189            tool: ToolFilter::PI,
190            bucket: Bucket::Day,
191            window: Window::All,
192        };
193        assert_eq!(
194            page_base(&q, Metric::Cost),
195            "/?tool=pi&bucket=daily&window=all&metric=cost"
196        );
197        assert!(!page_base(&q, Metric::Tokens).contains("theme"));
198    }
199}