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},
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
61#[page("/")]
62async fn home(cx: &Cx) -> Result<impl View> {
63 let params = query_params::<UsageParams>(cx)?;
64 let interactive = is_interactive(cx);
65 let metric = parse_metric(params.metric.as_deref());
66
67 let saved = settings::theme_index();
69 let theme_index = theme::resolve(params.theme.as_deref(), saved);
70 if theme_index != saved {
71 let _ = settings::save_theme(theme_index);
72 }
73
74 let selection = params.selection();
76 let snapshot = if interactive {
77 None
78 } else {
79 Some(load(params.to_query(), &selection).await)
80 };
81 let base = snapshot
82 .as_ref()
83 .map(|d| page_base(&d.query, metric))
84 .unwrap_or_default();
85
86 let tool = params.tool.clone().unwrap_or_else(|| "all".into());
88 let bucket = params.bucket.clone().unwrap_or_else(|| "total".into());
89 let window = params.window.clone().unwrap_or_else(|| "auto".into());
90
91 Ok(view! {
92 document(interactive: interactive, theme: theme_index,
93 if let Some(d) = &snapshot {
94 <header class="top">
95 <h1>"🔥 TokenBurn"</h1>
96 filters(query: d.query, theme: theme_index, metric: metric)
97 </header>
98 dashboard_body(d: d, live: false, metric: metric, base: &base, click_bucket: None, click_project: None)
99 } else {
100 <header class="top"><h1>"🔥 TokenBurn"</h1></header>
101 live_dashboard(tool: tool, bucket: bucket, window: window, theme: theme::names()[theme_index].to_string(), metric: metric.label().to_string(), sel_bucket: selection.bucket.clone().unwrap_or_default(), sel_project: selection.project.clone().unwrap_or_default())
102 }
103 )
104 })
105}
106
107#[shard]
111async fn live_dashboard(
112 cx: &Cx,
113 tool: String,
114 bucket: String,
115 window: String,
116 theme: String,
117 metric: String,
118 sel_bucket: String,
119 sel_project: String,
120) -> Result<impl View> {
121 let tool = signal(cx, move || tool);
122 let bucket = signal(cx, move || bucket);
123 let window = signal(cx, move || window);
124 let theme = signal(cx, move || theme);
125 let metric = signal(cx, move || metric);
126 let sel_bucket = signal(cx, move || sel_bucket);
129 let sel_project = signal(cx, move || sel_project);
130
131 let query = parse(Some(&tool.get()), Some(&bucket.get()), Some(&window.get()));
134 let chart_metric = parse_metric(Some(&metric.get()));
135 let selection = parse_selection(Some(&sel_bucket.get()), Some(&sel_project.get()));
136 let saved = settings::theme_index();
138 let theme_index = theme::resolve(Some(&theme.get()), saved);
139 if theme_index != saved {
140 let _ = settings::save_theme(theme_index);
141 }
142
143 Ok(view! {
144 theme_style(index: theme_index)
146 live_filters(tool: &tool, bucket: &bucket, window: &window, theme: &theme, metric: &metric)
147 (live! {
148 loop {
149 let d = load(query, &selection).await;
150 let is_connected = connected(cx);
151 let token = emit! { dashboard_body(d: &d, live: is_connected, metric: chart_metric, base: "", click_bucket: Some(&sel_bucket), click_project: Some(&sel_project)) }?;
152 if !is_connected {
153 break Ok(token);
155 }
156 tokio::time::sleep(REFRESH_EVERY).await;
157 }
158 })
159 })
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use tokenburn_core::{Bucket, Metric, Query, ToolFilter, Window};
166
167 #[test]
168 fn the_link_base_carries_the_view_but_not_the_theme_or_a_selection() {
169 let q = Query {
170 tool: ToolFilter::PI,
171 bucket: Bucket::Day,
172 window: Window::All,
173 };
174 assert_eq!(
175 page_base(&q, Metric::Cost),
176 "/?tool=pi&bucket=daily&window=all&metric=cost"
177 );
178 assert!(!page_base(&q, Metric::Tokens).contains("theme"));
179 }
180}