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, apply_limit_params, load},
15    params::{LimitParams, 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 (theme_index, currency) = saved_theme_and_currency();
166    // The limits form: a submit saves, a bad number says so and keeps the old limits.
167    let submitted = query_params::<LimitParams>(cx)?;
168    let mut limits = settings::load().limits;
169    let mut message = String::new();
170    if submitted.save.is_some() {
171        match apply_limit_params(&limits, submitted) {
172            Ok(new) => {
173                let _ = settings::save_limits(&new);
174                limits = new;
175                message = "Limits saved.".to_string();
176            }
177            Err(e) => message = e,
178        }
179    }
180    let field =
181        |p: tokenburn_core::Period| limits.get(p).map(|v| v.to_string()).unwrap_or_default();
182    let (daily, weekly, monthly) = (
183        field(tokenburn_core::Period::Day),
184        field(tokenburn_core::Period::Week),
185        field(tokenburn_core::Period::Month),
186    );
187    let notify = limits.notify;
188    Ok(view! {
189        document(interactive: false, theme: theme_index, title: format!("Settings — {}", tokenburn_core::appinfo::title()),
190            <header class="top">brand(active: "settings".to_string())</header>
191            <section>
192                <h2>"Settings"</h2>
193                <p class="dim">"Saved to " (tokenburn_core::appinfo::settings_file()) " and shared with the TUI and GUI."</p>
194                <form class="filters" method="get" action="/">
195                    <label>"currency "
196                        <select name="currency">
197                            for cur in tokenburn_core::CURRENCIES {
198                                <option value=(cur.code) selected=(cur.code == currency.code)>(format!("{} · {}", cur.code, cur.name))</option>
199                            }
200                        </select>
201                    </label>
202                    <label>"theme "
203                        <select name="theme">
204                            for n in theme::names() {
205                                <option value=(n) selected=(n == theme::name(theme_index))>(n)</option>
206                            }
207                        </select>
208                    </label>
209                    <button type="submit">"Save"</button>
210                </form>
211            </section>
212            <section>
213                <h2>"Spending limits"</h2>
214                <p class="dim">"In US dollars; empty or 0 = off. Limits never block anything: you get progress bars, and a desktop notification when one is nearly reached or exceeded (checked every 30 s while this server runs)."</p>
215                if !message.is_empty() {
216                    <p class="limit-msg">(message.clone())</p>
217                }
218                <form class="filters limits-form" method="get" action="/settings">
219                    <input type="hidden" name="save" value="1">
220                    <label>"daily " <input type="text" inputmode="decimal" name="limit_daily" value=(daily.clone()) placeholder="off"></label>
221                    <label>"weekly " <input type="text" inputmode="decimal" name="limit_weekly" value=(weekly.clone()) placeholder="off"></label>
222                    <label>"monthly " <input type="text" inputmode="decimal" name="limit_monthly" value=(monthly.clone()) placeholder="off"></label>
223                    <label><input type="checkbox" name="notify" value="on" checked=(notify)> " desktop notifications"</label>
224                    <button type="submit">"Save limits"</button>
225                </form>
226            </section>
227        )
228    })
229}
230
231/// The live dashboard. Filter values are signals; because this shard reads
232/// them on the server, changing a select re-renders only this shard. While a
233/// connection is open the data region re-reads the logs every few seconds.
234#[shard]
235async fn live_dashboard(
236    cx: &Cx,
237    tool: String,
238    bucket: String,
239    window: String,
240    theme: String,
241    metric: String,
242    currency: String,
243    sel_bucket: String,
244    sel_project: String,
245) -> Result<impl View> {
246    let tool = signal(cx, move || tool);
247    let bucket = signal(cx, move || bucket);
248    let window = signal(cx, move || window);
249    let theme = signal(cx, move || theme);
250    let metric = signal(cx, move || metric);
251    let currency_sig = signal(cx, move || currency);
252    // What the user clicked (empty = nothing). Untrusted like every signal: the
253    // core only ever *compares* these against real labels, and drops stale ones.
254    let sel_bucket = signal(cx, move || sel_bucket);
255    let sel_project = signal(cx, move || sel_project);
256
257    // Signal values come back from the browser: treat them as untrusted.
258    // `parse` ignores anything that is not a known choice.
259    let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
260    let chart_metric = parse_metric(Some(&metric.get()));
261    // Signal values are untrusted: only a known code counts, and a change is remembered.
262    let currency = effective_currency(Some(&currency_sig.get()));
263    let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
264    // Same for the theme; an unknown name keeps the saved one. A change is remembered.
265    let saved = theme::saved_index();
266    let theme_index = theme::resolve(Some(&theme.get()), saved);
267    if theme_index != saved {
268        theme::save_index(theme_index);
269    }
270
271    Ok(view! {
272        // Re-emitted on every theme change; later in the document than the <head> one.
273        theme_style(index: theme_index)
274        live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: &currency_sig)
275        (live! {
276            loop {
277                let d = load(query, &selection, currency).await;
278                let is_connected = connected(cx);
279                let token = emit! { dashboard_body(d: &d, live: is_connected, metric: chart_metric, base: "", click_bucket: Some(&sel_bucket), click_project: Some(&sel_project)) }?;
280                if !is_connected {
281                    // HTTP render: emit once and finish.
282                    break Ok(token);
283                }
284                tokio::time::sleep(REFRESH_EVERY).await;
285            }
286        })
287    })
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use tokenburn_core::{Bucket, Metric, Query, ToolFilter, Window};
294
295    #[test]
296    fn the_link_base_carries_the_view_but_not_the_theme_or_a_selection() {
297        let q = Query {
298            tool: ToolFilter::PI,
299            bucket: Bucket::Day,
300            window: Window::All,
301        };
302        assert_eq!(
303            page_base(&q, Metric::Cost),
304            "/?tool=pi&bucket=daily&window=all&metric=cost"
305        );
306        assert!(!page_base(&q, Metric::Tokens).contains("theme"));
307    }
308}