feat(stats): graphique « Activité de la semaine » — barres par jour, segments par personneUnverified
7 colonnes = jours de la semaine ISO (?semaine= partagé, flèches synchronisées), hauteur = total équipe du jour, segments empilés par personne dans un ordre stable (rang d'adhésion, ex-membres après). Nouvelle PERSON_PALETTE 8 teintes (OKLCH à luminosité alternée, validée CVD/contraste) + jumelle HC. Totaux compacts (3h/45m), aujourd'hui en accent, jours futurs grisés (≥70 % d'opacité, audit axe vert), sheets :target « Part du jour ». i18n FR/EN/DE. Claude-Session: https://claude.ai/code/session_01D114WAMbcTKTZShiyksS1z
a314c3e parent: 107945f modified
assets/style.css +14 -0 | @@ -433,6 +433,20 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 433 | 433 | légèrement sur la proportion visuelle pour les petits segments. */ |
| 434 | 434 | .col-bar a { display: block; min-height: 24px; width: 100%; } |
| 435 | 435 | |
| 436 | +/* — activité de la semaine (7 colonnes, segments par personne) — */ | |
| 437 | +.cols-7 { gap: var(--space-2); } | |
| 438 | +.cols-7 .col-bar { max-width: 40px; } | |
| 439 | +.cols-7 .col .user { font-size: 12px; font-weight: 500; } | |
| 440 | +/* Compromis 2.5.8 assumé : 24px × N personnes déborderait les petites | |
| 441 | + barres (overflow:hidden les tronquerait) ; le détail complet reste | |
| 442 | + accessible via la bottom sheet. */ | |
| 443 | +.cols-7 .col-bar a { min-height: 16px; } | |
| 444 | +/* ≥ 70 % : convention a11y du projet pour les textes atténués (le libellé | |
| 445 | + du jour futur est le seul contenu de la colonne). */ | |
| 446 | +.col-muted { opacity: .7; } | |
| 447 | +.col-muted .user { font-weight: 400; } | |
| 448 | +.col .user.day-today { font-weight: 700; color: var(--color-accent-deep); } | |
| 449 | + | |
| 436 | 450 | /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */ |
| 437 | 451 | .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; } |
| 438 | 452 | .sheet-wrap:target, .sheet-wrap.open { display: flex; align-items: flex-end; justify-content: center; } |
| @@ -433,6 +433,20 @@ a.team-pill:hover { filter: brightness(.96); } | |||
| 433 | légèrement sur la proportion visuelle pour les petits segments. */ | 433 | légèrement sur la proportion visuelle pour les petits segments. */ |
| 434 | .col-bar a { display: block; min-height: 24px; width: 100%; } | 434 | .col-bar a { display: block; min-height: 24px; width: 100%; } |
| 435 | 435 | ||
| 436 | +/* — activité de la semaine (7 colonnes, segments par personne) — */ | ||
| 437 | +.cols-7 { gap: var(--space-2); } | ||
| 438 | +.cols-7 .col-bar { max-width: 40px; } | ||
| 439 | +.cols-7 .col .user { font-size: 12px; font-weight: 500; } | ||
| 440 | +/* Compromis 2.5.8 assumé : 24px × N personnes déborderait les petites | ||
| 441 | + barres (overflow:hidden les tronquerait) ; le détail complet reste | ||
| 442 | + accessible via la bottom sheet. */ | ||
| 443 | +.cols-7 .col-bar a { min-height: 16px; } | ||
| 444 | +/* ≥ 70 % : convention a11y du projet pour les textes atténués (le libellé | ||
| 445 | + du jour futur est le seul contenu de la colonne). */ | ||
| 446 | +.col-muted { opacity: .7; } | ||
| 447 | +.col-muted .user { font-weight: 400; } | ||
| 448 | +.col .user.day-today { font-weight: 700; color: var(--color-accent-deep); } | ||
| 449 | + | ||
| 436 | /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */ | 450 | /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */ |
| 437 | .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; } | 451 | .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; } |
| 438 | .sheet-wrap:target, .sheet-wrap.open { display: flex; align-items: flex-end; justify-content: center; } | 452 | .sheet-wrap:target, .sheet-wrap.open { display: flex; align-items: flex-end; justify-content: center; } |
modified
src/charts.rs +321 -2 | @@ -1,10 +1,12 @@ | ||
| 1 | 1 | use std::collections::{BTreeMap, HashMap}; |
| 2 | 2 | |
| 3 | +use chrono::{Datelike, Days, NaiveDate}; | |
| 3 | 4 | use uuid::Uuid; |
| 4 | 5 | |
| 5 | -use crate::db::stats::StatRow; | |
| 6 | +use crate::db::stats::{DayStatRow, StatRow}; | |
| 6 | 7 | use crate::db::users::UserRow; |
| 7 | -use crate::i18n::Tr; | |
| 8 | +use crate::i18n::{Lang, Tr, p1}; | |
| 9 | +use crate::period::{self, Week}; | |
| 8 | 10 | |
| 9 | 11 | /// Palette du design system « Organic » — couleur par ordre de création de la |
| 10 | 12 | /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. |
| @@ -29,6 +31,40 @@ fn palette(hc: bool) -> &'static [&'static str; 15] { | ||
| 29 | 31 | if hc { &PALETTE_HC } else { &PALETTE } |
| 30 | 32 | } |
| 31 | 33 | |
| 34 | +/// Palette dédiée aux PERSONNES (graphique « Activité de la semaine »). | |
| 35 | +/// Teintes hors des familles de PALETTE (les catégories occupent oranges, | |
| 36 | +/// bruns, verts olive, or, sable, vieux rose) : bleus, sarcelle, violets, | |
| 37 | +/// baies. Construite en OKLCH à luminosité alternée entre rangs voisins et | |
| 38 | +/// validée CVD/contraste sur la surface de barre (#eee7db). | |
| 39 | +/// ⚠ Comme PALETTE : étendre uniquement par la fin. | |
| 40 | +pub const PERSON_PALETTE: [&str; 8] = [ | |
| 41 | + "#2a669f", // bleu foncé | |
| 42 | + "#d389ba", // rose-magenta clair | |
| 43 | + "#009084", // sarcelle | |
| 44 | + "#aa8dde", // violet clair | |
| 45 | + "#843b61", // baie foncée | |
| 46 | + "#4ab6c7", // azur clair | |
| 47 | + "#6a51a4", // violet foncé | |
| 48 | + "#55b395", // menthe claire | |
| 49 | +]; | |
| 50 | + | |
| 51 | +/// Jumelle contraste élevé — mêmes rangs, gris alternés clair/foncé | |
| 52 | +/// (voisins distinguables, tous visibles sur le fond de barre #1c1c1c). | |
| 53 | +pub const PERSON_PALETTE_HC: [&str; 8] = [ | |
| 54 | + "#f2f2f2", "#8c8c8c", "#d4d4d4", "#595959", "#b5b5b5", "#707070", "#e3e3e3", "#9e9e9e", | |
| 55 | +]; | |
| 56 | + | |
| 57 | +/// user_id → couleur, à partir des ids ordonnés par rang d'adhésion à | |
| 58 | +/// l'équipe (ex-membres après les membres actuels). | |
| 59 | +pub fn person_color_map(user_ids_in_rank_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { | |
| 60 | + let p: &[&'static str; 8] = if hc { &PERSON_PALETTE_HC } else { &PERSON_PALETTE }; | |
| 61 | + user_ids_in_rank_order | |
| 62 | + .iter() | |
| 63 | + .enumerate() | |
| 64 | + .map(|(i, id)| (*id, p[i % p.len()])) | |
| 65 | + .collect() | |
| 66 | +} | |
| 67 | + | |
| 32 | 68 | /// category_id → couleur, à partir de la liste des tâches triée par id (création). |
| 33 | 69 | pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { |
| 34 | 70 | let p = palette(hc); |
| @@ -56,6 +92,18 @@ pub fn fmt_minutes(total: i64) -> String { | ||
| 56 | 92 | } |
| 57 | 93 | } |
| 58 | 94 | |
| 95 | +/// Total compact au sommet des barres jour : « 45m », « 3h », « 3h30 ». | |
| 96 | +/// < 60 min → minutes exactes ; sinon arrondi à la demi-heure la plus | |
| 97 | +/// proche (égalité vers le haut) — la valeur exacte vit dans la sheet. | |
| 98 | +pub fn fmt_minutes_compact(total: i64) -> String { | |
| 99 | + if total < 60 { | |
| 100 | + return format!("{total}m"); | |
| 101 | + } | |
| 102 | + let halves = (total + 15) / 30; | |
| 103 | + let (h, half) = (halves / 2, halves % 2); | |
| 104 | + if half == 0 { format!("{h}h") } else { format!("{h}h30") } | |
| 105 | +} | |
| 106 | + | |
| 59 | 107 | pub struct Seg { |
| 60 | 108 | /// flex-grow ∝ minutes. |
| 61 | 109 | pub flex: i64, |
| @@ -194,6 +242,145 @@ pub fn build_chart( | ||
| 194 | 242 | ) |
| 195 | 243 | } |
| 196 | 244 | |
| 245 | +/// Colonne d'un jour du graphique « Activité de la semaine ». | |
| 246 | +pub struct DayCol { | |
| 247 | + /// « Lu 4 » — abréviation du jour + numéro. | |
| 248 | + pub label: String, | |
| 249 | + /// Total compact (« 3h », « 45m ») ; None = jour futur ou sans temps. | |
| 250 | + pub total: Option<String>, | |
| 251 | + pub height_pct: u32, | |
| 252 | + pub segs: Vec<Seg>, | |
| 253 | + /// Jour futur de la semaine courante : grisé, sans barre ni total. | |
| 254 | + pub muted: bool, | |
| 255 | + pub today: bool, | |
| 256 | +} | |
| 257 | + | |
| 258 | +pub struct DayChartView { | |
| 259 | + pub title: String, | |
| 260 | + pub range: String, | |
| 261 | + pub empty: bool, | |
| 262 | + pub prev: String, | |
| 263 | + pub next: Option<String>, | |
| 264 | + /// Toujours 7 entrées, lundi → dimanche. | |
| 265 | + pub cols: Vec<DayCol>, | |
| 266 | + /// Légende des personnes, dans l'ordre d'adhésion (= ordre des segments). | |
| 267 | + pub legend: Vec<LegendItem>, | |
| 268 | +} | |
| 269 | + | |
| 270 | +/// Graphique par jour : colonnes = jours de la semaine ISO, segments = | |
| 271 | +/// personnes, dans un ordre STABLE (rang d'adhésion) identique dans toutes | |
| 272 | +/// les barres — on suit la bande d'une personne à travers la semaine. | |
| 273 | +/// `today` est injecté (paris_today au call-site) pour rester testable. | |
| 274 | +pub fn build_day_chart( | |
| 275 | + week: Week, | |
| 276 | + today: NaiveDate, | |
| 277 | + users: &[UserRow], | |
| 278 | + rows: &[DayStatRow], | |
| 279 | + colors: &HashMap<Uuid, &'static str>, | |
| 280 | + nav: ChartNav, | |
| 281 | + lang: Lang, | |
| 282 | +) -> (DayChartView, Vec<Sheet>) { | |
| 283 | + let tr = lang.tr(); | |
| 284 | + let range = week.range_label(lang); | |
| 285 | + let (start, _) = week.bounds(); | |
| 286 | + let days: Vec<NaiveDate> = (0..7).map(|i| start + Days::new(i)).collect(); | |
| 287 | + | |
| 288 | + let mut by_key: HashMap<(NaiveDate, Uuid), &DayStatRow> = HashMap::new(); | |
| 289 | + for r in rows { | |
| 290 | + by_key.insert((r.entry_date, r.user_id), r); | |
| 291 | + } | |
| 292 | + | |
| 293 | + let day_totals: Vec<i64> = days | |
| 294 | + .iter() | |
| 295 | + .map(|d| { | |
| 296 | + users | |
| 297 | + .iter() | |
| 298 | + .filter_map(|u| by_key.get(&(*d, u.id))) | |
| 299 | + .map(|r| r.total) | |
| 300 | + .sum() | |
| 301 | + }) | |
| 302 | + .collect(); | |
| 303 | + // L'échelle ignore les jours futurs (toujours vides) de la semaine courante. | |
| 304 | + let max = days | |
| 305 | + .iter() | |
| 306 | + .zip(&day_totals) | |
| 307 | + .filter(|(d, _)| **d <= today) | |
| 308 | + .map(|(_, t)| *t) | |
| 309 | + .max() | |
| 310 | + .unwrap_or(0) | |
| 311 | + .max(1); | |
| 312 | + let week_total: i64 = day_totals.iter().sum(); | |
| 313 | + | |
| 314 | + let mut sheets = Vec::new(); | |
| 315 | + let cols = days | |
| 316 | + .iter() | |
| 317 | + .zip(&day_totals) | |
| 318 | + .enumerate() | |
| 319 | + .map(|(di, (day, &total))| { | |
| 320 | + let muted = *day > today; | |
| 321 | + let mut segs = Vec::new(); | |
| 322 | + if !muted { | |
| 323 | + for (ui, u) in users.iter().enumerate() { | |
| 324 | + let Some(r) = by_key.get(&(*day, u.id)) else { continue }; | |
| 325 | + let color = colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]); | |
| 326 | + let sheet_id = format!("d-jour-{di}-{ui}"); | |
| 327 | + let share = (r.total * 100) / total.max(1); | |
| 328 | + sheets.push(Sheet { | |
| 329 | + id: sheet_id.clone(), | |
| 330 | + color, | |
| 331 | + category: u.username.clone(), | |
| 332 | + who: period::day_label(lang, *day), | |
| 333 | + duration: fmt_minutes(r.total), | |
| 334 | + share: format!("{share} %"), | |
| 335 | + period: range.clone(), | |
| 336 | + count: tr.entries_count(r.entries), | |
| 337 | + }); | |
| 338 | + segs.push(Seg { | |
| 339 | + flex: r.total, | |
| 340 | + color, | |
| 341 | + aria: format!("{} — {}", u.username, fmt_minutes(r.total)), | |
| 342 | + sheet_id, | |
| 343 | + }); | |
| 344 | + } | |
| 345 | + } | |
| 346 | + DayCol { | |
| 347 | + label: format!("{} {}", tr.weekdays_short[di], day.day()), | |
| 348 | + total: (!muted && total > 0).then(|| fmt_minutes_compact(total)), | |
| 349 | + height_pct: if muted { | |
| 350 | + 0 | |
| 351 | + } else { | |
| 352 | + ((total * 100 + max - 1) / max).max(2) as u32 | |
| 353 | + }, | |
| 354 | + segs, | |
| 355 | + muted, | |
| 356 | + today: *day == today, | |
| 357 | + } | |
| 358 | + }) | |
| 359 | + .collect(); | |
| 360 | + | |
| 361 | + let legend = users | |
| 362 | + .iter() | |
| 363 | + .map(|u| LegendItem { | |
| 364 | + name: u.username.clone(), | |
| 365 | + color: colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]), | |
| 366 | + }) | |
| 367 | + .collect(); | |
| 368 | + | |
| 369 | + ( | |
| 370 | + DayChartView { | |
| 371 | + // Espaces insécables : le total ne se coupe pas en fin de titre. | |
| 372 | + title: p1(tr.week_activity_pat, fmt_minutes(week_total).replace(' ', "\u{a0}")), | |
| 373 | + range, | |
| 374 | + empty: day_totals.iter().all(|&t| t == 0), | |
| 375 | + prev: nav.prev, | |
| 376 | + next: nav.next, | |
| 377 | + cols, | |
| 378 | + legend, | |
| 379 | + }, | |
| 380 | + sheets, | |
| 381 | + ) | |
| 382 | +} | |
| 383 | + | |
| 197 | 384 | /// Légende commune : union des tâches présentes, triée par nom. |
| 198 | 385 | pub fn legend(row_sets: &[&[StatRow]], colors: &HashMap<Uuid, &'static str>) -> Vec<LegendItem> { |
| 199 | 386 | let mut seen: BTreeMap<String, &'static str> = BTreeMap::new(); |
| @@ -234,6 +421,30 @@ mod tests { | ||
| 234 | 421 | color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false) |
| 235 | 422 | } |
| 236 | 423 | |
| 424 | + fn day_row(uid: u128, date: NaiveDate, total: i64, entries: i64) -> DayStatRow { | |
| 425 | + DayStatRow { | |
| 426 | + user_id: Uuid::from_u128(uid), | |
| 427 | + entry_date: date, | |
| 428 | + total, | |
| 429 | + entries, | |
| 430 | + } | |
| 431 | + } | |
| 432 | + | |
| 433 | + fn day_setup() -> (Week, NaiveDate, [UserRow; 2], HashMap<Uuid, &'static str>) { | |
| 434 | + let week = Week::parse("2026-W12").unwrap(); | |
| 435 | + let start = week.bounds().0; | |
| 436 | + let users = [user(1, "alice"), user(2, "bob")]; | |
| 437 | + let colors = person_color_map(&[Uuid::from_u128(1), Uuid::from_u128(2)], false); | |
| 438 | + (week, start, users, colors) | |
| 439 | + } | |
| 440 | + | |
| 441 | + fn nav() -> ChartNav { | |
| 442 | + ChartNav { | |
| 443 | + prev: "p".into(), | |
| 444 | + next: None, | |
| 445 | + } | |
| 446 | + } | |
| 447 | + | |
| 237 | 448 | #[test] |
| 238 | 449 | fn fmt() { |
| 239 | 450 | assert_eq!(fmt_minutes(45), "45 min"); |
| @@ -284,6 +495,114 @@ mod tests { | ||
| 284 | 495 | assert_eq!(hc[&ids[1]], PALETTE_HC[1]); |
| 285 | 496 | } |
| 286 | 497 | |
| 498 | + #[test] | |
| 499 | + fn fmt_compact() { | |
| 500 | + assert_eq!(fmt_minutes_compact(45), "45m"); | |
| 501 | + assert_eq!(fmt_minutes_compact(59), "59m"); | |
| 502 | + assert_eq!(fmt_minutes_compact(60), "1h"); | |
| 503 | + assert_eq!(fmt_minutes_compact(74), "1h"); | |
| 504 | + assert_eq!(fmt_minutes_compact(75), "1h30"); | |
| 505 | + assert_eq!(fmt_minutes_compact(90), "1h30"); | |
| 506 | + assert_eq!(fmt_minutes_compact(105), "2h"); | |
| 507 | + assert_eq!(fmt_minutes_compact(200), "3h30"); | |
| 508 | + } | |
| 509 | + | |
| 510 | + #[test] | |
| 511 | + fn day_chart_seven_cols_scaled() { | |
| 512 | + let (week, start, users, colors) = day_setup(); | |
| 513 | + // today hors semaine (après) : aucun jour muted. | |
| 514 | + let today = start + Days::new(30); | |
| 515 | + let rows = [ | |
| 516 | + day_row(1, start, 90, 2), | |
| 517 | + day_row(2, start, 30, 1), | |
| 518 | + day_row(2, start + Days::new(3), 60, 1), | |
| 519 | + ]; | |
| 520 | + let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr); | |
| 521 | + assert_eq!(chart.cols.len(), 7); | |
| 522 | + assert_eq!(chart.cols[0].label, format!("Lu {}", start.day())); | |
| 523 | + assert_eq!(chart.cols[6].label, format!("Di {}", (start + Days::new(6)).day())); | |
| 524 | + assert_eq!(chart.cols[0].height_pct, 100); // lundi = max (120) | |
| 525 | + assert_eq!(chart.cols[3].height_pct, 50); | |
| 526 | + assert_eq!(chart.cols[1].height_pct, 2); // jour vide non futur : plancher | |
| 527 | + assert_eq!(chart.cols[1].total, None); | |
| 528 | + assert_eq!(chart.cols[0].total.as_deref(), Some("2h")); | |
| 529 | + assert!(!chart.empty); | |
| 530 | + assert!(!chart.cols.iter().any(|c| c.muted)); | |
| 531 | + // Titre = motif interpolé avec le total semaine (180 min), | |
| 532 | + // en espaces insécables pour ne pas couper la durée. | |
| 533 | + assert_eq!(chart.title, "Activité de la semaine — 3\u{a0}h"); | |
| 534 | + // Sheets : ids uniques préfixés d-jour, part du total du JOUR. | |
| 535 | + assert_eq!(sheets.len(), 3); | |
| 536 | + assert_eq!(sheets[0].id, "d-jour-0-0"); | |
| 537 | + assert_eq!(sheets[0].category, "alice"); | |
| 538 | + assert_eq!(sheets[0].share, "75 %"); | |
| 539 | + assert_eq!(sheets[0].count, "2 saisies"); | |
| 540 | + // Légende = personnes dans l'ordre du rang. | |
| 541 | + assert_eq!(chart.legend.len(), 2); | |
| 542 | + assert_eq!(chart.legend[0].name, "alice"); | |
| 543 | + assert_eq!(chart.legend[0].color, PERSON_PALETTE[0]); | |
| 544 | + } | |
| 545 | + | |
| 546 | + #[test] | |
| 547 | + fn day_chart_stable_segment_order() { | |
| 548 | + let (week, start, users, colors) = day_setup(); | |
| 549 | + let today = start + Days::new(30); | |
| 550 | + // Les parts s'inversent entre lundi et mardi : l'ordre des segments | |
| 551 | + // reste celui du rang d'adhésion, pas de la grosseur. | |
| 552 | + let rows = [ | |
| 553 | + day_row(1, start, 30, 1), | |
| 554 | + day_row(2, start, 90, 1), | |
| 555 | + day_row(1, start + Days::new(1), 120, 1), | |
| 556 | + day_row(2, start + Days::new(1), 10, 1), | |
| 557 | + ]; | |
| 558 | + let (chart, _) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr); | |
| 559 | + assert_eq!(chart.cols[0].segs[0].flex, 30); // alice d'abord, même minoritaire | |
| 560 | + assert_eq!(chart.cols[0].segs[0].color, PERSON_PALETTE[0]); | |
| 561 | + assert_eq!(chart.cols[1].segs[0].flex, 120); | |
| 562 | + assert_eq!(chart.cols[1].segs[0].color, PERSON_PALETTE[0]); | |
| 563 | + } | |
| 564 | + | |
| 565 | + #[test] | |
| 566 | + fn day_chart_future_days_muted() { | |
| 567 | + let (week, start, users, colors) = day_setup(); | |
| 568 | + let today = start + Days::new(2); // mercredi | |
| 569 | + let rows = [day_row(1, start, 100, 1)]; | |
| 570 | + let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr); | |
| 571 | + assert!(chart.cols[2].today); | |
| 572 | + for c in &chart.cols[3..] { | |
| 573 | + assert!(c.muted); | |
| 574 | + assert_eq!(c.total, None); | |
| 575 | + assert_eq!(c.height_pct, 0); | |
| 576 | + assert!(c.segs.is_empty()); | |
| 577 | + } | |
| 578 | + assert!(!chart.cols[0].muted); | |
| 579 | + assert_eq!(chart.cols[0].height_pct, 100); // le max ignore les jours futurs | |
| 580 | + assert_eq!(sheets.len(), 1); | |
| 581 | + } | |
| 582 | + | |
| 583 | + #[test] | |
| 584 | + fn day_chart_empty_week() { | |
| 585 | + let (week, start, users, colors) = day_setup(); | |
| 586 | + let today = start + Days::new(30); | |
| 587 | + let (chart, sheets) = build_day_chart(week, today, &users, &[], &colors, nav(), Lang::Fr); | |
| 588 | + assert!(chart.empty); | |
| 589 | + assert_eq!(chart.cols.len(), 7); // la grille des jours reste | |
| 590 | + assert!(chart.cols.iter().all(|c| c.total.is_none() && c.segs.is_empty())); | |
| 591 | + assert!(sheets.is_empty()); | |
| 592 | + } | |
| 593 | + | |
| 594 | + #[test] | |
| 595 | + fn person_colors_stable_and_paired() { | |
| 596 | + let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; | |
| 597 | + let m = person_color_map(&ids, false); | |
| 598 | + assert_eq!(m[&ids[0]], PERSON_PALETTE[0]); | |
| 599 | + assert_eq!(m[&ids[1]], PERSON_PALETTE[1]); | |
| 600 | + // Même rang dans les deux palettes : passer en HC ne mélange rien. | |
| 601 | + let hc = person_color_map(&ids, true); | |
| 602 | + assert_eq!(hc[&ids[0]], PERSON_PALETTE_HC[0]); | |
| 603 | + assert_eq!(hc[&ids[1]], PERSON_PALETTE_HC[1]); | |
| 604 | + } | |
| 605 | + | |
| 287 | 606 | #[test] |
| 288 | 607 | fn team_color_stable_and_in_palette() { |
| 289 | 608 | let id = Uuid::from_u128(0x1234_5678_9abc_def0); |
| @@ -1,10 +1,12 @@ | |||
| 1 | use std::collections::{BTreeMap, HashMap}; | 1 | use std::collections::{BTreeMap, HashMap}; |
| 2 | 2 | ||
| 3 | +use chrono::{Datelike, Days, NaiveDate}; | ||
| 3 | use uuid::Uuid; | 4 | use uuid::Uuid; |
| 4 | 5 | ||
| 5 | -use crate::db::stats::StatRow; | 6 | +use crate::db::stats::{DayStatRow, StatRow}; |
| 6 | use crate::db::users::UserRow; | 7 | use crate::db::users::UserRow; |
| 7 | -use crate::i18n::Tr; | 8 | +use crate::i18n::{Lang, Tr, p1}; |
| 9 | +use crate::period::{self, Week}; | ||
| 8 | 10 | ||
| 9 | /// Palette du design system « Organic » — couleur par ordre de création de la | 11 | /// Palette du design system « Organic » — couleur par ordre de création de la |
| 10 | /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. | 12 | /// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. |
| @@ -29,6 +31,40 @@ fn palette(hc: bool) -> &'static [&'static str; 15] { | |||
| 29 | if hc { &PALETTE_HC } else { &PALETTE } | 31 | if hc { &PALETTE_HC } else { &PALETTE } |
| 30 | } | 32 | } |
| 31 | 33 | ||
| 34 | +/// Palette dédiée aux PERSONNES (graphique « Activité de la semaine »). | ||
| 35 | +/// Teintes hors des familles de PALETTE (les catégories occupent oranges, | ||
| 36 | +/// bruns, verts olive, or, sable, vieux rose) : bleus, sarcelle, violets, | ||
| 37 | +/// baies. Construite en OKLCH à luminosité alternée entre rangs voisins et | ||
| 38 | +/// validée CVD/contraste sur la surface de barre (#eee7db). | ||
| 39 | +/// ⚠ Comme PALETTE : étendre uniquement par la fin. | ||
| 40 | +pub const PERSON_PALETTE: [&str; 8] = [ | ||
| 41 | + "#2a669f", // bleu foncé | ||
| 42 | + "#d389ba", // rose-magenta clair | ||
| 43 | + "#009084", // sarcelle | ||
| 44 | + "#aa8dde", // violet clair | ||
| 45 | + "#843b61", // baie foncée | ||
| 46 | + "#4ab6c7", // azur clair | ||
| 47 | + "#6a51a4", // violet foncé | ||
| 48 | + "#55b395", // menthe claire | ||
| 49 | +]; | ||
| 50 | + | ||
| 51 | +/// Jumelle contraste élevé — mêmes rangs, gris alternés clair/foncé | ||
| 52 | +/// (voisins distinguables, tous visibles sur le fond de barre #1c1c1c). | ||
| 53 | +pub const PERSON_PALETTE_HC: [&str; 8] = [ | ||
| 54 | + "#f2f2f2", "#8c8c8c", "#d4d4d4", "#595959", "#b5b5b5", "#707070", "#e3e3e3", "#9e9e9e", | ||
| 55 | +]; | ||
| 56 | + | ||
| 57 | +/// user_id → couleur, à partir des ids ordonnés par rang d'adhésion à | ||
| 58 | +/// l'équipe (ex-membres après les membres actuels). | ||
| 59 | +pub fn person_color_map(user_ids_in_rank_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { | ||
| 60 | + let p: &[&'static str; 8] = if hc { &PERSON_PALETTE_HC } else { &PERSON_PALETTE }; | ||
| 61 | + user_ids_in_rank_order | ||
| 62 | + .iter() | ||
| 63 | + .enumerate() | ||
| 64 | + .map(|(i, id)| (*id, p[i % p.len()])) | ||
| 65 | + .collect() | ||
| 66 | +} | ||
| 67 | + | ||
| 32 | /// category_id → couleur, à partir de la liste des tâches triée par id (création). | 68 | /// category_id → couleur, à partir de la liste des tâches triée par id (création). |
| 33 | pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { | 69 | pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> { |
| 34 | let p = palette(hc); | 70 | let p = palette(hc); |
| @@ -56,6 +92,18 @@ pub fn fmt_minutes(total: i64) -> String { | |||
| 56 | } | 92 | } |
| 57 | } | 93 | } |
| 58 | 94 | ||
| 95 | +/// Total compact au sommet des barres jour : « 45m », « 3h », « 3h30 ». | ||
| 96 | +/// < 60 min → minutes exactes ; sinon arrondi à la demi-heure la plus | ||
| 97 | +/// proche (égalité vers le haut) — la valeur exacte vit dans la sheet. | ||
| 98 | +pub fn fmt_minutes_compact(total: i64) -> String { | ||
| 99 | + if total < 60 { | ||
| 100 | + return format!("{total}m"); | ||
| 101 | + } | ||
| 102 | + let halves = (total + 15) / 30; | ||
| 103 | + let (h, half) = (halves / 2, halves % 2); | ||
| 104 | + if half == 0 { format!("{h}h") } else { format!("{h}h30") } | ||
| 105 | +} | ||
| 106 | + | ||
| 59 | pub struct Seg { | 107 | pub struct Seg { |
| 60 | /// flex-grow ∝ minutes. | 108 | /// flex-grow ∝ minutes. |
| 61 | pub flex: i64, | 109 | pub flex: i64, |
| @@ -194,6 +242,145 @@ pub fn build_chart( | |||
| 194 | ) | 242 | ) |
| 195 | } | 243 | } |
| 196 | 244 | ||
| 245 | +/// Colonne d'un jour du graphique « Activité de la semaine ». | ||
| 246 | +pub struct DayCol { | ||
| 247 | + /// « Lu 4 » — abréviation du jour + numéro. | ||
| 248 | + pub label: String, | ||
| 249 | + /// Total compact (« 3h », « 45m ») ; None = jour futur ou sans temps. | ||
| 250 | + pub total: Option<String>, | ||
| 251 | + pub height_pct: u32, | ||
| 252 | + pub segs: Vec<Seg>, | ||
| 253 | + /// Jour futur de la semaine courante : grisé, sans barre ni total. | ||
| 254 | + pub muted: bool, | ||
| 255 | + pub today: bool, | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +pub struct DayChartView { | ||
| 259 | + pub title: String, | ||
| 260 | + pub range: String, | ||
| 261 | + pub empty: bool, | ||
| 262 | + pub prev: String, | ||
| 263 | + pub next: Option<String>, | ||
| 264 | + /// Toujours 7 entrées, lundi → dimanche. | ||
| 265 | + pub cols: Vec<DayCol>, | ||
| 266 | + /// Légende des personnes, dans l'ordre d'adhésion (= ordre des segments). | ||
| 267 | + pub legend: Vec<LegendItem>, | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +/// Graphique par jour : colonnes = jours de la semaine ISO, segments = | ||
| 271 | +/// personnes, dans un ordre STABLE (rang d'adhésion) identique dans toutes | ||
| 272 | +/// les barres — on suit la bande d'une personne à travers la semaine. | ||
| 273 | +/// `today` est injecté (paris_today au call-site) pour rester testable. | ||
| 274 | +pub fn build_day_chart( | ||
| 275 | + week: Week, | ||
| 276 | + today: NaiveDate, | ||
| 277 | + users: &[UserRow], | ||
| 278 | + rows: &[DayStatRow], | ||
| 279 | + colors: &HashMap<Uuid, &'static str>, | ||
| 280 | + nav: ChartNav, | ||
| 281 | + lang: Lang, | ||
| 282 | +) -> (DayChartView, Vec<Sheet>) { | ||
| 283 | + let tr = lang.tr(); | ||
| 284 | + let range = week.range_label(lang); | ||
| 285 | + let (start, _) = week.bounds(); | ||
| 286 | + let days: Vec<NaiveDate> = (0..7).map(|i| start + Days::new(i)).collect(); | ||
| 287 | + | ||
| 288 | + let mut by_key: HashMap<(NaiveDate, Uuid), &DayStatRow> = HashMap::new(); | ||
| 289 | + for r in rows { | ||
| 290 | + by_key.insert((r.entry_date, r.user_id), r); | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + let day_totals: Vec<i64> = days | ||
| 294 | + .iter() | ||
| 295 | + .map(|d| { | ||
| 296 | + users | ||
| 297 | + .iter() | ||
| 298 | + .filter_map(|u| by_key.get(&(*d, u.id))) | ||
| 299 | + .map(|r| r.total) | ||
| 300 | + .sum() | ||
| 301 | + }) | ||
| 302 | + .collect(); | ||
| 303 | + // L'échelle ignore les jours futurs (toujours vides) de la semaine courante. | ||
| 304 | + let max = days | ||
| 305 | + .iter() | ||
| 306 | + .zip(&day_totals) | ||
| 307 | + .filter(|(d, _)| **d <= today) | ||
| 308 | + .map(|(_, t)| *t) | ||
| 309 | + .max() | ||
| 310 | + .unwrap_or(0) | ||
| 311 | + .max(1); | ||
| 312 | + let week_total: i64 = day_totals.iter().sum(); | ||
| 313 | + | ||
| 314 | + let mut sheets = Vec::new(); | ||
| 315 | + let cols = days | ||
| 316 | + .iter() | ||
| 317 | + .zip(&day_totals) | ||
| 318 | + .enumerate() | ||
| 319 | + .map(|(di, (day, &total))| { | ||
| 320 | + let muted = *day > today; | ||
| 321 | + let mut segs = Vec::new(); | ||
| 322 | + if !muted { | ||
| 323 | + for (ui, u) in users.iter().enumerate() { | ||
| 324 | + let Some(r) = by_key.get(&(*day, u.id)) else { continue }; | ||
| 325 | + let color = colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]); | ||
| 326 | + let sheet_id = format!("d-jour-{di}-{ui}"); | ||
| 327 | + let share = (r.total * 100) / total.max(1); | ||
| 328 | + sheets.push(Sheet { | ||
| 329 | + id: sheet_id.clone(), | ||
| 330 | + color, | ||
| 331 | + category: u.username.clone(), | ||
| 332 | + who: period::day_label(lang, *day), | ||
| 333 | + duration: fmt_minutes(r.total), | ||
| 334 | + share: format!("{share} %"), | ||
| 335 | + period: range.clone(), | ||
| 336 | + count: tr.entries_count(r.entries), | ||
| 337 | + }); | ||
| 338 | + segs.push(Seg { | ||
| 339 | + flex: r.total, | ||
| 340 | + color, | ||
| 341 | + aria: format!("{} — {}", u.username, fmt_minutes(r.total)), | ||
| 342 | + sheet_id, | ||
| 343 | + }); | ||
| 344 | + } | ||
| 345 | + } | ||
| 346 | + DayCol { | ||
| 347 | + label: format!("{} {}", tr.weekdays_short[di], day.day()), | ||
| 348 | + total: (!muted && total > 0).then(|| fmt_minutes_compact(total)), | ||
| 349 | + height_pct: if muted { | ||
| 350 | + 0 | ||
| 351 | + } else { | ||
| 352 | + ((total * 100 + max - 1) / max).max(2) as u32 | ||
| 353 | + }, | ||
| 354 | + segs, | ||
| 355 | + muted, | ||
| 356 | + today: *day == today, | ||
| 357 | + } | ||
| 358 | + }) | ||
| 359 | + .collect(); | ||
| 360 | + | ||
| 361 | + let legend = users | ||
| 362 | + .iter() | ||
| 363 | + .map(|u| LegendItem { | ||
| 364 | + name: u.username.clone(), | ||
| 365 | + color: colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]), | ||
| 366 | + }) | ||
| 367 | + .collect(); | ||
| 368 | + | ||
| 369 | + ( | ||
| 370 | + DayChartView { | ||
| 371 | + // Espaces insécables : le total ne se coupe pas en fin de titre. | ||
| 372 | + title: p1(tr.week_activity_pat, fmt_minutes(week_total).replace(' ', "\u{a0}")), | ||
| 373 | + range, | ||
| 374 | + empty: day_totals.iter().all(|&t| t == 0), | ||
| 375 | + prev: nav.prev, | ||
| 376 | + next: nav.next, | ||
| 377 | + cols, | ||
| 378 | + legend, | ||
| 379 | + }, | ||
| 380 | + sheets, | ||
| 381 | + ) | ||
| 382 | +} | ||
| 383 | + | ||
| 197 | /// Légende commune : union des tâches présentes, triée par nom. | 384 | /// Légende commune : union des tâches présentes, triée par nom. |
| 198 | pub fn legend(row_sets: &[&[StatRow]], colors: &HashMap<Uuid, &'static str>) -> Vec<LegendItem> { | 385 | pub fn legend(row_sets: &[&[StatRow]], colors: &HashMap<Uuid, &'static str>) -> Vec<LegendItem> { |
| 199 | let mut seen: BTreeMap<String, &'static str> = BTreeMap::new(); | 386 | let mut seen: BTreeMap<String, &'static str> = BTreeMap::new(); |
| @@ -234,6 +421,30 @@ mod tests { | |||
| 234 | color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false) | 421 | color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false) |
| 235 | } | 422 | } |
| 236 | 423 | ||
| 424 | + fn day_row(uid: u128, date: NaiveDate, total: i64, entries: i64) -> DayStatRow { | ||
| 425 | + DayStatRow { | ||
| 426 | + user_id: Uuid::from_u128(uid), | ||
| 427 | + entry_date: date, | ||
| 428 | + total, | ||
| 429 | + entries, | ||
| 430 | + } | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + fn day_setup() -> (Week, NaiveDate, [UserRow; 2], HashMap<Uuid, &'static str>) { | ||
| 434 | + let week = Week::parse("2026-W12").unwrap(); | ||
| 435 | + let start = week.bounds().0; | ||
| 436 | + let users = [user(1, "alice"), user(2, "bob")]; | ||
| 437 | + let colors = person_color_map(&[Uuid::from_u128(1), Uuid::from_u128(2)], false); | ||
| 438 | + (week, start, users, colors) | ||
| 439 | + } | ||
| 440 | + | ||
| 441 | + fn nav() -> ChartNav { | ||
| 442 | + ChartNav { | ||
| 443 | + prev: "p".into(), | ||
| 444 | + next: None, | ||
| 445 | + } | ||
| 446 | + } | ||
| 447 | + | ||
| 237 | #[test] | 448 | #[test] |
| 238 | fn fmt() { | 449 | fn fmt() { |
| 239 | assert_eq!(fmt_minutes(45), "45 min"); | 450 | assert_eq!(fmt_minutes(45), "45 min"); |
| @@ -284,6 +495,114 @@ mod tests { | |||
| 284 | assert_eq!(hc[&ids[1]], PALETTE_HC[1]); | 495 | assert_eq!(hc[&ids[1]], PALETTE_HC[1]); |
| 285 | } | 496 | } |
| 286 | 497 | ||
| 498 | + #[test] | ||
| 499 | + fn fmt_compact() { | ||
| 500 | + assert_eq!(fmt_minutes_compact(45), "45m"); | ||
| 501 | + assert_eq!(fmt_minutes_compact(59), "59m"); | ||
| 502 | + assert_eq!(fmt_minutes_compact(60), "1h"); | ||
| 503 | + assert_eq!(fmt_minutes_compact(74), "1h"); | ||
| 504 | + assert_eq!(fmt_minutes_compact(75), "1h30"); | ||
| 505 | + assert_eq!(fmt_minutes_compact(90), "1h30"); | ||
| 506 | + assert_eq!(fmt_minutes_compact(105), "2h"); | ||
| 507 | + assert_eq!(fmt_minutes_compact(200), "3h30"); | ||
| 508 | + } | ||
| 509 | + | ||
| 510 | + #[test] | ||
| 511 | + fn day_chart_seven_cols_scaled() { | ||
| 512 | + let (week, start, users, colors) = day_setup(); | ||
| 513 | + // today hors semaine (après) : aucun jour muted. | ||
| 514 | + let today = start + Days::new(30); | ||
| 515 | + let rows = [ | ||
| 516 | + day_row(1, start, 90, 2), | ||
| 517 | + day_row(2, start, 30, 1), | ||
| 518 | + day_row(2, start + Days::new(3), 60, 1), | ||
| 519 | + ]; | ||
| 520 | + let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr); | ||
| 521 | + assert_eq!(chart.cols.len(), 7); | ||
| 522 | + assert_eq!(chart.cols[0].label, format!("Lu {}", start.day())); | ||
| 523 | + assert_eq!(chart.cols[6].label, format!("Di {}", (start + Days::new(6)).day())); | ||
| 524 | + assert_eq!(chart.cols[0].height_pct, 100); // lundi = max (120) | ||
| 525 | + assert_eq!(chart.cols[3].height_pct, 50); | ||
| 526 | + assert_eq!(chart.cols[1].height_pct, 2); // jour vide non futur : plancher | ||
| 527 | + assert_eq!(chart.cols[1].total, None); | ||
| 528 | + assert_eq!(chart.cols[0].total.as_deref(), Some("2h")); | ||
| 529 | + assert!(!chart.empty); | ||
| 530 | + assert!(!chart.cols.iter().any(|c| c.muted)); | ||
| 531 | + // Titre = motif interpolé avec le total semaine (180 min), | ||
| 532 | + // en espaces insécables pour ne pas couper la durée. | ||
| 533 | + assert_eq!(chart.title, "Activité de la semaine — 3\u{a0}h"); | ||
| 534 | + // Sheets : ids uniques préfixés d-jour, part du total du JOUR. | ||
| 535 | + assert_eq!(sheets.len(), 3); | ||
| 536 | + assert_eq!(sheets[0].id, "d-jour-0-0"); | ||
| 537 | + assert_eq!(sheets[0].category, "alice"); | ||
| 538 | + assert_eq!(sheets[0].share, "75 %"); | ||
| 539 | + assert_eq!(sheets[0].count, "2 saisies"); | ||
| 540 | + // Légende = personnes dans l'ordre du rang. | ||
| 541 | + assert_eq!(chart.legend.len(), 2); | ||
| 542 | + assert_eq!(chart.legend[0].name, "alice"); | ||
| 543 | + assert_eq!(chart.legend[0].color, PERSON_PALETTE[0]); | ||
| 544 | + } | ||
| 545 | + | ||
| 546 | + #[test] | ||
| 547 | + fn day_chart_stable_segment_order() { | ||
| 548 | + let (week, start, users, colors) = day_setup(); | ||
| 549 | + let today = start + Days::new(30); | ||
| 550 | + // Les parts s'inversent entre lundi et mardi : l'ordre des segments | ||
| 551 | + // reste celui du rang d'adhésion, pas de la grosseur. | ||
| 552 | + let rows = [ | ||
| 553 | + day_row(1, start, 30, 1), | ||
| 554 | + day_row(2, start, 90, 1), | ||
| 555 | + day_row(1, start + Days::new(1), 120, 1), | ||
| 556 | + day_row(2, start + Days::new(1), 10, 1), | ||
| 557 | + ]; | ||
| 558 | + let (chart, _) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr); | ||
| 559 | + assert_eq!(chart.cols[0].segs[0].flex, 30); // alice d'abord, même minoritaire | ||
| 560 | + assert_eq!(chart.cols[0].segs[0].color, PERSON_PALETTE[0]); | ||
| 561 | + assert_eq!(chart.cols[1].segs[0].flex, 120); | ||
| 562 | + assert_eq!(chart.cols[1].segs[0].color, PERSON_PALETTE[0]); | ||
| 563 | + } | ||
| 564 | + | ||
| 565 | + #[test] | ||
| 566 | + fn day_chart_future_days_muted() { | ||
| 567 | + let (week, start, users, colors) = day_setup(); | ||
| 568 | + let today = start + Days::new(2); // mercredi | ||
| 569 | + let rows = [day_row(1, start, 100, 1)]; | ||
| 570 | + let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr); | ||
| 571 | + assert!(chart.cols[2].today); | ||
| 572 | + for c in &chart.cols[3..] { | ||
| 573 | + assert!(c.muted); | ||
| 574 | + assert_eq!(c.total, None); | ||
| 575 | + assert_eq!(c.height_pct, 0); | ||
| 576 | + assert!(c.segs.is_empty()); | ||
| 577 | + } | ||
| 578 | + assert!(!chart.cols[0].muted); | ||
| 579 | + assert_eq!(chart.cols[0].height_pct, 100); // le max ignore les jours futurs | ||
| 580 | + assert_eq!(sheets.len(), 1); | ||
| 581 | + } | ||
| 582 | + | ||
| 583 | + #[test] | ||
| 584 | + fn day_chart_empty_week() { | ||
| 585 | + let (week, start, users, colors) = day_setup(); | ||
| 586 | + let today = start + Days::new(30); | ||
| 587 | + let (chart, sheets) = build_day_chart(week, today, &users, &[], &colors, nav(), Lang::Fr); | ||
| 588 | + assert!(chart.empty); | ||
| 589 | + assert_eq!(chart.cols.len(), 7); // la grille des jours reste | ||
| 590 | + assert!(chart.cols.iter().all(|c| c.total.is_none() && c.segs.is_empty())); | ||
| 591 | + assert!(sheets.is_empty()); | ||
| 592 | + } | ||
| 593 | + | ||
| 594 | + #[test] | ||
| 595 | + fn person_colors_stable_and_paired() { | ||
| 596 | + let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; | ||
| 597 | + let m = person_color_map(&ids, false); | ||
| 598 | + assert_eq!(m[&ids[0]], PERSON_PALETTE[0]); | ||
| 599 | + assert_eq!(m[&ids[1]], PERSON_PALETTE[1]); | ||
| 600 | + // Même rang dans les deux palettes : passer en HC ne mélange rien. | ||
| 601 | + let hc = person_color_map(&ids, true); | ||
| 602 | + assert_eq!(hc[&ids[0]], PERSON_PALETTE_HC[0]); | ||
| 603 | + assert_eq!(hc[&ids[1]], PERSON_PALETTE_HC[1]); | ||
| 604 | + } | ||
| 605 | + | ||
| 287 | #[test] | 606 | #[test] |
| 288 | fn team_color_stable_and_in_palette() { | 607 | fn team_color_stable_and_in_palette() { |
| 289 | let id = Uuid::from_u128(0x1234_5678_9abc_def0); | 608 | let id = Uuid::from_u128(0x1234_5678_9abc_def0); |
modified
src/db/stats.rs +35 -0 | @@ -14,6 +14,41 @@ pub struct StatRow { | ||
| 14 | 14 | pub entries: i64, |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | +/// Total d'un utilisateur sur un jour (graphique « Activité de la semaine »). | |
| 18 | +/// Pas de username : le nom vient de la liste des membres classés par | |
| 19 | +/// adhésion (les ex-membres de la semaine sont forcément dans `totals`). | |
| 20 | +#[derive(sqlx::FromRow)] | |
| 21 | +pub struct DayStatRow { | |
| 22 | + pub user_id: Uuid, | |
| 23 | + pub entry_date: NaiveDate, | |
| 24 | + pub total: i64, | |
| 25 | + pub entries: i64, | |
| 26 | +} | |
| 27 | + | |
| 28 | +/// Totaux par jour × utilisateur sur [from, to). Le JOIN categories rattache | |
| 29 | +/// à l'équipe : les temps des ex-membres et des catégories archivées restent | |
| 30 | +/// inclus, comme dans `totals`. | |
| 31 | +pub async fn totals_by_day( | |
| 32 | + pool: &PgPool, | |
| 33 | + team_id: Uuid, | |
| 34 | + from: NaiveDate, | |
| 35 | + to: NaiveDate, | |
| 36 | +) -> sqlx::Result<Vec<DayStatRow>> { | |
| 37 | + sqlx::query_as( | |
| 38 | + "SELECT e.user_id, e.entry_date, | |
| 39 | + SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries | |
| 40 | + FROM time_entries e | |
| 41 | + JOIN categories t ON t.id = e.category_id | |
| 42 | + WHERE t.team_id = $1 AND e.entry_date >= $2 AND e.entry_date < $3 | |
| 43 | + GROUP BY e.user_id, e.entry_date", | |
| 44 | + ) | |
| 45 | + .bind(team_id) | |
| 46 | + .bind(from) | |
| 47 | + .bind(to) | |
| 48 | + .fetch_all(pool) | |
| 49 | + .await | |
| 50 | +} | |
| 51 | + | |
| 17 | 52 | /// Totaux par utilisateur × catégorie de l'équipe sur [from, to) ; |
| 18 | 53 | /// bornes NULL = depuis le début. Les catégories archivées restent incluses, |
| 19 | 54 | /// et les temps des ex-membres aussi (l'équipe se déduit de la catégorie). |
| @@ -14,6 +14,41 @@ pub struct StatRow { | |||
| 14 | pub entries: i64, | 14 | pub entries: i64, |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | +/// Total d'un utilisateur sur un jour (graphique « Activité de la semaine »). | ||
| 18 | +/// Pas de username : le nom vient de la liste des membres classés par | ||
| 19 | +/// adhésion (les ex-membres de la semaine sont forcément dans `totals`). | ||
| 20 | +#[derive(sqlx::FromRow)] | ||
| 21 | +pub struct DayStatRow { | ||
| 22 | + pub user_id: Uuid, | ||
| 23 | + pub entry_date: NaiveDate, | ||
| 24 | + pub total: i64, | ||
| 25 | + pub entries: i64, | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +/// Totaux par jour × utilisateur sur [from, to). Le JOIN categories rattache | ||
| 29 | +/// à l'équipe : les temps des ex-membres et des catégories archivées restent | ||
| 30 | +/// inclus, comme dans `totals`. | ||
| 31 | +pub async fn totals_by_day( | ||
| 32 | + pool: &PgPool, | ||
| 33 | + team_id: Uuid, | ||
| 34 | + from: NaiveDate, | ||
| 35 | + to: NaiveDate, | ||
| 36 | +) -> sqlx::Result<Vec<DayStatRow>> { | ||
| 37 | + sqlx::query_as( | ||
| 38 | + "SELECT e.user_id, e.entry_date, | ||
| 39 | + SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries | ||
| 40 | + FROM time_entries e | ||
| 41 | + JOIN categories t ON t.id = e.category_id | ||
| 42 | + WHERE t.team_id = $1 AND e.entry_date >= $2 AND e.entry_date < $3 | ||
| 43 | + GROUP BY e.user_id, e.entry_date", | ||
| 44 | + ) | ||
| 45 | + .bind(team_id) | ||
| 46 | + .bind(from) | ||
| 47 | + .bind(to) | ||
| 48 | + .fetch_all(pool) | ||
| 49 | + .await | ||
| 50 | +} | ||
| 51 | + | ||
| 17 | /// Totaux par utilisateur × catégorie de l'équipe sur [from, to) ; | 52 | /// Totaux par utilisateur × catégorie de l'équipe sur [from, to) ; |
| 18 | /// bornes NULL = depuis le début. Les catégories archivées restent incluses, | 53 | /// bornes NULL = depuis le début. Les catégories archivées restent incluses, |
| 19 | /// et les temps des ex-membres aussi (l'équipe se déduit de la catégorie). | 54 | /// et les temps des ex-membres aussi (l'équipe se déduit de la catégorie). |
modified
src/db/teams.rs +14 -0 | @@ -132,3 +132,17 @@ pub async fn members(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<UserRow>> | ||
| 132 | 132 | .fetch_all(pool) |
| 133 | 133 | .await |
| 134 | 134 | } |
| 135 | + | |
| 136 | +/// Membres par ancienneté d'adhésion — rang de couleur du graphique | |
| 137 | +/// « Activité de la semaine » (les ex-membres sont classés après, côté appelant). | |
| 138 | +pub async fn members_by_joined(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<UserRow>> { | |
| 139 | + sqlx::query_as( | |
| 140 | + "SELECT u.id, u.username FROM users u | |
| 141 | + JOIN team_members m ON m.user_id = u.id | |
| 142 | + WHERE m.team_id = $1 | |
| 143 | + ORDER BY m.joined_at, u.id", | |
| 144 | + ) | |
| 145 | + .bind(team_id) | |
| 146 | + .fetch_all(pool) | |
| 147 | + .await | |
| 148 | +} | |
| @@ -132,3 +132,17 @@ pub async fn members(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<UserRow>> | |||
| 132 | .fetch_all(pool) | 132 | .fetch_all(pool) |
| 133 | .await | 133 | .await |
| 134 | } | 134 | } |
| 135 | + | ||
| 136 | +/// Membres par ancienneté d'adhésion — rang de couleur du graphique | ||
| 137 | +/// « Activité de la semaine » (les ex-membres sont classés après, côté appelant). | ||
| 138 | +pub async fn members_by_joined(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<UserRow>> { | ||
| 139 | + sqlx::query_as( | ||
| 140 | + "SELECT u.id, u.username FROM users u | ||
| 141 | + JOIN team_members m ON m.user_id = u.id | ||
| 142 | + WHERE m.team_id = $1 | ||
| 143 | + ORDER BY m.joined_at, u.id", | ||
| 144 | + ) | ||
| 145 | + .bind(team_id) | ||
| 146 | + .fetch_all(pool) | ||
| 147 | + .await | ||
| 148 | +} | ||
modified
src/i18n.rs +18 -0 | @@ -131,6 +131,10 @@ pub struct Tr { | ||
| 131 | 131 | pub detail: &'static str, |
| 132 | 132 | pub entry_one: &'static str, |
| 133 | 133 | pub entry_many: &'static str, |
| 134 | + /// Titre du graphique par jour, {} = total équipe de la semaine. | |
| 135 | + pub week_activity_pat: &'static str, | |
| 136 | + pub no_time_week: &'static str, | |
| 137 | + pub share_of_day: &'static str, | |
| 134 | 138 | // ─── mes temps / édition ─── |
| 135 | 139 | pub title_times: &'static str, |
| 136 | 140 | pub times_empty: &'static str, |
| @@ -282,6 +286,8 @@ pub struct Tr { | ||
| 282 | 286 | pub back_home: &'static str, |
| 283 | 287 | // ─── dates ─── |
| 284 | 288 | pub weekdays: [&'static str; 7], |
| 289 | + /// Abréviations à deux lettres (axe du graphique par jour). | |
| 290 | + pub weekdays_short: [&'static str; 7], | |
| 285 | 291 | pub months: [&'static str; 12], |
| 286 | 292 | pub today_suffix: &'static str, |
| 287 | 293 | pub week_range_pat: &'static str, |
| @@ -380,6 +386,9 @@ pub static FR: Tr = Tr { | ||
| 380 | 386 | detail: "Détail", |
| 381 | 387 | entry_one: "saisie", |
| 382 | 388 | entry_many: "saisies", |
| 389 | + week_activity_pat: "Activité de la semaine — {}", | |
| 390 | + no_time_week: "Aucun temps cette semaine.", | |
| 391 | + share_of_day: "Part du jour", | |
| 383 | 392 | title_times: "Mes temps", |
| 384 | 393 | times_empty: "Tu n'as encore enregistré aucun temps dans cette équipe.", |
| 385 | 394 | see_more: "Voir plus", |
| @@ -521,6 +530,7 @@ pub static FR: Tr = Tr { | ||
| 521 | 530 | err_500: "Erreur interne.", |
| 522 | 531 | back_home: "← Accueil", |
| 523 | 532 | weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"], |
| 533 | + weekdays_short: ["Lu", "Ma", "Me", "Je", "Ve", "Sa", "Di"], | |
| 524 | 534 | months: [ |
| 525 | 535 | "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", |
| 526 | 536 | "septembre", "octobre", "novembre", "décembre", |
| @@ -594,6 +604,9 @@ pub static EN: Tr = Tr { | ||
| 594 | 604 | detail: "Detail", |
| 595 | 605 | entry_one: "entry", |
| 596 | 606 | entry_many: "entries", |
| 607 | + week_activity_pat: "Week activity — {}", | |
| 608 | + no_time_week: "No time this week.", | |
| 609 | + share_of_day: "Share of the day", | |
| 597 | 610 | title_times: "My time", |
| 598 | 611 | times_empty: "You haven't recorded any time in this team yet.", |
| 599 | 612 | see_more: "Show more", |
| @@ -735,6 +748,7 @@ pub static EN: Tr = Tr { | ||
| 735 | 748 | err_500: "Internal error.", |
| 736 | 749 | back_home: "← Home", |
| 737 | 750 | weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], |
| 751 | + weekdays_short: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"], | |
| 738 | 752 | months: [ |
| 739 | 753 | "January", "February", "March", "April", "May", "June", "July", "August", |
| 740 | 754 | "September", "October", "November", "December", |
| @@ -808,6 +822,9 @@ pub static DE: Tr = Tr { | ||
| 808 | 822 | detail: "Detail", |
| 809 | 823 | entry_one: "Eintrag", |
| 810 | 824 | entry_many: "Einträge", |
| 825 | + week_activity_pat: "Wochenaktivität — {}", | |
| 826 | + no_time_week: "Keine Zeit in dieser Woche.", | |
| 827 | + share_of_day: "Anteil des Tages", | |
| 811 | 828 | title_times: "Meine Zeiten", |
| 812 | 829 | times_empty: "Du hast in diesem Team noch keine Zeit erfasst.", |
| 813 | 830 | see_more: "Mehr anzeigen", |
| @@ -949,6 +966,7 @@ pub static DE: Tr = Tr { | ||
| 949 | 966 | err_500: "Interner Fehler.", |
| 950 | 967 | back_home: "← Startseite", |
| 951 | 968 | weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"], |
| 969 | + weekdays_short: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"], | |
| 952 | 970 | months: [ |
| 953 | 971 | "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", |
| 954 | 972 | "September", "Oktober", "November", "Dezember", |
| @@ -131,6 +131,10 @@ pub struct Tr { | |||
| 131 | pub detail: &'static str, | 131 | pub detail: &'static str, |
| 132 | pub entry_one: &'static str, | 132 | pub entry_one: &'static str, |
| 133 | pub entry_many: &'static str, | 133 | pub entry_many: &'static str, |
| 134 | + /// Titre du graphique par jour, {} = total équipe de la semaine. | ||
| 135 | + pub week_activity_pat: &'static str, | ||
| 136 | + pub no_time_week: &'static str, | ||
| 137 | + pub share_of_day: &'static str, | ||
| 134 | // ─── mes temps / édition ─── | 138 | // ─── mes temps / édition ─── |
| 135 | pub title_times: &'static str, | 139 | pub title_times: &'static str, |
| 136 | pub times_empty: &'static str, | 140 | pub times_empty: &'static str, |
| @@ -282,6 +286,8 @@ pub struct Tr { | |||
| 282 | pub back_home: &'static str, | 286 | pub back_home: &'static str, |
| 283 | // ─── dates ─── | 287 | // ─── dates ─── |
| 284 | pub weekdays: [&'static str; 7], | 288 | pub weekdays: [&'static str; 7], |
| 289 | + /// Abréviations à deux lettres (axe du graphique par jour). | ||
| 290 | + pub weekdays_short: [&'static str; 7], | ||
| 285 | pub months: [&'static str; 12], | 291 | pub months: [&'static str; 12], |
| 286 | pub today_suffix: &'static str, | 292 | pub today_suffix: &'static str, |
| 287 | pub week_range_pat: &'static str, | 293 | pub week_range_pat: &'static str, |
| @@ -380,6 +386,9 @@ pub static FR: Tr = Tr { | |||
| 380 | detail: "Détail", | 386 | detail: "Détail", |
| 381 | entry_one: "saisie", | 387 | entry_one: "saisie", |
| 382 | entry_many: "saisies", | 388 | entry_many: "saisies", |
| 389 | + week_activity_pat: "Activité de la semaine — {}", | ||
| 390 | + no_time_week: "Aucun temps cette semaine.", | ||
| 391 | + share_of_day: "Part du jour", | ||
| 383 | title_times: "Mes temps", | 392 | title_times: "Mes temps", |
| 384 | times_empty: "Tu n'as encore enregistré aucun temps dans cette équipe.", | 393 | times_empty: "Tu n'as encore enregistré aucun temps dans cette équipe.", |
| 385 | see_more: "Voir plus", | 394 | see_more: "Voir plus", |
| @@ -521,6 +530,7 @@ pub static FR: Tr = Tr { | |||
| 521 | err_500: "Erreur interne.", | 530 | err_500: "Erreur interne.", |
| 522 | back_home: "← Accueil", | 531 | back_home: "← Accueil", |
| 523 | weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"], | 532 | weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"], |
| 533 | + weekdays_short: ["Lu", "Ma", "Me", "Je", "Ve", "Sa", "Di"], | ||
| 524 | months: [ | 534 | months: [ |
| 525 | "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", | 535 | "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", |
| 526 | "septembre", "octobre", "novembre", "décembre", | 536 | "septembre", "octobre", "novembre", "décembre", |
| @@ -594,6 +604,9 @@ pub static EN: Tr = Tr { | |||
| 594 | detail: "Detail", | 604 | detail: "Detail", |
| 595 | entry_one: "entry", | 605 | entry_one: "entry", |
| 596 | entry_many: "entries", | 606 | entry_many: "entries", |
| 607 | + week_activity_pat: "Week activity — {}", | ||
| 608 | + no_time_week: "No time this week.", | ||
| 609 | + share_of_day: "Share of the day", | ||
| 597 | title_times: "My time", | 610 | title_times: "My time", |
| 598 | times_empty: "You haven't recorded any time in this team yet.", | 611 | times_empty: "You haven't recorded any time in this team yet.", |
| 599 | see_more: "Show more", | 612 | see_more: "Show more", |
| @@ -735,6 +748,7 @@ pub static EN: Tr = Tr { | |||
| 735 | err_500: "Internal error.", | 748 | err_500: "Internal error.", |
| 736 | back_home: "← Home", | 749 | back_home: "← Home", |
| 737 | weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], | 750 | weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], |
| 751 | + weekdays_short: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"], | ||
| 738 | months: [ | 752 | months: [ |
| 739 | "January", "February", "March", "April", "May", "June", "July", "August", | 753 | "January", "February", "March", "April", "May", "June", "July", "August", |
| 740 | "September", "October", "November", "December", | 754 | "September", "October", "November", "December", |
| @@ -808,6 +822,9 @@ pub static DE: Tr = Tr { | |||
| 808 | detail: "Detail", | 822 | detail: "Detail", |
| 809 | entry_one: "Eintrag", | 823 | entry_one: "Eintrag", |
| 810 | entry_many: "Einträge", | 824 | entry_many: "Einträge", |
| 825 | + week_activity_pat: "Wochenaktivität — {}", | ||
| 826 | + no_time_week: "Keine Zeit in dieser Woche.", | ||
| 827 | + share_of_day: "Anteil des Tages", | ||
| 811 | title_times: "Meine Zeiten", | 828 | title_times: "Meine Zeiten", |
| 812 | times_empty: "Du hast in diesem Team noch keine Zeit erfasst.", | 829 | times_empty: "Du hast in diesem Team noch keine Zeit erfasst.", |
| 813 | see_more: "Mehr anzeigen", | 830 | see_more: "Mehr anzeigen", |
| @@ -949,6 +966,7 @@ pub static DE: Tr = Tr { | |||
| 949 | err_500: "Interner Fehler.", | 966 | err_500: "Interner Fehler.", |
| 950 | back_home: "← Startseite", | 967 | back_home: "← Startseite", |
| 951 | weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"], | 968 | weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"], |
| 969 | + weekdays_short: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"], | ||
| 952 | months: [ | 970 | months: [ |
| 953 | "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", | 971 | "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", |
| 954 | "September", "Oktober", "November", "Dezember", | 972 | "September", "Oktober", "November", "Dezember", |
modified
src/routes/stats.rs +48 -3 | @@ -1,15 +1,18 @@ | ||
| 1 | -use std::collections::BTreeMap; | |
| 1 | +use std::collections::{BTreeMap, HashSet}; | |
| 2 | 2 | |
| 3 | 3 | use askama::Template; |
| 4 | 4 | use axum::extract::{Query, State}; |
| 5 | 5 | use serde::Deserialize; |
| 6 | 6 | use uuid::Uuid; |
| 7 | 7 | |
| 8 | -use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend}; | |
| 8 | +use crate::charts::{ | |
| 9 | + ChartNav, ChartView, DayChartView, LegendItem, Sheet, build_chart, build_day_chart, color_map, | |
| 10 | + legend, person_color_map, | |
| 11 | +}; | |
| 9 | 12 | use crate::db::users::UserRow; |
| 10 | 13 | use crate::db::{stats, categories, teams}; |
| 11 | 14 | use crate::error::AppError; |
| 12 | -use crate::period::{Month, Week, Year}; | |
| 15 | +use crate::period::{Month, Week, Year, paris_today}; | |
| 13 | 16 | use crate::render::{HtmlTemplate, Shell}; |
| 14 | 17 | use crate::routes::shell; |
| 15 | 18 | use crate::state::AppState; |
| @@ -20,8 +23,12 @@ use crate::team::ActiveTeam; | ||
| 20 | 23 | pub struct StatsPage { |
| 21 | 24 | pub shell: Shell, |
| 22 | 25 | pub legend: Vec<LegendItem>, |
| 26 | + /// Graphique par jour « Activité de la semaine », hors de la boucle | |
| 27 | + /// `charts` (colonnes = jours, légende propre). | |
| 28 | + pub day_chart: DayChartView, | |
| 23 | 29 | pub charts: Vec<ChartView>, |
| 24 | 30 | pub sheets: Vec<Sheet>, |
| 31 | + pub day_sheets: Vec<Sheet>, | |
| 25 | 32 | } |
| 26 | 33 | |
| 27 | 34 | #[derive(Deserialize)] |
| @@ -89,7 +96,43 @@ pub async fn stats_page( | ||
| 89 | 96 | |
| 90 | 97 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); |
| 91 | 98 | |
| 99 | + // ── Graphique par jour : couleurs par rang d'adhésion, stables dans le | |
| 100 | + // temps (calculées sur membres actuels + TOUS les ex-membres ayant des | |
| 101 | + // temps, pas seulement ceux de la semaine affichée). | |
| 102 | + let day_rows = stats::totals_by_day(&state.pool, ctx.team_id, week_from, week_to).await?; | |
| 103 | + let mut ranked = teams::members_by_joined(&state.pool, ctx.team_id).await?; | |
| 104 | + let current: HashSet<Uuid> = ranked.iter().map(|u| u.id).collect(); | |
| 105 | + let ex: BTreeMap<Uuid, String> = total_rows | |
| 106 | + .iter() | |
| 107 | + .filter(|r| !current.contains(&r.user_id)) | |
| 108 | + .map(|r| (r.user_id, r.username.clone())) | |
| 109 | + .collect(); | |
| 110 | + // BTreeMap → tri par user_id (UUIDv7 ≈ ordre de création des comptes), | |
| 111 | + // déterministe et insensible aux renommages. | |
| 112 | + ranked.extend(ex.into_iter().map(|(id, username)| UserRow { id, username })); | |
| 113 | + let person_colors = | |
| 114 | + person_color_map(&ranked.iter().map(|u| u.id).collect::<Vec<_>>(), ctx.hc); | |
| 115 | + // Barres et légende : membres actuels + seuls les ex-membres actifs | |
| 116 | + // cette semaine-là (leur couleur vient de la liste complète). | |
| 117 | + let week_users: HashSet<Uuid> = day_rows.iter().map(|r| r.user_id).collect(); | |
| 118 | + let shown: Vec<UserRow> = ranked | |
| 119 | + .into_iter() | |
| 120 | + .filter(|u| current.contains(&u.id) || week_users.contains(&u.id)) | |
| 121 | + .collect(); | |
| 122 | + | |
| 92 | 123 | let tr = ctx.tr(); |
| 124 | + let (day_chart, day_sheets) = build_day_chart( | |
| 125 | + week, | |
| 126 | + paris_today(), | |
| 127 | + &shown, | |
| 128 | + &day_rows, | |
| 129 | + &person_colors, | |
| 130 | + ChartNav { | |
| 131 | + prev: href(week.prev(), month, year), | |
| 132 | + next: (week != Week::current()).then(|| href(week.next(), month, year)), | |
| 133 | + }, | |
| 134 | + ctx.lang, | |
| 135 | + ); | |
| 93 | 136 | let (week_chart, mut sheets) = build_chart( |
| 94 | 137 | "sem", |
| 95 | 138 | tr.this_week, |
| @@ -146,7 +189,9 @@ pub async fn stats_page( | ||
| 146 | 189 | Ok(HtmlTemplate(StatsPage { |
| 147 | 190 | shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?, |
| 148 | 191 | legend, |
| 192 | + day_chart, | |
| 149 | 193 | charts: vec![week_chart, month_chart, year_chart, total_chart], |
| 150 | 194 | sheets, |
| 195 | + day_sheets, | |
| 151 | 196 | })) |
| 152 | 197 | } |
| @@ -1,15 +1,18 @@ | |||
| 1 | -use std::collections::BTreeMap; | 1 | +use std::collections::{BTreeMap, HashSet}; |
| 2 | 2 | ||
| 3 | use askama::Template; | 3 | use askama::Template; |
| 4 | use axum::extract::{Query, State}; | 4 | use axum::extract::{Query, State}; |
| 5 | use serde::Deserialize; | 5 | use serde::Deserialize; |
| 6 | use uuid::Uuid; | 6 | use uuid::Uuid; |
| 7 | 7 | ||
| 8 | -use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend}; | 8 | +use crate::charts::{ |
| 9 | + ChartNav, ChartView, DayChartView, LegendItem, Sheet, build_chart, build_day_chart, color_map, | ||
| 10 | + legend, person_color_map, | ||
| 11 | +}; | ||
| 9 | use crate::db::users::UserRow; | 12 | use crate::db::users::UserRow; |
| 10 | use crate::db::{stats, categories, teams}; | 13 | use crate::db::{stats, categories, teams}; |
| 11 | use crate::error::AppError; | 14 | use crate::error::AppError; |
| 12 | -use crate::period::{Month, Week, Year}; | 15 | +use crate::period::{Month, Week, Year, paris_today}; |
| 13 | use crate::render::{HtmlTemplate, Shell}; | 16 | use crate::render::{HtmlTemplate, Shell}; |
| 14 | use crate::routes::shell; | 17 | use crate::routes::shell; |
| 15 | use crate::state::AppState; | 18 | use crate::state::AppState; |
| @@ -20,8 +23,12 @@ use crate::team::ActiveTeam; | |||
| 20 | pub struct StatsPage { | 23 | pub struct StatsPage { |
| 21 | pub shell: Shell, | 24 | pub shell: Shell, |
| 22 | pub legend: Vec<LegendItem>, | 25 | pub legend: Vec<LegendItem>, |
| 26 | + /// Graphique par jour « Activité de la semaine », hors de la boucle | ||
| 27 | + /// `charts` (colonnes = jours, légende propre). | ||
| 28 | + pub day_chart: DayChartView, | ||
| 23 | pub charts: Vec<ChartView>, | 29 | pub charts: Vec<ChartView>, |
| 24 | pub sheets: Vec<Sheet>, | 30 | pub sheets: Vec<Sheet>, |
| 31 | + pub day_sheets: Vec<Sheet>, | ||
| 25 | } | 32 | } |
| 26 | 33 | ||
| 27 | #[derive(Deserialize)] | 34 | #[derive(Deserialize)] |
| @@ -89,7 +96,43 @@ pub async fn stats_page( | |||
| 89 | 96 | ||
| 90 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); | 97 | let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); |
| 91 | 98 | ||
| 99 | + // ── Graphique par jour : couleurs par rang d'adhésion, stables dans le | ||
| 100 | + // temps (calculées sur membres actuels + TOUS les ex-membres ayant des | ||
| 101 | + // temps, pas seulement ceux de la semaine affichée). | ||
| 102 | + let day_rows = stats::totals_by_day(&state.pool, ctx.team_id, week_from, week_to).await?; | ||
| 103 | + let mut ranked = teams::members_by_joined(&state.pool, ctx.team_id).await?; | ||
| 104 | + let current: HashSet<Uuid> = ranked.iter().map(|u| u.id).collect(); | ||
| 105 | + let ex: BTreeMap<Uuid, String> = total_rows | ||
| 106 | + .iter() | ||
| 107 | + .filter(|r| !current.contains(&r.user_id)) | ||
| 108 | + .map(|r| (r.user_id, r.username.clone())) | ||
| 109 | + .collect(); | ||
| 110 | + // BTreeMap → tri par user_id (UUIDv7 ≈ ordre de création des comptes), | ||
| 111 | + // déterministe et insensible aux renommages. | ||
| 112 | + ranked.extend(ex.into_iter().map(|(id, username)| UserRow { id, username })); | ||
| 113 | + let person_colors = | ||
| 114 | + person_color_map(&ranked.iter().map(|u| u.id).collect::<Vec<_>>(), ctx.hc); | ||
| 115 | + // Barres et légende : membres actuels + seuls les ex-membres actifs | ||
| 116 | + // cette semaine-là (leur couleur vient de la liste complète). | ||
| 117 | + let week_users: HashSet<Uuid> = day_rows.iter().map(|r| r.user_id).collect(); | ||
| 118 | + let shown: Vec<UserRow> = ranked | ||
| 119 | + .into_iter() | ||
| 120 | + .filter(|u| current.contains(&u.id) || week_users.contains(&u.id)) | ||
| 121 | + .collect(); | ||
| 122 | + | ||
| 92 | let tr = ctx.tr(); | 123 | let tr = ctx.tr(); |
| 124 | + let (day_chart, day_sheets) = build_day_chart( | ||
| 125 | + week, | ||
| 126 | + paris_today(), | ||
| 127 | + &shown, | ||
| 128 | + &day_rows, | ||
| 129 | + &person_colors, | ||
| 130 | + ChartNav { | ||
| 131 | + prev: href(week.prev(), month, year), | ||
| 132 | + next: (week != Week::current()).then(|| href(week.next(), month, year)), | ||
| 133 | + }, | ||
| 134 | + ctx.lang, | ||
| 135 | + ); | ||
| 93 | let (week_chart, mut sheets) = build_chart( | 136 | let (week_chart, mut sheets) = build_chart( |
| 94 | "sem", | 137 | "sem", |
| 95 | tr.this_week, | 138 | tr.this_week, |
| @@ -146,7 +189,9 @@ pub async fn stats_page( | |||
| 146 | Ok(HtmlTemplate(StatsPage { | 189 | Ok(HtmlTemplate(StatsPage { |
| 147 | shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?, | 190 | shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?, |
| 148 | legend, | 191 | legend, |
| 192 | + day_chart, | ||
| 149 | charts: vec![week_chart, month_chart, year_chart, total_chart], | 193 | charts: vec![week_chart, month_chart, year_chart, total_chart], |
| 150 | sheets, | 194 | sheets, |
| 195 | + day_sheets, | ||
| 151 | })) | 196 | })) |
| 152 | } | 197 | } |
modified
templates/stats.html +66 -0 | @@ -9,6 +9,55 @@ | ||
| 9 | 9 | </ul> |
| 10 | 10 | {% endif %} |
| 11 | 11 | |
| 12 | +<section class="chart-section day-chart"> | |
| 13 | + <div class="chart-head"> | |
| 14 | + <h2>{{ day_chart.title }}</h2> | |
| 15 | + <div class="chart-nav"> | |
| 16 | + <a class="btn btn-secondary btn-icon-sm" href="{{ day_chart.prev }}" aria-label="{{ shell.tr.prev_period }}"> | |
| 17 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | |
| 18 | + </a> | |
| 19 | + {% if let Some(next) = day_chart.next %} | |
| 20 | + <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}"> | |
| 21 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | |
| 22 | + </a> | |
| 23 | + {% else %} | |
| 24 | + <span class="btn btn-secondary btn-icon-sm" aria-disabled="true"> | |
| 25 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | |
| 26 | + </span> | |
| 27 | + {% endif %} | |
| 28 | + </div> | |
| 29 | + </div> | |
| 30 | + <p class="chart-range">{{ day_chart.range }}</p> | |
| 31 | + {% if !day_chart.legend.is_empty() %} | |
| 32 | + <ul class="legend legend-people"> | |
| 33 | + {% for item in day_chart.legend %} | |
| 34 | + <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li> | |
| 35 | + {% endfor %} | |
| 36 | + </ul> | |
| 37 | + {% endif %} | |
| 38 | + {% if day_chart.empty %} | |
| 39 | + <p class="chart-empty">{{ shell.tr.no_time_week }}</p> | |
| 40 | + {% endif %} | |
| 41 | + <div class="cols cols-7"> | |
| 42 | + {% for col in day_chart.cols %} | |
| 43 | + <div class="col{% if col.muted %} col-muted{% endif %}"> | |
| 44 | + {% if let Some(t) = col.total %} | |
| 45 | + <span class="total">{{ t }}</span> | |
| 46 | + {% endif %} | |
| 47 | + {% if !col.muted %} | |
| 48 | + <div class="col-bar" style="height:{{ col.height_pct }}%"> | |
| 49 | + {% for s in col.segs %} | |
| 50 | + <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}" | |
| 51 | + title="{{ s.aria }}" aria-label="{{ s.aria }}"></a> | |
| 52 | + {% endfor %} | |
| 53 | + </div> | |
| 54 | + {% endif %} | |
| 55 | + <span class="user{% if col.today %} day-today{% endif %}">{{ col.label }}</span> | |
| 56 | + </div> | |
| 57 | + {% endfor %} | |
| 58 | + </div> | |
| 59 | +</section> | |
| 60 | + | |
| 12 | 61 | {% for c in charts %} |
| 13 | 62 | <section class="chart-section"> |
| 14 | 63 | <div class="chart-head"> |
| @@ -73,4 +122,21 @@ | ||
| 73 | 122 | </div> |
| 74 | 123 | </div> |
| 75 | 124 | {% endfor %} |
| 125 | +{% for s in day_sheets %} | |
| 126 | +<div class="sheet-wrap" id="{{ s.id }}" role="dialog" aria-label="{{ shell.tr.detail }} — {{ s.category }}"> | |
| 127 | + <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a> | |
| 128 | + <div class="sheet"> | |
| 129 | + <div class="sheet-head"> | |
| 130 | + <span class="dot dot-14" style="background:{{ s.color }}"></span> | |
| 131 | + <span class="pname">{{ s.category }}</span> | |
| 132 | + <a class="btn btn-ghost close" href="#fermer">{{ shell.tr.close }}</a> | |
| 133 | + </div> | |
| 134 | + <div class="sheet-figures"> | |
| 135 | + <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> | |
| 136 | + <div><div class="k">{{ shell.tr.share_of_day }}</div><div class="v">{{ s.share }}</div></div> | |
| 137 | + </div> | |
| 138 | + <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> | |
| 139 | + </div> | |
| 140 | +</div> | |
| 141 | +{% endfor %} | |
| 76 | 142 | {% endblock %} |
| @@ -9,6 +9,55 @@ | |||
| 9 | </ul> | 9 | </ul> |
| 10 | {% endif %} | 10 | {% endif %} |
| 11 | 11 | ||
| 12 | +<section class="chart-section day-chart"> | ||
| 13 | + <div class="chart-head"> | ||
| 14 | + <h2>{{ day_chart.title }}</h2> | ||
| 15 | + <div class="chart-nav"> | ||
| 16 | + <a class="btn btn-secondary btn-icon-sm" href="{{ day_chart.prev }}" aria-label="{{ shell.tr.prev_period }}"> | ||
| 17 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | ||
| 18 | + </a> | ||
| 19 | + {% if let Some(next) = day_chart.next %} | ||
| 20 | + <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}"> | ||
| 21 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | ||
| 22 | + </a> | ||
| 23 | + {% else %} | ||
| 24 | + <span class="btn btn-secondary btn-icon-sm" aria-disabled="true"> | ||
| 25 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | ||
| 26 | + </span> | ||
| 27 | + {% endif %} | ||
| 28 | + </div> | ||
| 29 | + </div> | ||
| 30 | + <p class="chart-range">{{ day_chart.range }}</p> | ||
| 31 | + {% if !day_chart.legend.is_empty() %} | ||
| 32 | + <ul class="legend legend-people"> | ||
| 33 | + {% for item in day_chart.legend %} | ||
| 34 | + <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li> | ||
| 35 | + {% endfor %} | ||
| 36 | + </ul> | ||
| 37 | + {% endif %} | ||
| 38 | + {% if day_chart.empty %} | ||
| 39 | + <p class="chart-empty">{{ shell.tr.no_time_week }}</p> | ||
| 40 | + {% endif %} | ||
| 41 | + <div class="cols cols-7"> | ||
| 42 | + {% for col in day_chart.cols %} | ||
| 43 | + <div class="col{% if col.muted %} col-muted{% endif %}"> | ||
| 44 | + {% if let Some(t) = col.total %} | ||
| 45 | + <span class="total">{{ t }}</span> | ||
| 46 | + {% endif %} | ||
| 47 | + {% if !col.muted %} | ||
| 48 | + <div class="col-bar" style="height:{{ col.height_pct }}%"> | ||
| 49 | + {% for s in col.segs %} | ||
| 50 | + <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}" | ||
| 51 | + title="{{ s.aria }}" aria-label="{{ s.aria }}"></a> | ||
| 52 | + {% endfor %} | ||
| 53 | + </div> | ||
| 54 | + {% endif %} | ||
| 55 | + <span class="user{% if col.today %} day-today{% endif %}">{{ col.label }}</span> | ||
| 56 | + </div> | ||
| 57 | + {% endfor %} | ||
| 58 | + </div> | ||
| 59 | +</section> | ||
| 60 | + | ||
| 12 | {% for c in charts %} | 61 | {% for c in charts %} |
| 13 | <section class="chart-section"> | 62 | <section class="chart-section"> |
| 14 | <div class="chart-head"> | 63 | <div class="chart-head"> |
| @@ -73,4 +122,21 @@ | |||
| 73 | </div> | 122 | </div> |
| 74 | </div> | 123 | </div> |
| 75 | {% endfor %} | 124 | {% endfor %} |
| 125 | +{% for s in day_sheets %} | ||
| 126 | +<div class="sheet-wrap" id="{{ s.id }}" role="dialog" aria-label="{{ shell.tr.detail }} — {{ s.category }}"> | ||
| 127 | + <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a> | ||
| 128 | + <div class="sheet"> | ||
| 129 | + <div class="sheet-head"> | ||
| 130 | + <span class="dot dot-14" style="background:{{ s.color }}"></span> | ||
| 131 | + <span class="pname">{{ s.category }}</span> | ||
| 132 | + <a class="btn btn-ghost close" href="#fermer">{{ shell.tr.close }}</a> | ||
| 133 | + </div> | ||
| 134 | + <div class="sheet-figures"> | ||
| 135 | + <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> | ||
| 136 | + <div><div class="k">{{ shell.tr.share_of_day }}</div><div class="v">{{ s.share }}</div></div> | ||
| 137 | + </div> | ||
| 138 | + <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> | ||
| 139 | + </div> | ||
| 140 | +</div> | ||
| 141 | +{% endfor %} | ||
| 76 | {% endblock %} | 142 | {% endblock %} |