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 <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
54pub 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
67fn 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 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 let currency = effective_currency(params.currency.as_deref());
94
95 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 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
128fn 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#[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 let sel_bucket = signal(cx, move || sel_bucket);
219 let sel_project = signal(cx, move || sel_project);
220
221 let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
224 let chart_metric = parse_metric(Some(&metric.get()));
225 let currency = effective_currency(Some(¤cy_sig.get()));
227 let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
228 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 theme_style(index: theme_index)
238 live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: ¤cy_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 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}