frontend/pages/utilities.rs
1use std::collections::HashMap;
2
3use chrono::{DateTime, Datelike, Utc};
4use gloo_net::http::Request;
5use serde::Deserialize;
6use wasm_bindgen_futures::spawn_local;
7use yew::prelude::*;
8
9use crate::pages::ticket::{ActiveUser, Ticket};
10
11/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
12///
13/// This struct is used to efficiently retrieve and process ticket data for diagnostics
14/// without fetching the full ticket details.
15///
16/// # Fields
17/// - `date`: The creation date and time of the ticket in UTC.
18/// - `room`: The room number associated with the ticket.
19#[derive(Debug, Deserialize, Clone, PartialEq)]
20struct TicketPartial {
21 date: DateTime<Utc>,
22 room: i16,
23}
24
25/// Properties for components that display room-wise ticket totals.
26///
27/// This struct passes a list of partial ticket data to a component responsible
28/// for calculating and visualizing ticket distribution per room.
29///
30/// # Fields
31/// - `tickets`: A vector of [`TicketPartial`] containing ticket data relevant for room totals.
32#[derive(Properties, PartialEq)]
33struct RoomTotalsProps {
34 tickets: Vec<TicketPartial>,
35}
36
37/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
38///
39/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
40/// to a 0-indexed system (Monday = 0, Sunday = 6).
41///
42/// # Arguments
43/// - `dt`: A reference to a `DateTime<Utc>` object.
44///
45/// # Returns
46/// A `usize` representing the weekday index (0 for Monday, ..., 6 for Sunday).
47fn weekday_index(dt: &DateTime<Utc>) -> usize {
48 // chrono::Weekday: Mon = 1 ... Sun = 7
49 match dt.weekday() {
50 chrono::Weekday::Mon => 0,
51 chrono::Weekday::Tue => 1,
52 chrono::Weekday::Wed => 2,
53 chrono::Weekday::Thu => 3,
54 chrono::Weekday::Fri => 4,
55 chrono::Weekday::Sat => 5,
56 chrono::Weekday::Sun => 6,
57 }
58}
59
60/// Counts the number of tickets submitted on each day of the week.
61///
62/// This function takes a slice of `TicketPartial` and returns an array where each
63/// element corresponds to a day of the week (Monday=0, Sunday=6) and contains
64/// the total count of tickets submitted on that day.
65///
66/// # Arguments
67/// - `tickets`: A slice of [`TicketPartial`] items to count.
68///
69/// # Returns
70/// An array `[usize; 7]` with ticket counts for each weekday.
71fn count_by_weekday(tickets: &[TicketPartial]) -> [usize; 7] {
72 let mut counts = [0usize; 7];
73 for t in tickets {
74 counts[weekday_index(&t.date)] += 1;
75 }
76 counts
77}
78
79/// Calculates the occurrences of each weekday within the date range covered by the tickets.
80///
81/// This function determines the minimum and maximum dates from the provided [`TicketPartial`]
82/// slice and then counts how many times each weekday occurs within that inclusive date range.
83/// This is useful for normalizing ticket counts against the number of available days for each weekday.
84///
85/// # Arguments
86/// - `partials`: A slice of [`TicketPartial`] items defining the date range.
87///
88/// # Returns
89/// An array `[usize; 7]` where each element represents the number of times a
90/// specific weekday (Monday=0, ..., Sunday=6) appears in the date range.
91fn day_counts(partials: &[TicketPartial]) -> [usize; 7] {
92 if partials.is_empty() {
93 return [0usize; 7];
94 }
95
96 let mut min = partials[0].date.date_naive();
97 let mut max = min;
98 for d in partials.iter().skip(1) {
99 let dt = d.date.date_naive();
100 if dt < min {
101 min = dt
102 }
103 if dt > max {
104 max = dt
105 }
106 }
107
108 let total_days = (max - min).num_days() + 1;
109 if total_days <= 0 {
110 return [0usize; 7];
111 }
112
113 let mut occ = [0usize; 7];
114 let mut current = min;
115 for _ in 0..total_days {
116 let wd = current.weekday();
117 let idx = match wd {
118 chrono::Weekday::Mon => 0,
119 chrono::Weekday::Tue => 1,
120 chrono::Weekday::Wed => 2,
121 chrono::Weekday::Thu => 3,
122 chrono::Weekday::Fri => 4,
123 chrono::Weekday::Sat => 5,
124 chrono::Weekday::Sun => 6,
125 };
126 occ[idx] += 1;
127 current = current + chrono::Duration::days(1);
128 }
129
130 occ
131}
132
133/// Converts a numerical room representation back into a human-readable string format.
134///
135/// This function is the inverse of the room parsing logic in
136/// [`SubmitTicket`](`crate::pages::ticket::SubmitTicket`) component.
137/// It converts negative numbers back to "K" prefixed rooms, numbers >= 1000 back to "D" prefixed rooms,
138/// and other numbers to their string representation.
139///
140/// # Arguments
141/// - `r`: An `i16` representing the internal numerical representation of a room.
142///
143/// # Returns
144/// A `String` containing the formatted room number (e.g., "K1", "D1", "101").
145fn parse_room(r: i16) -> String {
146 if r < 0 {
147 format!("K{}", r.abs())
148 } else if r > 1000 {
149 format!("D{}", r - 1000)
150 } else {
151 r.to_string()
152 }
153}
154
155/// The main diagnostics dashboard component.
156///
157/// This component serves as a container for various statistical and diagnostic
158/// views related to the application's tickets.
159///
160/// # Components Rendered
161/// - [`TicketCount`]: Displays the count of open tickets.
162/// - [`SubmitStats`]: Displays statistics related to ticket submissions (e.g., by weekday, by room).
163///
164/// # Example
165/// ```rust
166/// html! {
167/// <Diagnostics />
168/// }
169/// ```
170#[component(Diagnostics)]
171pub fn diagnostics_component() -> Html {
172 html! {
173 <div class="rundbg">
174 <TicketCount/>
175 <SubmitStats/>
176 </div>
177 }
178}
179
180/// A component that displays the count of currently open (ToDo or InProgress) tickets.
181///
182/// This component fetches all tickets and the current user's details. It then filters
183/// the tickets to count only those that are "ToDo" or "InProgress" and are either
184/// created by the current user (for non-admins) or all tickets (for admins).
185///
186/// # State
187/// Uses `use_state` hooks to manage:
188/// - `tickets`: A vector of `Ticket` structs for all fetched tickets.
189/// - `error`: Any error message from API calls.
190/// - `loading`: A boolean indicating if data is being fetched.
191/// - `user`: The `ActiveUser` details for conditional filtering.
192///
193/// # Functionality
194/// - Fetches all tickets from `/api/tickets`.
195/// - Fetches current user details from `/api/users/current`.
196/// - Filters tickets by status ("ToDo" or "InProgress") and user ID (if not admin).
197/// - Displays the count of matching tickets.
198///
199/// # Example
200/// ```rust
201/// html! {
202/// <TicketCount />
203/// }
204/// ```
205#[component(TicketCount)]
206pub fn ticket_count_component() -> Html {
207 let tickets = use_state(|| Vec::<Ticket>::new());
208 let error = use_state(|| None::<String>);
209 let loading = use_state(|| false);
210 let user = use_state(|| ActiveUser {
211 id: None,
212 is_admin: false,
213 });
214
215 {
216 let tickets = tickets.clone();
217 let error = error.clone();
218 let loading = loading.clone();
219
220 use_effect_with((), move |_| {
221 loading.set(true);
222 spawn_local(async move {
223 let url = format!("/api/tickets");
224 match Request::get(&url).send().await {
225 Ok(response) if response.status() == 200 => {
226 match response.json::<Vec<Ticket>>().await {
227 Ok(t) => tickets.set(t),
228 Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
229 }
230 }
231 Ok(response) => {
232 if let Ok(text) = response.text().await {
233 error.set(Some(text));
234 } else {
235 error.set(Some(format!("Status {}", response.status())));
236 }
237 }
238 Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
239 }
240 loading.set(false);
241 });
242 || ()
243 });
244 }
245
246 {
247 let user = user.clone();
248 use_effect_with((), move |_| {
249 let user = user.clone();
250 spawn_local(async move {
251 if let Ok(response) = Request::get("/api/users/current")
252 .credentials(web_sys::RequestCredentials::Include)
253 .send()
254 .await
255 {
256 if response.status() == 200 {
257 if let Ok(json) = response.json::<serde_json::Value>().await {
258 let id = json
259 .get("data")
260 .and_then(|d| d.get("id"))
261 .and_then(|v| v.as_i64())
262 .and_then(|n| i16::try_from(n).ok());
263 let is_admin = json
264 .get("data")
265 .and_then(|d| d.get("is_admin"))
266 .and_then(|v| v.as_bool())
267 .unwrap_or(false);
268 user.set(ActiveUser { id, is_admin });
269 }
270 }
271 }
272 });
273 || ()
274 });
275 }
276
277 if *loading {
278 html! {<p>{ "Wird geladen" }</p>}
279 } else if let Some(e) = &*error {
280 html! { <p>{ format!("Fehler: {}", e) }</p> }
281 } else {
282 let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress";
283 let count = tickets
284 .iter()
285 .filter(|t| {
286 status_conditions(t)
287 && (user.is_admin || user.id.map_or(false, |uid| t.user_id == uid))
288 })
289 .count();
290 html! {
291 <div class="ticket-count">
292 <h2>{ "Offene Tickets" }</h2>
293 <h4>{ count }</h4>
294 </div>
295 }
296 }
297}
298
299/// A component that displays various statistics about ticket submissions.
300///
301/// This component fetches all tickets (in a partial format for efficiency)
302/// and calculates statistics such as tickets per weekday and tickets per room.
303/// It renders these statistics visually using simple bar charts.
304///
305/// # State
306/// Uses `use_state` hooks to manage:
307/// - `tickets`: A vector of [`TicketPartial`] for statistical analysis.
308/// - `error`: Any error message from API calls.
309/// - `loading`: A boolean indicating if data is being fetched.
310///
311/// # Functionality
312/// - Fetches all tickets (as [`TicketPartial`]) from `/api/tickets`.
313/// - Calculates:
314/// - `counts`: Number of tickets submitted on each weekday.
315/// - `occ`: Number of occurrences of each weekday in the ticket date range.
316/// - `avg`: Average number of tickets per day for each weekday, normalized by `occ`.
317/// - Renders a bar chart for average tickets per weekday.
318/// - Renders a [`RoomTotalTickets`] component to display tickets per room.
319///
320/// # Example
321/// ```rust
322/// html! {
323/// <SubmitStats />
324/// }
325/// ```
326#[component(SubmitStats)]
327pub fn submit_stats_component() -> Html {
328 let tickets = use_state(|| Vec::<TicketPartial>::new());
329 let error = use_state(|| None::<String>);
330 let loading = use_state(|| false);
331
332 {
333 let tickets = tickets.clone();
334 let error = error.clone();
335 let loading = loading.clone();
336
337 use_effect_with((), move |_| {
338 loading.set(true);
339 spawn_local(async move {
340 let url = "/api/tickets".to_string();
341 match Request::get(&url).send().await {
342 Ok(response) if response.status() == 200 => {
343 match response.json::<Vec<TicketPartial>>().await {
344 Ok(t) => tickets.set(t),
345 Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
346 }
347 }
348 Ok(response) => {
349 if let Ok(text) = response.text().await {
350 error.set(Some(text));
351 } else {
352 error.set(Some(format!("Status {}", response.status())));
353 }
354 }
355 Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
356 }
357 loading.set(false);
358 });
359 || ()
360 });
361 }
362
363 let counts = count_by_weekday(&tickets);
364 let occ = day_counts(&tickets);
365
366 let mut avg = [0.0f64; 7];
367 for i in 0..7 {
368 if occ[i] > 0 {
369 avg[i] = counts[i] as f64 / occ[i] as f64;
370 } else {
371 avg[i] = 0.0;
372 }
373 }
374
375 let weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
376 let (max_idx, _max_val) = counts
377 .iter()
378 .enumerate()
379 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
380 .map(|(i, _)| (i, ()))
381 .unwrap_or((0, ()));
382
383 html! {
384 <div class="diagnostics-section">
385 if *loading {
386 <p>{ "Wird geladen..." }</p>
387 }
388 if let Some(e) = &*error {
389 <p style="color: red;">{ e.clone() }</p>
390 }
391 <h3>{ "Tickets pro Wochentag" }</h3>
392 <div class="weekday-chart">
393 <div class="weekday-bars">
394 { for (0..7).map(|i| {
395 let is_max = i == max_idx;
396 let max_value = counts.iter().cloned().max().unwrap_or(1).max(1);
397 let bar_height_percent = if max_value > 0 { (avg[i] / max_value as f64) * 100.0 } else { 0.0 };
398 let bar_height_px = (bar_height_percent * 200.0 / 100.0) as i32;
399 html! {
400 <div class="weekday-bar">
401 <div class={format!("bar{}", if is_max { " max" } else { "" })} style={format!("height: {}px;", bar_height_px)}>
402 </div>
403 </div>
404 }
405 })}
406 </div>
407 <div class="weekday-labels">
408 { for (0..7).map(|i| {
409 html! {
410 <div class="label-item">
411 <div class="day">{ weekdays[i] }</div>
412 <div class="value">{ format!("{:.1}", avg[i]) }</div>
413 </div>
414 }
415 })}
416 </div>
417 </div>
418 <RoomTotalTickets tickets={(*tickets).clone()}/>
419 </div>
420 }
421}
422
423/// A component that displays the total number of tickets per room.
424///
425/// This component takes a list of [`TicketPartial`] items and calculates the
426/// total number of tickets for each room. It then displays these totals
427/// in a sorted list with a bar chart visualization.
428///
429/// # Props
430/// - `tickets`: A `Vec<TicketPartial>` containing the partial ticket data for analysis.
431///
432/// # Functionality
433/// - **Calculates Totals**: Aggregates ticket counts for each unique room.
434/// - **Sorts Results**: Displays rooms sorted by ticket count in descending order.
435/// - **Visualizes Data**: Renders a bar chart where the width of each bar is
436/// proportional to the ticket count for that room, relative to the room with the maximum tickets.
437/// - **Room Formatting**: Uses the [`parse_room`] function to display room numbers
438/// in a human-readable format.
439///
440/// # Example
441/// ```rust
442/// html! {
443/// <RoomTotalTickets tickets={my_ticket_partials} />
444/// }
445/// ```
446#[component(RoomTotalTickets)]
447fn room_total_component(props: &RoomTotalsProps) -> Html {
448 let mut totals: HashMap<i16, usize> = HashMap::new();
449 for t in &props.tickets {
450 *totals.entry(t.room).or_insert(0) += 1;
451 }
452
453 let mut totals_vec: Vec<(i16, usize)> = totals.into_iter().collect();
454 totals_vec.sort_by(|a, b| b.1.cmp(&a.1));
455
456 let max_count = totals_vec.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
457
458 html! {
459 <div class="diagnostics-section">
460 <h3>{ "Tickets pro Raum" }</h3>
461 <div class="room-chart">
462 { for totals_vec.into_iter().map(|(room, count)| {
463 let label = parse_room(room);
464 let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
465 html! {
466 <div class="room-bar-item">
467 <div class="room-header">
468 <span class="room-label">{ label }</span>
469 <span class="room-count">{ count }</span>
470 </div>
471 <div class="room-bar-container">
472 <div class="room-bar" style={format!("width: {}%;", bar_width_percent)}>
473 </div>
474 </div>
475 </div>
476 }
477 }) }
478 </div>
479 </div>
480 }
481}