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}