1use 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#[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 <meta http-equiv="refresh" content="10">
38 }
39 <title>(title)</title>
40 <link rel="stylesheet" href="/style.css">
41 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
53pub 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
66fn 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 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 let currency = effective_currency(params.currency.as_deref());
93
94 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 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
127fn 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#[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 let sel_bucket = signal(cx, move || sel_bucket);
218 let sel_project = signal(cx, move || sel_project);
219
220 let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
223 let chart_metric = parse_metric(Some(&metric.get()));
224 let currency = effective_currency(Some(¤cy_sig.get()));
226 let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
227 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 theme_style(index: theme_index)
237 live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: ¤cy_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 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}