skill_web/pages/analytics.rs
1//! Analytics Dashboard Page - Visualize search and feedback data
2//!
3//! Features:
4//! - Overview statistics (searches, feedback, latency)
5//! - Top queries with feedback counts
6//! - Recent search history
7//! - Feedback statistics by type and result
8//! - Time range selector (7, 30, 90 days)
9
10use std::rc::Rc;
11use wasm_bindgen_futures::spawn_local;
12use yew::prelude::*;
13
14use crate::api::analytics::{
15 AnalyticsOverviewResponse, FeedbackStatsResponse, TopQueriesResponse,
16};
17use crate::api::Api;
18use crate::components::card::Card;
19use crate::components::use_notifications;
20
21#[derive(Clone, PartialEq)]
22enum TimeRange {
23 Week,
24 Month,
25 Quarter,
26}
27
28impl TimeRange {
29 fn to_days(&self) -> u32 {
30 match self {
31 TimeRange::Week => 7,
32 TimeRange::Month => 30,
33 TimeRange::Quarter => 90,
34 }
35 }
36
37 fn label(&self) -> &'static str {
38 match self {
39 TimeRange::Week => "Last 7 Days",
40 TimeRange::Month => "Last 30 Days",
41 TimeRange::Quarter => "Last 90 Days",
42 }
43 }
44}
45
46#[function_component(AnalyticsPage)]
47pub fn analytics_page() -> Html {
48 // State
49 let time_range = use_state(|| TimeRange::Month);
50 let overview = use_state(|| None::<AnalyticsOverviewResponse>);
51 let top_queries = use_state(|| None::<TopQueriesResponse>);
52 let feedback_stats = use_state(|| None::<FeedbackStatsResponse>);
53 let is_loading = use_state(|| false);
54
55 // API & notifications
56 let api = use_memo((), |_| Rc::new(Api::new()));
57 let notifications = use_notifications();
58
59 // Load data effect
60 {
61 let api = api.clone();
62 let time_range = time_range.clone();
63 let overview = overview.clone();
64 let top_queries = top_queries.clone();
65 let feedback_stats = feedback_stats.clone();
66 let is_loading = is_loading.clone();
67 let notifications = notifications.clone();
68
69 use_effect_with((*time_range).clone(), move |range| {
70 let days = range.to_days();
71 is_loading.set(true);
72
73 let api = api.clone();
74 let overview = overview.clone();
75 let top_queries = top_queries.clone();
76 let feedback_stats = feedback_stats.clone();
77 let is_loading = is_loading.clone();
78 let notifications = notifications.clone();
79
80 spawn_local(async move {
81 // Load all analytics data sequentially (WASM doesn't have tokio::try_join!)
82 match api.analytics.get_overview(days).await {
83 Ok(ov) => overview.set(Some(ov)),
84 Err(e) => {
85 notifications.error("Failed to load overview", format!("Error: {}", e));
86 is_loading.set(false);
87 return;
88 }
89 }
90
91 match api.analytics.get_top_queries(10, days).await {
92 Ok(tq) => top_queries.set(Some(tq)),
93 Err(e) => {
94 notifications.error("Failed to load top queries", format!("Error: {}", e));
95 }
96 }
97
98 match api.analytics.get_feedback_stats(days).await {
99 Ok(fs) => feedback_stats.set(Some(fs)),
100 Err(e) => {
101 notifications.error("Failed to load feedback stats", format!("Error: {}", e));
102 }
103 }
104
105 is_loading.set(false);
106 });
107
108 || ()
109 });
110 }
111
112 // Time range selector callback
113 let on_range_change = time_range.clone();
114
115 html! {
116 <div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
117 <div class="max-w-7xl mx-auto space-y-6">
118 // Header
119 <div class="flex items-center justify-between">
120 <div>
121 <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">
122 { "Search Analytics" }
123 </h1>
124 <p class="text-sm text-gray-600 dark:text-gray-400">
125 { "Monitor search performance and user feedback" }
126 </p>
127 </div>
128
129 // Time range selector
130 <div class="flex gap-2">
131 {[TimeRange::Week, TimeRange::Month, TimeRange::Quarter].iter().map(|range| {
132 let is_active = &*time_range == range;
133 let range_clone = range.clone();
134 let time_range_setter = on_range_change.clone();
135 let on_click = Callback::from(move |_: web_sys::MouseEvent| {
136 time_range_setter.set(range_clone.clone());
137 });
138
139 html! {
140 <button
141 class={classes!(
142 "px-4", "py-2", "rounded-lg", "text-sm", "font-medium", "transition-colors",
143 if is_active {
144 "bg-primary-500 text-white"
145 } else {
146 "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
147 }
148 )}
149 onclick={on_click}
150 >
151 { range.label() }
152 </button>
153 }
154 }).collect::<Html>()}
155 </div>
156 </div>
157
158 if *is_loading {
159 <div class="flex items-center justify-center py-12">
160 <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
161 </div>
162 } else {
163 <>
164 // Overview Cards
165 if let Some(ov) = &*overview {
166 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
167 // Total Searches
168 <Card>
169 <div class="p-6">
170 <div class="flex items-center justify-between mb-2">
171 <p class="text-sm font-medium text-gray-600 dark:text-gray-400">
172 { "Total Searches" }
173 </p>
174 <svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
175 <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
176 </svg>
177 </div>
178 <p class="text-3xl font-bold text-gray-900 dark:text-white">
179 { ov.total_searches }
180 </p>
181 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
182 { format!("Avg {:.1} results", ov.avg_results) }
183 </p>
184 </div>
185 </Card>
186
187 // Total Feedback
188 <Card>
189 <div class="p-6">
190 <div class="flex items-center justify-between mb-2">
191 <p class="text-sm font-medium text-gray-600 dark:text-gray-400">
192 { "Total Feedback" }
193 </p>
194 <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
195 <path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
196 </svg>
197 </div>
198 <p class="text-3xl font-bold text-gray-900 dark:text-white">
199 { ov.total_feedback }
200 </p>
201 <p class="text-xs text-green-600 dark:text-green-400 mt-1">
202 { format!("{} positive", ov.positive_feedback) }
203 </p>
204 </div>
205 </Card>
206
207 // Average Latency
208 <Card>
209 <div class="p-6">
210 <div class="flex items-center justify-between mb-2">
211 <p class="text-sm font-medium text-gray-600 dark:text-gray-400">
212 { "Avg Latency" }
213 </p>
214 <svg class="w-5 h-5 text-purple-500" fill="currentColor" viewBox="0 0 20 20">
215 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
216 </svg>
217 </div>
218 <p class="text-3xl font-bold text-gray-900 dark:text-white">
219 { format!("{:.0}ms", ov.avg_latency_ms) }
220 </p>
221 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
222 { "Response time" }
223 </p>
224 </div>
225 </Card>
226
227 // Feedback Rate
228 <Card>
229 <div class="p-6">
230 <div class="flex items-center justify-between mb-2">
231 <p class="text-sm font-medium text-gray-600 dark:text-gray-400">
232 { "Feedback Rate" }
233 </p>
234 <svg class="w-5 h-5 text-orange-500" fill="currentColor" viewBox="0 0 20 20">
235 <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
236 </svg>
237 </div>
238 <p class="text-3xl font-bold text-gray-900 dark:text-white">
239 {
240 if ov.total_searches > 0 {
241 format!("{:.1}%", (ov.total_feedback as f64 / ov.total_searches as f64) * 100.0)
242 } else {
243 "0%".to_string()
244 }
245 }
246 </p>
247 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
248 { "Of searches" }
249 </p>
250 </div>
251 </Card>
252 </div>
253 }
254
255 <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
256 // Top Queries
257 <Card title="Top Queries">
258 if let Some(tq) = &*top_queries {
259 if tq.queries.is_empty() {
260 <div class="text-center py-8 text-gray-500 dark:text-gray-400">
261 <p>{ "No queries yet" }</p>
262 </div>
263 } else {
264 <div class="space-y-3">
265 { for tq.queries.iter().enumerate().map(|(idx, query)| {
266 let positive_pct = if query.count > 0 {
267 (query.positive_feedback as f64 / query.count as f64) * 100.0
268 } else {
269 0.0
270 };
271
272 html! {
273 <div class="p-4 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
274 <div class="flex items-start justify-between mb-2">
275 <div class="flex items-center gap-2 flex-1">
276 <span class="flex-shrink-0 w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 flex items-center justify-center text-xs font-bold">
277 { idx + 1 }
278 </span>
279 <p class="font-medium text-gray-900 dark:text-white truncate">
280 { &query.query }
281 </p>
282 </div>
283 <span class="flex-shrink-0 px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
284 { format!("{}x", query.count) }
285 </span>
286 </div>
287
288 <div class="grid grid-cols-3 gap-4 text-xs">
289 <div>
290 <span class="text-gray-600 dark:text-gray-400">{ "Results:" }</span>
291 <span class="ml-1 font-medium text-gray-900 dark:text-white">
292 { format!("{:.1}", query.avg_results) }
293 </span>
294 </div>
295 <div>
296 <span class="text-gray-600 dark:text-gray-400">{ "Latency:" }</span>
297 <span class="ml-1 font-medium text-gray-900 dark:text-white">
298 { format!("{:.0}ms", query.avg_latency_ms) }
299 </span>
300 </div>
301 <div>
302 <span class="text-gray-600 dark:text-gray-400">{ "Positive:" }</span>
303 <span class="ml-1 font-medium text-green-600 dark:text-green-400">
304 { format!("{:.0}%", positive_pct) }
305 </span>
306 </div>
307 </div>
308 </div>
309 }
310 }) }
311 </div>
312 }
313 }
314 </Card>
315
316 // Recent Searches
317 <Card title="Recent Searches">
318 if let Some(ov) = &*overview {
319 if ov.recent_searches.is_empty() {
320 <div class="text-center py-8 text-gray-500 dark:text-gray-400">
321 <p>{ "No recent searches" }</p>
322 </div>
323 } else {
324 <div class="space-y-2">
325 { for ov.recent_searches.iter().map(|search| {
326 let timestamp = search.timestamp.format("%Y-%m-%d %H:%M").to_string();
327
328 html! {
329 <div class="p-3 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
330 <div class="flex items-center justify-between mb-1">
331 <p class="text-sm font-medium text-gray-900 dark:text-white truncate">
332 { &search.query }
333 </p>
334 <span class="text-xs text-gray-500 dark:text-gray-400 ml-2">
335 { timestamp }
336 </span>
337 </div>
338 <div class="flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
339 <span>{ format!("{} results", search.results_count) }</span>
340 <span>{ format!("{}ms", search.duration_ms) }</span>
341 </div>
342 </div>
343 }
344 }) }
345 </div>
346 }
347 }
348 </Card>
349 </div>
350
351 // Feedback Statistics
352 if let Some(fs) = &*feedback_stats {
353 <Card title="Feedback Statistics">
354 <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
355 // Top Positive Results
356 <div>
357 <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
358 { "Top Positive Results" }
359 </h4>
360 if fs.top_positive.is_empty() {
361 <p class="text-sm text-gray-500 dark:text-gray-400">{ "No positive feedback yet" }</p>
362 } else {
363 <div class="space-y-2">
364 { for fs.top_positive.iter().take(5).map(|result| {
365 html! {
366 <div class="flex items-center justify-between p-2 bg-green-50 dark:bg-green-900/20 rounded">
367 <span class="text-sm text-gray-900 dark:text-white truncate">
368 { &result.result_id }
369 </span>
370 <span class="text-sm font-medium text-green-600 dark:text-green-400">
371 { format!("+{}", result.positive_count) }
372 </span>
373 </div>
374 }
375 }) }
376 </div>
377 }
378 </div>
379
380 // Top Negative Results
381 <div>
382 <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
383 { "Top Negative Results" }
384 </h4>
385 if fs.top_negative.is_empty() {
386 <p class="text-sm text-gray-500 dark:text-gray-400">{ "No negative feedback yet" }</p>
387 } else {
388 <div class="space-y-2">
389 { for fs.top_negative.iter().take(5).map(|result| {
390 html! {
391 <div class="flex items-center justify-between p-2 bg-red-50 dark:bg-red-900/20 rounded">
392 <span class="text-sm text-gray-900 dark:text-white truncate">
393 { &result.result_id }
394 </span>
395 <span class="text-sm font-medium text-red-600 dark:text-red-400">
396 { format!("-{}", result.negative_count) }
397 </span>
398 </div>
399 }
400 }) }
401 </div>
402 }
403 </div>
404 </div>
405 </Card>
406 }
407 </>
408 }
409 </div>
410 </div>
411 }
412}