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::{brand, 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(
24    interactive: bool,
25    theme: usize,
26    title: String,
27    child: Child<'_>,
28) -> Result<impl View> {
29    Ok(view! {
30        <!DOCTYPE html>
31        <html lang="en">
32            <head>
33                <meta charset="utf-8">
34                <meta name="viewport" content="width=device-width, initial-scale=1">
35                if !interactive {
36                    // Static fallback: no runtime, so reload every 10 s (the live mode pushes updates).
37                    <meta http-equiv="refresh" content="10">
38                }
39                <title>(title)</title>
40                <link rel="stylesheet" href="/style.css">
41                // The theme's CSS variables override the stylesheet's defaults.
42                theme_style(index: theme)
43                topcoat::dev::script()
44                if interactive {
45                    topcoat::runtime::script()
46                }
47            </head>
48            <body><main>(child)</main></body>
49        </html>
50    })
51}
52
53/// The dashboard URL for `query`/`metric` *without* a selection — what the static
54/// page's bar and slice links build on. The theme is left out on purpose: the saved
55/// one applies, so following a link never re-asserts (and re-saves) a stale choice.
56pub fn page_base(query: &tokenburn_core::Query, metric: tokenburn_core::Metric) -> String {
57    format!(
58        "/?tool={}&bucket={}&window={}&metric={}",
59        query.tool.as_str(),
60        query.bucket.label(),
61        query.window.label(),
62        metric.label(),
63    )
64}
65
66/// The currency to show for an optional explicit code: a valid one wins and is saved
67/// (shared with the TUI, GUI and CLI), anything else keeps the saved one.
68fn effective_currency(requested: Option<&str>) -> tokenburn_core::Currency {
69    let st = settings::load();
70    let saved = tokenburn_core::features::currency::resolve(&st.currency, &st.rates);
71    let currency = pick_currency(requested, saved, &st.rates);
72    if currency.code != saved.code {
73        let _ = settings::save_currency(currency.code);
74    }
75    currency
76}
77
78#[page("/")]
79async fn home(cx: &Cx) -> Result<impl View> {
80    let params = query_params::<UsageParams>(cx)?;
81    let interactive = is_interactive(cx);
82    let metric = parse_metric(params.metric.as_deref());
83
84    // An explicit, valid `?theme=` wins and is remembered (shared with the TUI and GUI).
85    let saved = settings::theme_index();
86    let theme_index = theme::resolve(params.theme.as_deref(), saved);
87    if theme_index != saved {
88        let _ = settings::save_theme(theme_index);
89    }
90
91    // Likewise `?currency=`: explicit and valid wins and is remembered.
92    let currency = effective_currency(params.currency.as_deref());
93
94    // Static fallback loads once here; the live path loads inside the shard.
95    let selection = params.selection();
96    let snapshot = if interactive {
97        None
98    } else {
99        Some(load(params.to_query(), &selection, currency).await)
100    };
101    let base = snapshot
102        .as_ref()
103        .map(|d| page_base(&d.query, metric))
104        .unwrap_or_default();
105
106    // Initial control values come from the URL; signals take over afterwards.
107    let tool = params.tool.clone().unwrap_or_else(|| "all".into());
108    let bucket = params.bucket.clone().unwrap_or_else(|| "total".into());
109    let window = params.window.clone().unwrap_or_else(|| "auto".into());
110
111    Ok(view! {
112        document(interactive: interactive, theme: theme_index, title: format!("{} — AI token usage", tokenburn_core::appinfo::title()),
113            if let Some(d) = &snapshot {
114                <header class="top">
115                    brand(active: "dashboard".to_string())
116                    filters(query: d.query, theme: theme_index, metric: metric, currency: currency)
117                </header>
118                dashboard_body(d: d, live: false, metric: metric, base: &base, click_bucket: None, click_project: None)
119            } else {
120                <header class="top">brand(active: "dashboard".to_string())</header>
121                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())
122            }
123        )
124    })
125}
126
127/// The saved theme and currency, shared by the pages that are not the dashboard.
128fn saved_theme_and_currency() -> (usize, tokenburn_core::Currency) {
129    (settings::theme_index(), settings::currency())
130}
131
132#[page("/about")]
133async fn about_page(cx: &Cx) -> Result<impl View> {
134    let _ = cx;
135    let (theme_index, _) = saved_theme_and_currency();
136    let rows = tokenburn_core::appinfo::about_rows();
137    Ok(view! {
138        document(interactive: false, theme: theme_index, title: format!("About — {}", tokenburn_core::appinfo::title()),
139            <header class="top">brand(active: "about".to_string())</header>
140            <section>
141                <h2>(tokenburn_core::appinfo::title())</h2>
142                <p class="dim">(tokenburn_core::appinfo::TAGLINE)</p>
143                <table class="kv"><tbody>
144                    for (k, v) in rows {
145                        <tr>
146                            <th>(k)</th>
147                            <td>
148                                if tokenburn_core::appinfo::is_link(&v) {
149                                    <a href=(v.clone()) target="_blank" rel="noopener">(v.clone())</a>
150                                } else {
151                                    (v.clone())
152                                }
153                            </td>
154                        </tr>
155                    }
156                </tbody></table>
157            </section>
158        )
159    })
160}
161
162#[page("/settings")]
163async fn settings_page(cx: &Cx) -> Result<impl View> {
164    let _ = cx;
165    let (theme_index, currency) = saved_theme_and_currency();
166    Ok(view! {
167        document(interactive: false, theme: theme_index, title: format!("Settings — {}", tokenburn_core::appinfo::title()),
168            <header class="top">brand(active: "settings".to_string())</header>
169            <section>
170                <h2>"Settings"</h2>
171                <p class="dim">"Saved to " (tokenburn_core::appinfo::settings_file()) " and shared with the TUI and GUI."</p>
172                <form class="filters" method="get" action="/">
173                    <label>"currency "
174                        <select name="currency">
175                            for cur in tokenburn_core::CURRENCIES {
176                                <option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
177                            }
178                        </select>
179                    </label>
180                    <label>"theme "
181                        <select name="theme">
182                            for (i, n) in theme::names().iter().enumerate() {
183                                <option value=(*n) selected=(i == theme_index)>(*n)</option>
184                            }
185                        </select>
186                    </label>
187                    <button type="submit">"Save"</button>
188                </form>
189            </section>
190        )
191    })
192}
193
194/// The live dashboard. Filter values are signals; because this shard reads
195/// them on the server, changing a select re-renders only this shard. While a
196/// connection is open the data region re-reads the logs every few seconds.
197#[shard]
198async fn live_dashboard(
199    cx: &Cx,
200    tool: String,
201    bucket: String,
202    window: String,
203    theme: String,
204    metric: String,
205    currency: String,
206    sel_bucket: String,
207    sel_project: String,
208) -> Result<impl View> {
209    let tool = signal(cx, move || tool);
210    let bucket = signal(cx, move || bucket);
211    let window = signal(cx, move || window);
212    let theme = signal(cx, move || theme);
213    let metric = signal(cx, move || metric);
214    let currency_sig = signal(cx, move || currency);
215    // What the user clicked (empty = nothing). Untrusted like every signal: the
216    // core only ever *compares* these against real labels, and drops stale ones.
217    let sel_bucket = signal(cx, move || sel_bucket);
218    let sel_project = signal(cx, move || sel_project);
219
220    // Signal values come back from the browser: treat them as untrusted.
221    // `parse` ignores anything that is not a known choice.
222    let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
223    let chart_metric = parse_metric(Some(&metric.get()));
224    // Signal values are untrusted: only a known code counts, and a change is remembered.
225    let currency = effective_currency(Some(&currency_sig.get()));
226    let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
227    // Same for the theme; an unknown name keeps the saved one. A change is remembered.
228    let saved = settings::theme_index();
229    let theme_index = theme::resolve(Some(&theme.get()), saved);
230    if theme_index != saved {
231        let _ = settings::save_theme(theme_index);
232    }
233
234    Ok(view! {
235        // Re-emitted on every theme change; later in the document than the <head> one.
236        theme_style(index: theme_index)
237        live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: &currency_sig)
238        (live! {
239            loop {
240                let d = load(query, &selection, currency).await;
241                let is_connected = connected(cx);
242                let token = emit! { dashboard_body(d: &d, live: is_connected, metric: chart_metric, base: "", click_bucket: Some(&sel_bucket), click_project: Some(&sel_project)) }?;
243                if !is_connected {
244                    // HTTP render: emit once and finish.
245                    break Ok(token);
246                }
247                tokio::time::sleep(REFRESH_EVERY).await;
248            }
249        })
250    })
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use tokenburn_core::{Bucket, Metric, Query, ToolFilter, Window};
257
258    #[test]
259    fn the_link_base_carries_the_view_but_not_the_theme_or_a_selection() {
260        let q = Query {
261            tool: ToolFilter::PI,
262            bucket: Bucket::Day,
263            window: Window::All,
264        };
265        assert_eq!(
266            page_base(&q, Metric::Cost),
267            "/?tool=pi&bucket=daily&window=all&metric=cost"
268        );
269        assert!(!page_base(&q, Metric::Tokens).contains("theme"));
270    }
271}