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::{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(interactive: bool, theme: usize, child: Child<'_>) -> Result<impl View> {
24 Ok(view! {
25 <!DOCTYPE html>
26 <html lang="en">
27 <head>
28 <meta charset="utf-8">
29 <meta name="viewport" content="width=device-width, initial-scale=1">
30 if !interactive {
31 <meta http-equiv="refresh" content="10">
33 }
34 <title>"TokenBurn — AI token usage"</title>
35 <link rel="stylesheet" href="/style.css">
36 theme_style(index: theme)
38 topcoat::dev::script()
39 if interactive {
40 topcoat::runtime::script()
41 }
42 </head>
43 <body><main>(child)</main></body>
44 </html>
45 })
46}
47
48pub fn page_base(query: &tokenburn_core::Query, metric: tokenburn_core::Metric) -> String {
52 format!(
53 "/?tool={}&bucket={}&window={}&metric={}",
54 query.tool.as_str(),
55 query.bucket.label(),
56 query.window.label(),
57 metric.label(),
58 )
59}
60
61fn effective_currency(requested: Option<&str>) -> tokenburn_core::Currency {
64 let st = settings::load();
65 let saved = tokenburn_core::features::currency::resolve(&st.currency, &st.rates);
66 let currency = pick_currency(requested, saved, &st.rates);
67 if currency.code != saved.code {
68 let _ = settings::save_currency(currency.code);
69 }
70 currency
71}
72
73#[page("/")]
74async fn home(cx: &Cx) -> Result<impl View> {
75 let params = query_params::<UsageParams>(cx)?;
76 let interactive = is_interactive(cx);
77 let metric = parse_metric(params.metric.as_deref());
78
79 let saved = settings::theme_index();
81 let theme_index = theme::resolve(params.theme.as_deref(), saved);
82 if theme_index != saved {
83 let _ = settings::save_theme(theme_index);
84 }
85
86 let currency = effective_currency(params.currency.as_deref());
88
89 let selection = params.selection();
91 let snapshot = if interactive {
92 None
93 } else {
94 Some(load(params.to_query(), &selection, currency).await)
95 };
96 let base = snapshot
97 .as_ref()
98 .map(|d| page_base(&d.query, metric))
99 .unwrap_or_default();
100
101 let tool = params.tool.clone().unwrap_or_else(|| "all".into());
103 let bucket = params.bucket.clone().unwrap_or_else(|| "total".into());
104 let window = params.window.clone().unwrap_or_else(|| "auto".into());
105
106 Ok(view! {
107 document(interactive: interactive, theme: theme_index,
108 if let Some(d) = &snapshot {
109 <header class="top">
110 <h1>"🔥 TokenBurn"</h1>
111 filters(query: d.query, theme: theme_index, metric: metric, currency: currency)
112 </header>
113 dashboard_body(d: d, live: false, metric: metric, base: &base, click_bucket: None, click_project: None)
114 } else {
115 <header class="top"><h1>"🔥 TokenBurn"</h1></header>
116 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())
117 }
118 )
119 })
120}
121
122#[shard]
126async fn live_dashboard(
127 cx: &Cx,
128 tool: String,
129 bucket: String,
130 window: String,
131 theme: String,
132 metric: String,
133 currency: String,
134 sel_bucket: String,
135 sel_project: String,
136) -> Result<impl View> {
137 let tool = signal(cx, move || tool);
138 let bucket = signal(cx, move || bucket);
139 let window = signal(cx, move || window);
140 let theme = signal(cx, move || theme);
141 let metric = signal(cx, move || metric);
142 let currency_sig = signal(cx, move || currency);
143 let sel_bucket = signal(cx, move || sel_bucket);
146 let sel_project = signal(cx, move || sel_project);
147
148 let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
151 let chart_metric = parse_metric(Some(&metric.get()));
152 let currency = effective_currency(Some(¤cy_sig.get()));
154 let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
155 let saved = settings::theme_index();
157 let theme_index = theme::resolve(Some(&theme.get()), saved);
158 if theme_index != saved {
159 let _ = settings::save_theme(theme_index);
160 }
161
162 Ok(view! {
163 theme_style(index: theme_index)
165 live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric, currency: ¤cy_sig)
166 (live! {
167 loop {
168 let d = load(query, &selection, currency).await;
169 let is_connected = connected(cx);
170 let token = emit! { dashboard_body(d: &d, live: is_connected, metric: chart_metric, base: "", click_bucket: Some(&sel_bucket), click_project: Some(&sel_project)) }?;
171 if !is_connected {
172 break Ok(token);
174 }
175 tokio::time::sleep(REFRESH_EVERY).await;
176 }
177 })
178 })
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use tokenburn_core::{Bucket, Metric, Query, ToolFilter, Window};
185
186 #[test]
187 fn the_link_base_carries_the_view_but_not_the_theme_or_a_selection() {
188 let q = Query {
189 tool: ToolFilter::PI,
190 bucket: Bucket::Day,
191 window: Window::All,
192 };
193 assert_eq!(
194 page_base(&q, Metric::Cost),
195 "/?tool=pi&bucket=daily&window=all&metric=cost"
196 );
197 assert!(!page_base(&q, Metric::Tokens).contains("theme"));
198 }
199}