judu/ex-egopublic Fork
⑂ a314c3e
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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
Julien Durillon committed 2026-08-10T20:03:32+02:00 Browse files
a314c3e parent: 107945f
modified assets/style.css +14 -0
@@ -433,6 +433,20 @@ a.team-pill:hover { filter: brightness(.96); }
433433 légèrement sur la proportion visuelle pour les petits segments. */
434434 .col-bar a { display: block; min-height: 24px; width: 100%; }
435435
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+
436450 /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */
437451 .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; }
438452 .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 @@
11 use std::collections::{BTreeMap, HashMap};
22
3+use chrono::{Datelike, Days, NaiveDate};
34 use uuid::Uuid;
45
5-use crate::db::stats::StatRow;
6+use crate::db::stats::{DayStatRow, StatRow};
67 use crate::db::users::UserRow;
7-use crate::i18n::Tr;
8+use crate::i18n::{Lang, Tr, p1};
9+use crate::period::{self, Week};
810
911 /// Palette du design system « Organic » — couleur par ordre de création de la
1012 /// 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] {
2931 if hc { &PALETTE_HC } else { &PALETTE }
3032 }
3133
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+
3268 /// category_id → couleur, à partir de la liste des tâches triée par id (création).
3369 pub fn color_map(task_ids_in_creation_order: &[Uuid], hc: bool) -> HashMap<Uuid, &'static str> {
3470 let p = palette(hc);
@@ -56,6 +92,18 @@ pub fn fmt_minutes(total: i64) -> String {
5692 }
5793 }
5894
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+
59107 pub struct Seg {
60108 /// flex-grow ∝ minutes.
61109 pub flex: i64,
@@ -194,6 +242,145 @@ pub fn build_chart(
194242 )
195243 }
196244
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+
197384 /// Légende commune : union des tâches présentes, triée par nom.
198385 pub fn legend(row_sets: &[&[StatRow]], colors: &HashMap<Uuid, &'static str>) -> Vec<LegendItem> {
199386 let mut seen: BTreeMap<String, &'static str> = BTreeMap::new();
@@ -234,6 +421,30 @@ mod tests {
234421 color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)], false)
235422 }
236423
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+
237448 #[test]
238449 fn fmt() {
239450 assert_eq!(fmt_minutes(45), "45 min");
@@ -284,6 +495,114 @@ mod tests {
284495 assert_eq!(hc[&ids[1]], PALETTE_HC[1]);
285496 }
286497
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+
287606 #[test]
288607 fn team_color_stable_and_in_palette() {
289608 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 la11 /// 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 {
1414 pub entries: i64,
1515 }
1616
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+
1752 /// Totaux par utilisateur × catégorie de l'équipe sur [from, to) ;
1853 /// bornes NULL = depuis le début. Les catégories archivées restent incluses,
1954 /// 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>>
132132 .fetch_all(pool)
133133 .await
134134 }
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 .await133 .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 {
131131 pub detail: &'static str,
132132 pub entry_one: &'static str,
133133 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,
134138 // ─── mes temps / édition ───
135139 pub title_times: &'static str,
136140 pub times_empty: &'static str,
@@ -282,6 +286,8 @@ pub struct Tr {
282286 pub back_home: &'static str,
283287 // ─── dates ───
284288 pub weekdays: [&'static str; 7],
289+ /// Abréviations à deux lettres (axe du graphique par jour).
290+ pub weekdays_short: [&'static str; 7],
285291 pub months: [&'static str; 12],
286292 pub today_suffix: &'static str,
287293 pub week_range_pat: &'static str,
@@ -380,6 +386,9 @@ pub static FR: Tr = Tr {
380386 detail: "Détail",
381387 entry_one: "saisie",
382388 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",
383392 title_times: "Mes temps",
384393 times_empty: "Tu n'as encore enregistré aucun temps dans cette équipe.",
385394 see_more: "Voir plus",
@@ -521,6 +530,7 @@ pub static FR: Tr = Tr {
521530 err_500: "Erreur interne.",
522531 back_home: "← Accueil",
523532 weekdays: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"],
533+ weekdays_short: ["Lu", "Ma", "Me", "Je", "Ve", "Sa", "Di"],
524534 months: [
525535 "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août",
526536 "septembre", "octobre", "novembre", "décembre",
@@ -594,6 +604,9 @@ pub static EN: Tr = Tr {
594604 detail: "Detail",
595605 entry_one: "entry",
596606 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",
597610 title_times: "My time",
598611 times_empty: "You haven't recorded any time in this team yet.",
599612 see_more: "Show more",
@@ -735,6 +748,7 @@ pub static EN: Tr = Tr {
735748 err_500: "Internal error.",
736749 back_home: "← Home",
737750 weekdays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
751+ weekdays_short: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
738752 months: [
739753 "January", "February", "March", "April", "May", "June", "July", "August",
740754 "September", "October", "November", "December",
@@ -808,6 +822,9 @@ pub static DE: Tr = Tr {
808822 detail: "Detail",
809823 entry_one: "Eintrag",
810824 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",
811828 title_times: "Meine Zeiten",
812829 times_empty: "Du hast in diesem Team noch keine Zeit erfasst.",
813830 see_more: "Mehr anzeigen",
@@ -949,6 +966,7 @@ pub static DE: Tr = Tr {
949966 err_500: "Interner Fehler.",
950967 back_home: "← Startseite",
951968 weekdays: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"],
969+ weekdays_short: ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
952970 months: [
953971 "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August",
954972 "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};
22
33 use askama::Template;
44 use axum::extract::{Query, State};
55 use serde::Deserialize;
66 use uuid::Uuid;
77
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+};
912 use crate::db::users::UserRow;
1013 use crate::db::{stats, categories, teams};
1114 use crate::error::AppError;
12-use crate::period::{Month, Week, Year};
15+use crate::period::{Month, Week, Year, paris_today};
1316 use crate::render::{HtmlTemplate, Shell};
1417 use crate::routes::shell;
1518 use crate::state::AppState;
@@ -20,8 +23,12 @@ use crate::team::ActiveTeam;
2023 pub struct StatsPage {
2124 pub shell: Shell,
2225 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,
2329 pub charts: Vec<ChartView>,
2430 pub sheets: Vec<Sheet>,
31+ pub day_sheets: Vec<Sheet>,
2532 }
2633
2734 #[derive(Deserialize)]
@@ -89,7 +96,43 @@ pub async fn stats_page(
8996
9097 let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);
9198
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+
92123 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+ );
93136 let (week_chart, mut sheets) = build_chart(
94137 "sem",
95138 tr.this_week,
@@ -146,7 +189,9 @@ pub async fn stats_page(
146189 Ok(HtmlTemplate(StatsPage {
147190 shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?,
148191 legend,
192+ day_chart,
149193 charts: vec![week_chart, month_chart, year_chart, total_chart],
150194 sheets,
195+ day_sheets,
151196 }))
152197 }
@@ -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 @@
99 </ul>
1010 {% endif %}
1111
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+
1261 {% for c in charts %}
1362 <section class="chart-section">
1463 <div class="chart-head">
@@ -73,4 +122,21 @@
73122 </div>
74123 </div>
75124 {% 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 %}
76142 {% 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 %}