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