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}