judu/ex-egopublic Fork
⑂ d7ea44f
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): groupes de catégories — carrousel de scopes à scroll-snapUnverified

Un label group_name optionnel sur la catégorie (migration 0009) met son
temps « à part » : la vue principale des stats l'exclut complètement,
chaque groupe a son panneau (page complète rescopée). Invisible tant
qu'aucun groupe n'existe. Carrousel CSS scroll-snap conforme au design :
pills (ancre = zéro JS), liens de période porteurs de groupe + ancre,
JS d'amélioration (scrollend → aria-current + history.replaceState,
placement initial sur ?groupe=). Écran Catégories : section Groupes
(cartes + chips retirables + formulaire « Mettre à part »), i18n
FR/EN/DE. Pill active teintée à 25 % (écart a11y assumé vs le design).

Claude-Session: https://claude.ai/code/session_01D114WAMbcTKTZShiyksS1z
Julien Durillon committed 2026-08-11T09:51:24+02:00 Browse files
d7ea44f parent: 1392029
modified assets/style.css +62 -0
@@ -474,6 +474,68 @@ a.team-pill:hover { filter: brightness(.96); }
474474 .col-muted .user { font-weight: 400; }
475475 .col .user.day-today { font-weight: 700; color: var(--color-accent-deep); }
476476
477+/* — groupes de catégories : carrousel de scopes (page Stats) — */
478+.scope-pills {
479+ display: flex; gap: var(--space-2); overflow-x: auto; scrollbar-width: none;
480+ margin: 0 calc(-1 * var(--space-4)) var(--space-4);
481+ padding: 0 var(--space-4) var(--space-1);
482+}
483+.scope-pills::-webkit-scrollbar { display: none; }
484+.scope-pill {
485+ flex: none; display: inline-flex; align-items: center;
486+ padding: 8px 15px; min-height: 34px; border-radius: 999px;
487+ font-size: 14px; text-decoration: none; color: var(--color-text);
488+ border: 1px solid var(--color-divider);
489+}
490+/* Teinte à 25 % comme la pill d'équipe : l'accent-2 plein ne tient pas
491+ 4.5:1 avec l'encre (écart assumé vs le design, cf. pill d'en-tête). */
492+.scope-pill[aria-current] {
493+ background: color-mix(in srgb, var(--color-accent-2) 25%, var(--color-bg));
494+ border-color: var(--color-accent-2); font-weight: 700;
495+}
496+.scopes {
497+ display: flex; overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
498+ scroll-snap-type: x mandatory;
499+}
500+.scopes::-webkit-scrollbar { display: none; }
501+.scope-panel {
502+ flex: 0 0 100%; min-width: 100%;
503+ padding-right: var(--space-6);
504+ scroll-snap-align: start; scroll-snap-stop: always;
505+}
506+.scope-title {
507+ margin: 0 0 var(--space-3); font-family: var(--font-heading);
508+ font-size: 15px; letter-spacing: -0.01em; color: var(--color-accent-700);
509+}
510+@media (prefers-reduced-motion: no-preference) {
511+ .scopes { scroll-behavior: smooth; }
512+}
513+
514+/* — groupes de catégories : section de l'écran Catégories — */
515+.groups-title { font-size: 15px; margin: var(--space-8) 0 var(--space-2); }
516+.groups-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); text-wrap: pretty; }
517+.group-card {
518+ background: var(--color-surface); border-radius: var(--radius-lg);
519+ padding: var(--space-3) var(--space-4); margin-bottom: var(--space-2);
520+}
521+.group-name { font-size: 15px; font-weight: 600; margin-bottom: var(--space-2); }
522+.group-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }
523+.group-chip {
524+ display: inline-flex; align-items: center; gap: 7px; font-size: 13px;
525+ padding: 5px 6px 5px 11px; border-radius: 999px; background: var(--color-bg);
526+}
527+.group-chip .dot { width: 9px; height: 9px; }
528+.chip-x {
529+ width: 24px; height: 24px; flex: none; display: flex; align-items: center;
530+ justify-content: center; border: none; background: none; border-radius: 999px;
531+ cursor: pointer; color: var(--color-text); opacity: .7; padding: 0;
532+}
533+.chip-x:hover { background: color-mix(in srgb, var(--color-text) 10%, transparent); opacity: 1; }
534+.group-form { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-end; margin-top: var(--space-4); }
535+.group-form .field { flex: 1; min-width: 150px; margin: 0; }
536+.group-form .input { min-height: 44px; }
537+.group-form > .btn { min-height: 44px; flex: none; white-space: nowrap; }
538+
477539 /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */
478540 .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; }
479541 .sheet-wrap:target, .sheet-wrap.open { display: flex; align-items: flex-end; justify-content: center; }
@@ -474,6 +474,68 @@ a.team-pill:hover { filter: brightness(.96); }
474 .col-muted .user { font-weight: 400; }474 .col-muted .user { font-weight: 400; }
475 .col .user.day-today { font-weight: 700; color: var(--color-accent-deep); }475 .col .user.day-today { font-weight: 700; color: var(--color-accent-deep); }
476 476
477+/* — groupes de catégories : carrousel de scopes (page Stats) — */
478+.scope-pills {
479+ display: flex; gap: var(--space-2); overflow-x: auto; scrollbar-width: none;
480+ margin: 0 calc(-1 * var(--space-4)) var(--space-4);
481+ padding: 0 var(--space-4) var(--space-1);
482+}
483+.scope-pills::-webkit-scrollbar { display: none; }
484+.scope-pill {
485+ flex: none; display: inline-flex; align-items: center;
486+ padding: 8px 15px; min-height: 34px; border-radius: 999px;
487+ font-size: 14px; text-decoration: none; color: var(--color-text);
488+ border: 1px solid var(--color-divider);
489+}
490+/* Teinte à 25 % comme la pill d'équipe : l'accent-2 plein ne tient pas
491+ 4.5:1 avec l'encre (écart assumé vs le design, cf. pill d'en-tête). */
492+.scope-pill[aria-current] {
493+ background: color-mix(in srgb, var(--color-accent-2) 25%, var(--color-bg));
494+ border-color: var(--color-accent-2); font-weight: 700;
495+}
496+.scopes {
497+ display: flex; overflow-x: auto; overflow-y: hidden; scrollbar-width: none;
498+ scroll-snap-type: x mandatory;
499+}
500+.scopes::-webkit-scrollbar { display: none; }
501+.scope-panel {
502+ flex: 0 0 100%; min-width: 100%;
503+ padding-right: var(--space-6);
504+ scroll-snap-align: start; scroll-snap-stop: always;
505+}
506+.scope-title {
507+ margin: 0 0 var(--space-3); font-family: var(--font-heading);
508+ font-size: 15px; letter-spacing: -0.01em; color: var(--color-accent-700);
509+}
510+@media (prefers-reduced-motion: no-preference) {
511+ .scopes { scroll-behavior: smooth; }
512+}
513+
514+/* — groupes de catégories : section de l'écran Catégories — */
515+.groups-title { font-size: 15px; margin: var(--space-8) 0 var(--space-2); }
516+.groups-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); text-wrap: pretty; }
517+.group-card {
518+ background: var(--color-surface); border-radius: var(--radius-lg);
519+ padding: var(--space-3) var(--space-4); margin-bottom: var(--space-2);
520+}
521+.group-name { font-size: 15px; font-weight: 600; margin-bottom: var(--space-2); }
522+.group-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }
523+.group-chip {
524+ display: inline-flex; align-items: center; gap: 7px; font-size: 13px;
525+ padding: 5px 6px 5px 11px; border-radius: 999px; background: var(--color-bg);
526+}
527+.group-chip .dot { width: 9px; height: 9px; }
528+.chip-x {
529+ width: 24px; height: 24px; flex: none; display: flex; align-items: center;
530+ justify-content: center; border: none; background: none; border-radius: 999px;
531+ cursor: pointer; color: var(--color-text); opacity: .7; padding: 0;
532+}
533+.chip-x:hover { background: color-mix(in srgb, var(--color-text) 10%, transparent); opacity: 1; }
534+.group-form { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-end; margin-top: var(--space-4); }
535+.group-form .field { flex: 1; min-width: 150px; margin: 0; }
536+.group-form .input { min-height: 44px; }
537+.group-form > .btn { min-height: 44px; flex: none; white-space: nowrap; }
538+
477 /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */539 /* — bottom sheet (zéro JS : :target ; .open = variante injectée en JS) — */
478 .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; }540 .sheet-wrap { position: absolute; inset: 0; display: none; z-index: 10; }
479 .sheet-wrap:target, .sheet-wrap.open { display: flex; align-items: flex-end; justify-content: center; }541 .sheet-wrap:target, .sheet-wrap.open { display: flex; align-items: flex-end; justify-content: center; }
added migrations/0009_category_groups.sql +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+-- Groupes de catégories : un label optionnel qui met la catégorie « à part »
2+-- dans les statistiques (la vue principale l'exclut, une vue dédiée par
3+-- groupe). Pas de table de groupes : un groupe existe tant qu'au moins une
4+-- catégorie porte son nom, et disparaît tout seul.
5+ALTER TABLE categories ADD COLUMN group_name TEXT;
new file mode 100644
@@ -0,0 +1,5 @@
1+-- Groupes de catégories : un label optionnel qui met la catégorie « à part »
2+-- dans les statistiques (la vue principale l'exclut, une vue dédiée par
3+-- groupe). Pas de table de groupes : un groupe existe tant qu'au moins une
4+-- catégorie porte son nom, et disparaît tout seul.
5+ALTER TABLE categories ADD COLUMN group_name TEXT;
modified src/charts.rs +9 -6
@@ -270,8 +270,10 @@ pub struct DayChartView {
270270 /// Graphique par jour : colonnes = jours de la semaine ISO, segments =
271271 /// personnes, dans un ordre STABLE (rang d'adhésion) identique dans toutes
272272 /// les barres — on suit la bande d'une personne à travers la semaine.
273-/// `today` est injecté (paris_today au call-site) pour rester testable.
273+/// `today` est injecté (paris_today au call-site) pour rester testable ;
274+/// `key` distingue les ancres entre les panneaux de scope (« jour », « g1jour »…).
274275 pub fn build_day_chart(
276+ key: &str,
275277 week: Week,
276278 today: NaiveDate,
277279 users: &[UserRow],
@@ -323,7 +325,7 @@ pub fn build_day_chart(
323325 for (ui, u) in users.iter().enumerate() {
324326 let Some(r) = by_key.get(&(*day, u.id)) else { continue };
325327 let color = colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]);
326- let sheet_id = format!("d-jour-{di}-{ui}");
328+ let sheet_id = format!("d-{key}-{di}-{ui}");
327329 let share = (r.total * 100) / total.max(1);
328330 sheets.push(Sheet {
329331 id: sheet_id.clone(),
@@ -425,6 +427,7 @@ mod tests {
425427 DayStatRow {
426428 user_id: Uuid::from_u128(uid),
427429 entry_date: date,
430+ group_name: None,
428431 total,
429432 entries,
430433 }
@@ -517,7 +520,7 @@ mod tests {
517520 day_row(2, start, 30, 1),
518521 day_row(2, start + Days::new(3), 60, 1),
519522 ];
520- let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr);
523+ let (chart, sheets) = build_day_chart("jour", week, today, &users, &rows, &colors, nav(), Lang::Fr);
521524 assert_eq!(chart.cols.len(), 7);
522525 assert_eq!(chart.cols[0].label, format!("Lu {}", start.day()));
523526 assert_eq!(chart.cols[6].label, format!("Di {}", (start + Days::new(6)).day()));
@@ -555,7 +558,7 @@ mod tests {
555558 day_row(1, start + Days::new(1), 120, 1),
556559 day_row(2, start + Days::new(1), 10, 1),
557560 ];
558- let (chart, _) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr);
561+ let (chart, _) = build_day_chart("jour", week, today, &users, &rows, &colors, nav(), Lang::Fr);
559562 assert_eq!(chart.cols[0].segs[0].flex, 30); // alice d'abord, même minoritaire
560563 assert_eq!(chart.cols[0].segs[0].color, PERSON_PALETTE[0]);
561564 assert_eq!(chart.cols[1].segs[0].flex, 120);
@@ -567,7 +570,7 @@ mod tests {
567570 let (week, start, users, colors) = day_setup();
568571 let today = start + Days::new(2); // mercredi
569572 let rows = [day_row(1, start, 100, 1)];
570- let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr);
573+ let (chart, sheets) = build_day_chart("jour", week, today, &users, &rows, &colors, nav(), Lang::Fr);
571574 assert!(chart.cols[2].today);
572575 for c in &chart.cols[3..] {
573576 assert!(c.muted);
@@ -584,7 +587,7 @@ mod tests {
584587 fn day_chart_empty_week() {
585588 let (week, start, users, colors) = day_setup();
586589 let today = start + Days::new(30);
587- let (chart, sheets) = build_day_chart(week, today, &users, &[], &colors, nav(), Lang::Fr);
590+ let (chart, sheets) = build_day_chart("jour", week, today, &users, &[], &colors, nav(), Lang::Fr);
588591 assert!(chart.empty);
589592 assert_eq!(chart.cols.len(), 7); // la grille des jours reste
590593 assert!(chart.cols.iter().all(|c| c.total.is_none() && c.segs.is_empty()));
@@ -270,8 +270,10 @@ pub struct DayChartView {
270 /// Graphique par jour : colonnes = jours de la semaine ISO, segments =270 /// Graphique par jour : colonnes = jours de la semaine ISO, segments =
271 /// personnes, dans un ordre STABLE (rang d'adhésion) identique dans toutes271 /// 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.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.273+/// `today` est injecté (paris_today au call-site) pour rester testable ;
274+/// `key` distingue les ancres entre les panneaux de scope (« jour », « g1jour »…).
274 pub fn build_day_chart(275 pub fn build_day_chart(
276+ key: &str,
275 week: Week,277 week: Week,
276 today: NaiveDate,278 today: NaiveDate,
277 users: &[UserRow],279 users: &[UserRow],
@@ -323,7 +325,7 @@ pub fn build_day_chart(
323 for (ui, u) in users.iter().enumerate() {325 for (ui, u) in users.iter().enumerate() {
324 let Some(r) = by_key.get(&(*day, u.id)) else { continue };326 let Some(r) = by_key.get(&(*day, u.id)) else { continue };
325 let color = colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]);327 let color = colors.get(&u.id).copied().unwrap_or(PERSON_PALETTE[0]);
326- let sheet_id = format!("d-jour-{di}-{ui}");328+ let sheet_id = format!("d-{key}-{di}-{ui}");
327 let share = (r.total * 100) / total.max(1);329 let share = (r.total * 100) / total.max(1);
328 sheets.push(Sheet {330 sheets.push(Sheet {
329 id: sheet_id.clone(),331 id: sheet_id.clone(),
@@ -425,6 +427,7 @@ mod tests {
425 DayStatRow {427 DayStatRow {
426 user_id: Uuid::from_u128(uid),428 user_id: Uuid::from_u128(uid),
427 entry_date: date,429 entry_date: date,
430+ group_name: None,
428 total,431 total,
429 entries,432 entries,
430 }433 }
@@ -517,7 +520,7 @@ mod tests {
517 day_row(2, start, 30, 1),520 day_row(2, start, 30, 1),
518 day_row(2, start + Days::new(3), 60, 1),521 day_row(2, start + Days::new(3), 60, 1),
519 ];522 ];
520- let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr);523+ let (chart, sheets) = build_day_chart("jour", week, today, &users, &rows, &colors, nav(), Lang::Fr);
521 assert_eq!(chart.cols.len(), 7);524 assert_eq!(chart.cols.len(), 7);
522 assert_eq!(chart.cols[0].label, format!("Lu {}", start.day()));525 assert_eq!(chart.cols[0].label, format!("Lu {}", start.day()));
523 assert_eq!(chart.cols[6].label, format!("Di {}", (start + Days::new(6)).day()));526 assert_eq!(chart.cols[6].label, format!("Di {}", (start + Days::new(6)).day()));
@@ -555,7 +558,7 @@ mod tests {
555 day_row(1, start + Days::new(1), 120, 1),558 day_row(1, start + Days::new(1), 120, 1),
556 day_row(2, start + Days::new(1), 10, 1),559 day_row(2, start + Days::new(1), 10, 1),
557 ];560 ];
558- let (chart, _) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr);561+ let (chart, _) = build_day_chart("jour", week, today, &users, &rows, &colors, nav(), Lang::Fr);
559 assert_eq!(chart.cols[0].segs[0].flex, 30); // alice d'abord, même minoritaire562 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]);563 assert_eq!(chart.cols[0].segs[0].color, PERSON_PALETTE[0]);
561 assert_eq!(chart.cols[1].segs[0].flex, 120);564 assert_eq!(chart.cols[1].segs[0].flex, 120);
@@ -567,7 +570,7 @@ mod tests {
567 let (week, start, users, colors) = day_setup();570 let (week, start, users, colors) = day_setup();
568 let today = start + Days::new(2); // mercredi571 let today = start + Days::new(2); // mercredi
569 let rows = [day_row(1, start, 100, 1)];572 let rows = [day_row(1, start, 100, 1)];
570- let (chart, sheets) = build_day_chart(week, today, &users, &rows, &colors, nav(), Lang::Fr);573+ let (chart, sheets) = build_day_chart("jour", week, today, &users, &rows, &colors, nav(), Lang::Fr);
571 assert!(chart.cols[2].today);574 assert!(chart.cols[2].today);
572 for c in &chart.cols[3..] {575 for c in &chart.cols[3..] {
573 assert!(c.muted);576 assert!(c.muted);
@@ -584,7 +587,7 @@ mod tests {
584 fn day_chart_empty_week() {587 fn day_chart_empty_week() {
585 let (week, start, users, colors) = day_setup();588 let (week, start, users, colors) = day_setup();
586 let today = start + Days::new(30);589 let today = start + Days::new(30);
587- let (chart, sheets) = build_day_chart(week, today, &users, &[], &colors, nav(), Lang::Fr);590+ let (chart, sheets) = build_day_chart("jour", week, today, &users, &[], &colors, nav(), Lang::Fr);
588 assert!(chart.empty);591 assert!(chart.empty);
589 assert_eq!(chart.cols.len(), 7); // la grille des jours reste592 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()));593 assert!(chart.cols.iter().all(|c| c.total.is_none() && c.segs.is_empty()));
modified src/db/categories.rs +31 -7
@@ -6,23 +6,47 @@ pub struct CategoryRow {
66 pub id: Uuid,
77 pub name: String,
88 pub archived: bool,
9+ /// Groupe « à part » (stats) — None = statistiques de l'équipe.
10+ pub group_name: Option<String>,
911 }
1012
1113 /// Toutes les catégories de l'équipe (écran Catégories).
1214 pub async fn list_all(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<CategoryRow>> {
13- sqlx::query_as("SELECT id, name, archived FROM categories WHERE team_id = $1 ORDER BY archived, name")
14- .bind(team_id)
15- .fetch_all(pool)
16- .await
15+ sqlx::query_as(
16+ "SELECT id, name, archived, group_name FROM categories
17+ WHERE team_id = $1 ORDER BY archived, name",
18+ )
19+ .bind(team_id)
20+ .fetch_all(pool)
21+ .await
1722 }
1823
1924 /// Les catégories de l'équipe par ordre de création (UUIDv7 ≈ chronologique) —
2025 /// c'est l'ordre qui fixe la couleur de chaque catégorie, par équipe.
2126 pub async fn list_by_creation(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<CategoryRow>> {
22- sqlx::query_as("SELECT id, name, archived FROM categories WHERE team_id = $1 ORDER BY id")
27+ sqlx::query_as(
28+ "SELECT id, name, archived, group_name FROM categories
29+ WHERE team_id = $1 ORDER BY id",
30+ )
31+ .bind(team_id)
32+ .fetch_all(pool)
33+ .await
34+}
35+
36+/// Pose ou retire (None) le groupe d'une catégorie. Scopé à l'équipe active.
37+pub async fn set_group(
38+ pool: &PgPool,
39+ id: Uuid,
40+ team_id: Uuid,
41+ group: Option<&str>,
42+) -> sqlx::Result<u64> {
43+ let res = sqlx::query("UPDATE categories SET group_name = $1 WHERE id = $2 AND team_id = $3")
44+ .bind(group)
45+ .bind(id)
2346 .bind(team_id)
24- .fetch_all(pool)
25- .await
47+ .execute(pool)
48+ .await?;
49+ Ok(res.rows_affected())
2650 }
2751
2852 pub async fn create(pool: &PgPool, team_id: Uuid, name: &str) -> sqlx::Result<Uuid> {
@@ -6,23 +6,47 @@ pub struct CategoryRow {
6 pub id: Uuid,6 pub id: Uuid,
7 pub name: String,7 pub name: String,
8 pub archived: bool,8 pub archived: bool,
9+ /// Groupe « à part » (stats) — None = statistiques de l'équipe.
10+ pub group_name: Option<String>,
9 }11 }
10 12
11 /// Toutes les catégories de l'équipe (écran Catégories).13 /// Toutes les catégories de l'équipe (écran Catégories).
12 pub async fn list_all(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<CategoryRow>> {14 pub async fn list_all(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<CategoryRow>> {
13- sqlx::query_as("SELECT id, name, archived FROM categories WHERE team_id = $1 ORDER BY archived, name")15+ sqlx::query_as(
14- .bind(team_id)16+ "SELECT id, name, archived, group_name FROM categories
15- .fetch_all(pool)17+ WHERE team_id = $1 ORDER BY archived, name",
16- .await18+ )
19+ .bind(team_id)
20+ .fetch_all(pool)
21+ .await
17 }22 }
18 23
19 /// Les catégories de l'équipe par ordre de création (UUIDv7 ≈ chronologique) —24 /// Les catégories de l'équipe par ordre de création (UUIDv7 ≈ chronologique) —
20 /// c'est l'ordre qui fixe la couleur de chaque catégorie, par équipe.25 /// c'est l'ordre qui fixe la couleur de chaque catégorie, par équipe.
21 pub async fn list_by_creation(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<CategoryRow>> {26 pub async fn list_by_creation(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<CategoryRow>> {
22- sqlx::query_as("SELECT id, name, archived FROM categories WHERE team_id = $1 ORDER BY id")27+ sqlx::query_as(
28+ "SELECT id, name, archived, group_name FROM categories
29+ WHERE team_id = $1 ORDER BY id",
30+ )
31+ .bind(team_id)
32+ .fetch_all(pool)
33+ .await
34+}
35+
36+/// Pose ou retire (None) le groupe d'une catégorie. Scopé à l'équipe active.
37+pub async fn set_group(
38+ pool: &PgPool,
39+ id: Uuid,
40+ team_id: Uuid,
41+ group: Option<&str>,
42+) -> sqlx::Result<u64> {
43+ let res = sqlx::query("UPDATE categories SET group_name = $1 WHERE id = $2 AND team_id = $3")
44+ .bind(group)
45+ .bind(id)
23 .bind(team_id)46 .bind(team_id)
24- .fetch_all(pool)47+ .execute(pool)
25- .await48+ .await?;
49+ Ok(res.rows_affected())
26 }50 }
27 51
28 pub async fn create(pool: &PgPool, team_id: Uuid, name: &str) -> sqlx::Result<Uuid> {52 pub async fn create(pool: &PgPool, team_id: Uuid, name: &str) -> sqlx::Result<Uuid> {
modified src/db/stats.rs +13 -10
@@ -2,7 +2,7 @@ use chrono::NaiveDate;
22 use sqlx::PgPool;
33 use uuid::Uuid;
44
5-#[derive(sqlx::FromRow)]
5+#[derive(Clone, sqlx::FromRow)]
66 pub struct StatRow {
77 pub user_id: Uuid,
88 /// Sert à faire apparaître les ex-membres qui ont des temps (historique intact).
@@ -14,20 +14,23 @@ 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)]
17+/// Total d'un utilisateur sur un jour, par groupe de catégories (graphique
18+/// « Activité de la semaine », un panneau par scope). Pas de username : le
19+/// nom vient de la liste des membres classés par adhésion (les ex-membres
20+/// de la semaine sont forcément dans `totals`).
21+#[derive(Clone, sqlx::FromRow)]
2122 pub struct DayStatRow {
2223 pub user_id: Uuid,
2324 pub entry_date: NaiveDate,
25+ /// Groupe de la catégorie — None = statistiques de l'équipe.
26+ pub group_name: Option<String>,
2427 pub total: i64,
2528 pub entries: i64,
2629 }
2730
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+/// Totaux par jour × utilisateur × groupe sur [from, to). Le JOIN categories
32+/// rattache à l'équipe : les temps des ex-membres et des catégories
33+/// archivées restent inclus, comme dans `totals`.
3134 pub async fn totals_by_day(
3235 pool: &PgPool,
3336 team_id: Uuid,
@@ -35,12 +38,12 @@ pub async fn totals_by_day(
3538 to: NaiveDate,
3639 ) -> sqlx::Result<Vec<DayStatRow>> {
3740 sqlx::query_as(
38- "SELECT e.user_id, e.entry_date,
41+ "SELECT e.user_id, e.entry_date, t.group_name,
3942 SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries
4043 FROM time_entries e
4144 JOIN categories t ON t.id = e.category_id
4245 WHERE t.team_id = $1 AND e.entry_date >= $2 AND e.entry_date < $3
43- GROUP BY e.user_id, e.entry_date",
46+ GROUP BY e.user_id, e.entry_date, t.group_name",
4447 )
4548 .bind(team_id)
4649 .bind(from)
@@ -2,7 +2,7 @@ use chrono::NaiveDate;
2 use sqlx::PgPool;2 use sqlx::PgPool;
3 use uuid::Uuid;3 use uuid::Uuid;
4 4
5-#[derive(sqlx::FromRow)]5+#[derive(Clone, sqlx::FromRow)]
6 pub struct StatRow {6 pub struct StatRow {
7 pub user_id: Uuid,7 pub user_id: Uuid,
8 /// Sert à faire apparaître les ex-membres qui ont des temps (historique intact).8 /// Sert à faire apparaître les ex-membres qui ont des temps (historique intact).
@@ -14,20 +14,23 @@ 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 »).17+/// Total d'un utilisateur sur un jour, par groupe de catégories (graphique
18-/// Pas de username : le nom vient de la liste des membres classés par18+/// « Activité de la semaine », un panneau par scope). Pas de username : le
19-/// adhésion (les ex-membres de la semaine sont forcément dans `totals`).19+/// nom vient de la liste des membres classés par adhésion (les ex-membres
20-#[derive(sqlx::FromRow)]20+/// de la semaine sont forcément dans `totals`).
21+#[derive(Clone, sqlx::FromRow)]
21 pub struct DayStatRow {22 pub struct DayStatRow {
22 pub user_id: Uuid,23 pub user_id: Uuid,
23 pub entry_date: NaiveDate,24 pub entry_date: NaiveDate,
25+ /// Groupe de la catégorie — None = statistiques de l'équipe.
26+ pub group_name: Option<String>,
24 pub total: i64,27 pub total: i64,
25 pub entries: i64,28 pub entries: i64,
26 }29 }
27 30
28-/// Totaux par jour × utilisateur sur [from, to). Le JOIN categories rattache31+/// Totaux par jour × utilisateur × groupe sur [from, to). Le JOIN categories
29-/// à l'équipe : les temps des ex-membres et des catégories archivées restent32+/// rattache à l'équipe : les temps des ex-membres et des catégories
30-/// inclus, comme dans `totals`.33+/// archivées restent inclus, comme dans `totals`.
31 pub async fn totals_by_day(34 pub async fn totals_by_day(
32 pool: &PgPool,35 pool: &PgPool,
33 team_id: Uuid,36 team_id: Uuid,
@@ -35,12 +38,12 @@ pub async fn totals_by_day(
35 to: NaiveDate,38 to: NaiveDate,
36 ) -> sqlx::Result<Vec<DayStatRow>> {39 ) -> sqlx::Result<Vec<DayStatRow>> {
37 sqlx::query_as(40 sqlx::query_as(
38- "SELECT e.user_id, e.entry_date,41+ "SELECT e.user_id, e.entry_date, t.group_name,
39 SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries42 SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries
40 FROM time_entries e43 FROM time_entries e
41 JOIN categories t ON t.id = e.category_id44 JOIN categories t ON t.id = e.category_id
42 WHERE t.team_id = $1 AND e.entry_date >= $2 AND e.entry_date < $345 WHERE t.team_id = $1 AND e.entry_date >= $2 AND e.entry_date < $3
43- GROUP BY e.user_id, e.entry_date",46+ GROUP BY e.user_id, e.entry_date, t.group_name",
44 )47 )
45 .bind(team_id)48 .bind(team_id)
46 .bind(from)49 .bind(from)
modified src/db/users.rs +1 -1
@@ -1,7 +1,7 @@
11 use sqlx::PgPool;
22 use uuid::Uuid;
33
4-#[derive(sqlx::FromRow)]
4+#[derive(Clone, sqlx::FromRow)]
55 pub struct UserRow {
66 pub id: Uuid,
77 pub username: String,
@@ -1,7 +1,7 @@
1 use sqlx::PgPool;1 use sqlx::PgPool;
2 use uuid::Uuid;2 use uuid::Uuid;
3 3
4-#[derive(sqlx::FromRow)]4+#[derive(Clone, sqlx::FromRow)]
5 pub struct UserRow {5 pub struct UserRow {
6 pub id: Uuid,6 pub id: Uuid,
7 pub username: String,7 pub username: String,
modified src/i18n.rs +38 -0
@@ -170,6 +170,17 @@ pub struct Tr {
170170 pub toast_cat_restored: &'static str,
171171 pub err_cat_dup: &'static str,
172172 pub err_cat_empty: &'static str,
173+ // ─── groupes de catégories ───
174+ pub groups_title: &'static str,
175+ pub groups_hint: &'static str,
176+ pub group_name_label: &'static str,
177+ pub group_name_ph: &'static str,
178+ pub set_apart: &'static str,
179+ /// p2 : « Retirer {catégorie} du groupe {groupe} ».
180+ pub ungroup_aria_pat: &'static str,
181+ pub toast_grouped_pat: &'static str,
182+ pub toast_ungrouped_pat: &'static str,
183+ pub scope_pills_aria: &'static str,
173184 // ─── réglages ───
174185 pub title_settings: &'static str,
175186 pub invitation_kicker: &'static str,
@@ -427,6 +438,15 @@ pub static FR: Tr = Tr {
427438 toast_cat_restored: "Catégorie restaurée",
428439 err_cat_dup: "Une catégorie porte déjà ce nom",
429440 err_cat_empty: "Le nom ne peut pas être vide",
441+ groups_title: "Groupes",
442+ groups_hint: "Un groupe met des catégories à part : leur temps sort des statistiques de l'équipe et se lit dans une vue dédiée. Utile pour un sujet qu'on veut suivre séparément, rarement nécessaire.",
443+ group_name_label: "Nom du groupe",
444+ group_name_ph: "ex. Soin de Gisèle",
445+ set_apart: "Mettre à part",
446+ ungroup_aria_pat: "Retirer {} du groupe {}",
447+ toast_grouped_pat: "Mise à part dans « {} »",
448+ toast_ungrouped_pat: "{} revient dans les statistiques de l'équipe",
449+ scope_pills_aria: "Périmètre des statistiques",
430450 title_settings: "Paramètres",
431451 invitation_kicker: "Invitation",
432452 invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».",
@@ -649,6 +669,15 @@ pub static EN: Tr = Tr {
649669 toast_cat_restored: "Category restored",
650670 err_cat_dup: "A category already has this name",
651671 err_cat_empty: "The name cannot be empty",
672+ groups_title: "Groups",
673+ groups_hint: "A group sets categories apart: their time leaves the team's statistics and lives in a dedicated view. Useful for a topic you want to track separately — rarely needed.",
674+ group_name_label: "Group name",
675+ group_name_ph: "e.g. Care for Gisèle",
676+ set_apart: "Set apart",
677+ ungroup_aria_pat: "Remove {} from the group {}",
678+ toast_grouped_pat: "Set apart in “{}”",
679+ toast_ungrouped_pat: "{} returns to the team's statistics",
680+ scope_pills_aria: "Statistics scope",
652681 title_settings: "Settings",
653682 invitation_kicker: "Invitation",
654683 invite_text_pat: "{} invites you to join the team “{}”.",
@@ -871,6 +900,15 @@ pub static DE: Tr = Tr {
871900 toast_cat_restored: "Kategorie wiederhergestellt",
872901 err_cat_dup: "Eine Kategorie trägt bereits diesen Namen",
873902 err_cat_empty: "Der Name darf nicht leer sein",
903+ groups_title: "Gruppen",
904+ groups_hint: "Eine Gruppe stellt Kategorien beiseite: ihre Zeit verlässt die Team-Statistik und erscheint in einer eigenen Ansicht. Nützlich für ein Thema, das man getrennt verfolgen will — selten nötig.",
905+ group_name_label: "Name der Gruppe",
906+ group_name_ph: "z. B. Pflege von Gisèle",
907+ set_apart: "Beiseitestellen",
908+ ungroup_aria_pat: "{} aus der Gruppe {} entfernen",
909+ toast_grouped_pat: "Beiseitegestellt in „{}“",
910+ toast_ungrouped_pat: "{} kehrt in die Team-Statistik zurück",
911+ scope_pills_aria: "Umfang der Statistik",
874912 title_settings: "Einstellungen",
875913 invitation_kicker: "Einladung",
876914 invite_text_pat: "{} lädt dich ins Team „{}“ ein.",
@@ -170,6 +170,17 @@ pub struct Tr {
170 pub toast_cat_restored: &'static str,170 pub toast_cat_restored: &'static str,
171 pub err_cat_dup: &'static str,171 pub err_cat_dup: &'static str,
172 pub err_cat_empty: &'static str,172 pub err_cat_empty: &'static str,
173+ // ─── groupes de catégories ───
174+ pub groups_title: &'static str,
175+ pub groups_hint: &'static str,
176+ pub group_name_label: &'static str,
177+ pub group_name_ph: &'static str,
178+ pub set_apart: &'static str,
179+ /// p2 : « Retirer {catégorie} du groupe {groupe} ».
180+ pub ungroup_aria_pat: &'static str,
181+ pub toast_grouped_pat: &'static str,
182+ pub toast_ungrouped_pat: &'static str,
183+ pub scope_pills_aria: &'static str,
173 // ─── réglages ───184 // ─── réglages ───
174 pub title_settings: &'static str,185 pub title_settings: &'static str,
175 pub invitation_kicker: &'static str,186 pub invitation_kicker: &'static str,
@@ -427,6 +438,15 @@ pub static FR: Tr = Tr {
427 toast_cat_restored: "Catégorie restaurée",438 toast_cat_restored: "Catégorie restaurée",
428 err_cat_dup: "Une catégorie porte déjà ce nom",439 err_cat_dup: "Une catégorie porte déjà ce nom",
429 err_cat_empty: "Le nom ne peut pas être vide",440 err_cat_empty: "Le nom ne peut pas être vide",
441+ groups_title: "Groupes",
442+ groups_hint: "Un groupe met des catégories à part : leur temps sort des statistiques de l'équipe et se lit dans une vue dédiée. Utile pour un sujet qu'on veut suivre séparément, rarement nécessaire.",
443+ group_name_label: "Nom du groupe",
444+ group_name_ph: "ex. Soin de Gisèle",
445+ set_apart: "Mettre à part",
446+ ungroup_aria_pat: "Retirer {} du groupe {}",
447+ toast_grouped_pat: "Mise à part dans « {} »",
448+ toast_ungrouped_pat: "{} revient dans les statistiques de l'équipe",
449+ scope_pills_aria: "Périmètre des statistiques",
430 title_settings: "Paramètres",450 title_settings: "Paramètres",
431 invitation_kicker: "Invitation",451 invitation_kicker: "Invitation",
432 invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».",452 invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».",
@@ -649,6 +669,15 @@ pub static EN: Tr = Tr {
649 toast_cat_restored: "Category restored",669 toast_cat_restored: "Category restored",
650 err_cat_dup: "A category already has this name",670 err_cat_dup: "A category already has this name",
651 err_cat_empty: "The name cannot be empty",671 err_cat_empty: "The name cannot be empty",
672+ groups_title: "Groups",
673+ groups_hint: "A group sets categories apart: their time leaves the team's statistics and lives in a dedicated view. Useful for a topic you want to track separately — rarely needed.",
674+ group_name_label: "Group name",
675+ group_name_ph: "e.g. Care for Gisèle",
676+ set_apart: "Set apart",
677+ ungroup_aria_pat: "Remove {} from the group {}",
678+ toast_grouped_pat: "Set apart in “{}”",
679+ toast_ungrouped_pat: "{} returns to the team's statistics",
680+ scope_pills_aria: "Statistics scope",
652 title_settings: "Settings",681 title_settings: "Settings",
653 invitation_kicker: "Invitation",682 invitation_kicker: "Invitation",
654 invite_text_pat: "{} invites you to join the team “{}”.",683 invite_text_pat: "{} invites you to join the team “{}”.",
@@ -871,6 +900,15 @@ pub static DE: Tr = Tr {
871 toast_cat_restored: "Kategorie wiederhergestellt",900 toast_cat_restored: "Kategorie wiederhergestellt",
872 err_cat_dup: "Eine Kategorie trägt bereits diesen Namen",901 err_cat_dup: "Eine Kategorie trägt bereits diesen Namen",
873 err_cat_empty: "Der Name darf nicht leer sein",902 err_cat_empty: "Der Name darf nicht leer sein",
903+ groups_title: "Gruppen",
904+ groups_hint: "Eine Gruppe stellt Kategorien beiseite: ihre Zeit verlässt die Team-Statistik und erscheint in einer eigenen Ansicht. Nützlich für ein Thema, das man getrennt verfolgen will — selten nötig.",
905+ group_name_label: "Name der Gruppe",
906+ group_name_ph: "z. B. Pflege von Gisèle",
907+ set_apart: "Beiseitestellen",
908+ ungroup_aria_pat: "{} aus der Gruppe {} entfernen",
909+ toast_grouped_pat: "Beiseitegestellt in „{}“",
910+ toast_ungrouped_pat: "{} kehrt in die Team-Statistik zurück",
911+ scope_pills_aria: "Umfang der Statistik",
874 title_settings: "Einstellungen",912 title_settings: "Einstellungen",
875 invitation_kicker: "Einladung",913 invitation_kicker: "Einladung",
876 invite_text_pat: "{} lädt dich ins Team „{}“ ein.",914 invite_text_pat: "{} lädt dich ins Team „{}“ ein.",
modified src/routes/mod.rs +16 -0
@@ -38,6 +38,7 @@ pub fn router(state: AppState) -> Router {
3838 .route("/stats", get(stats::stats_page))
3939 .route("/parametres", get(settings::settings_page))
4040 .route("/parametres/categories", get(settings::categories_page).post(settings::category_create))
41+ .route("/parametres/categories/groupe", post(settings::category_set_group))
4142 .route("/parametres/categories/{id}", post(settings::category_rename))
4243 .route(
4344 "/parametres/categories/{id}/archiver",
@@ -205,3 +206,18 @@ pub async fn shell(
205206 },
206207 })
207208 }
209+
210+/// Encodage percent d'une valeur de query string (caractères non réservés
211+/// RFC 3986 conservés) — partagé par les redirects et les liens de scope.
212+pub(crate) fn urlenc(s: &str) -> String {
213+ let mut out = String::with_capacity(s.len());
214+ for b in s.bytes() {
215+ match b {
216+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
217+ out.push(b as char)
218+ }
219+ _ => out.push_str(&format!("%{b:02X}")),
220+ }
221+ }
222+ out
223+}
@@ -38,6 +38,7 @@ pub fn router(state: AppState) -> Router {
38 .route("/stats", get(stats::stats_page))38 .route("/stats", get(stats::stats_page))
39 .route("/parametres", get(settings::settings_page))39 .route("/parametres", get(settings::settings_page))
40 .route("/parametres/categories", get(settings::categories_page).post(settings::category_create))40 .route("/parametres/categories", get(settings::categories_page).post(settings::category_create))
41+ .route("/parametres/categories/groupe", post(settings::category_set_group))
41 .route("/parametres/categories/{id}", post(settings::category_rename))42 .route("/parametres/categories/{id}", post(settings::category_rename))
42 .route(43 .route(
43 "/parametres/categories/{id}/archiver",44 "/parametres/categories/{id}/archiver",
@@ -205,3 +206,18 @@ pub async fn shell(
205 },206 },
206 })207 })
207 }208 }
209+
210+/// Encodage percent d'une valeur de query string (caractères non réservés
211+/// RFC 3986 conservés) — partagé par les redirects et les liens de scope.
212+pub(crate) fn urlenc(s: &str) -> String {
213+ let mut out = String::with_capacity(s.len());
214+ for b in s.bytes() {
215+ match b {
216+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
217+ out.push(b as char)
218+ }
219+ _ => out.push_str(&format!("%{b:02X}")),
220+ }
221+ }
222+ out
223+}
modified src/routes/settings.rs +100 -0
@@ -1,3 +1,5 @@
1+use std::collections::BTreeMap;
2+
13 use askama::Template;
24 use axum::Form;
35 use axum::extract::{Path, Query, State};
@@ -176,6 +178,26 @@ pub struct CategoryView {
176178 pub toggle_action: String,
177179 }
178180
181+/// Chip d'une catégorie dans la section Groupes (croix = retour aux stats
182+/// de l'équipe).
183+pub struct GroupChip {
184+ pub cat_id: Uuid,
185+ pub name: String,
186+ pub color: &'static str,
187+ pub remove_aria: String,
188+}
189+
190+pub struct GroupView {
191+ pub name: String,
192+ pub chips: Vec<GroupChip>,
193+}
194+
195+/// Catégorie proposable au formulaire « Mettre à part » (active, sans groupe).
196+pub struct GroupOption {
197+ pub id: Uuid,
198+ pub name: String,
199+}
200+
179201 #[derive(Template)]
180202 #[template(path = "categories.html")]
181203 pub struct CategoriesPage {
@@ -183,6 +205,8 @@ pub struct CategoriesPage {
183205 pub csrf: String,
184206 pub hint: String,
185207 pub categories: Vec<CategoryView>,
208+ pub groups: Vec<GroupView>,
209+ pub group_options: Vec<GroupOption>,
186210 }
187211
188212 pub async fn categories_page(
@@ -196,12 +220,45 @@ pub async fn categories_page(
196220 (Some("renommee"), _) => Some(tr.toast_cat_renamed.to_string()),
197221 (Some("archivee"), _) => Some(tr.toast_cat_archived.to_string()),
198222 (Some("restauree"), _) => Some(tr.toast_cat_restored.to_string()),
223+ (Some("groupee"), _) => q.nom.as_deref().map(|n| p1(tr.toast_grouped_pat, n)),
224+ (Some("degroupee"), _) => q.nom.as_deref().map(|n| p1(tr.toast_ungrouped_pat, n)),
199225 (_, Some("doublon")) => Some(tr.err_cat_dup.to_string()),
200226 (_, Some("vide")) => Some(tr.err_cat_empty.to_string()),
201227 _ => None,
202228 };
203229 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;
204230 let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
231+
232+ // Section Groupes : cartes par groupe (archivées incluses — un temps
233+ // groupé reste groupé) + catégories actives proposables au formulaire.
234+ let mut by_group: BTreeMap<String, Vec<GroupChip>> = BTreeMap::new();
235+ for t in &by_creation {
236+ if let Some(g) = &t.group_name {
237+ by_group.entry(g.clone()).or_default().push(GroupChip {
238+ cat_id: t.id,
239+ name: t.name.clone(),
240+ color: colors[&t.id],
241+ remove_aria: p2(tr.ungroup_aria_pat, &t.name, g),
242+ });
243+ }
244+ }
245+ let groups = by_group
246+ .into_iter()
247+ .map(|(name, mut chips)| {
248+ chips.sort_by(|a, b| a.name.cmp(&b.name));
249+ GroupView { name, chips }
250+ })
251+ .collect();
252+ let mut group_options: Vec<GroupOption> = by_creation
253+ .iter()
254+ .filter(|t| !t.archived && t.group_name.is_none())
255+ .map(|t| GroupOption {
256+ id: t.id,
257+ name: t.name.clone(),
258+ })
259+ .collect();
260+ group_options.sort_by(|a, b| a.name.cmp(&b.name));
261+
205262 let mut list = by_creation;
206263 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));
207264 let categories = list
@@ -219,6 +276,8 @@ pub async fn categories_page(
219276 hint: p1(tr.categories_hint_pat, &ctx.team_name),
220277 shell: shell(&state, &ctx, tr.title_categories, "add", toast).await?,
221278 categories,
279+ groups,
280+ group_options,
222281 }))
223282 }
224283
@@ -228,6 +287,47 @@ pub struct CategoryForm {
228287 csrf: String,
229288 }
230289
290+#[derive(Deserialize)]
291+pub struct GroupForm {
292+ csrf: String,
293+ categorie: Uuid,
294+ /// Vide = retirer du groupe.
295+ groupe: String,
296+ /// Nom de la catégorie, rejoué dans le toast de retrait.
297+ nom: Option<String>,
298+}
299+
300+/// Pose ou retire le groupe d'une catégorie (formulaire « Mettre à part »
301+/// et croix des chips). Garde SQL : 0 ligne = catégorie d'une autre équipe.
302+pub async fn category_set_group(
303+ State(state): State<AppState>,
304+ ctx: ActiveTeam,
305+ Form(form): Form<GroupForm>,
306+) -> Result<Redirect, AppError> {
307+ if !ctx.user.check_csrf(&state, &form.csrf) {
308+ return Err(AppError::Csrf);
309+ }
310+ let groupe = form.groupe.trim();
311+ let n = categories::set_group(
312+ &state.pool,
313+ form.categorie,
314+ ctx.team_id,
315+ (!groupe.is_empty()).then_some(groupe),
316+ )
317+ .await?;
318+ if n == 0 {
319+ return Err(AppError::NotFound);
320+ }
321+ Ok(Redirect::to(&if groupe.is_empty() {
322+ format!(
323+ "/parametres/categories?ok=degroupee&nom={}",
324+ super::urlenc(form.nom.as_deref().unwrap_or_default())
325+ )
326+ } else {
327+ format!("/parametres/categories?ok=groupee&nom={}", super::urlenc(groupe))
328+ }))
329+}
330+
231331 pub struct LangView {
232332 pub code: &'static str,
233333 pub name: &'static str,
@@ -1,3 +1,5 @@
1+use std::collections::BTreeMap;
2+
1 use askama::Template;3 use askama::Template;
2 use axum::Form;4 use axum::Form;
3 use axum::extract::{Path, Query, State};5 use axum::extract::{Path, Query, State};
@@ -176,6 +178,26 @@ pub struct CategoryView {
176 pub toggle_action: String,178 pub toggle_action: String,
177 }179 }
178 180
181+/// Chip d'une catégorie dans la section Groupes (croix = retour aux stats
182+/// de l'équipe).
183+pub struct GroupChip {
184+ pub cat_id: Uuid,
185+ pub name: String,
186+ pub color: &'static str,
187+ pub remove_aria: String,
188+}
189+
190+pub struct GroupView {
191+ pub name: String,
192+ pub chips: Vec<GroupChip>,
193+}
194+
195+/// Catégorie proposable au formulaire « Mettre à part » (active, sans groupe).
196+pub struct GroupOption {
197+ pub id: Uuid,
198+ pub name: String,
199+}
200+
179 #[derive(Template)]201 #[derive(Template)]
180 #[template(path = "categories.html")]202 #[template(path = "categories.html")]
181 pub struct CategoriesPage {203 pub struct CategoriesPage {
@@ -183,6 +205,8 @@ pub struct CategoriesPage {
183 pub csrf: String,205 pub csrf: String,
184 pub hint: String,206 pub hint: String,
185 pub categories: Vec<CategoryView>,207 pub categories: Vec<CategoryView>,
208+ pub groups: Vec<GroupView>,
209+ pub group_options: Vec<GroupOption>,
186 }210 }
187 211
188 pub async fn categories_page(212 pub async fn categories_page(
@@ -196,12 +220,45 @@ pub async fn categories_page(
196 (Some("renommee"), _) => Some(tr.toast_cat_renamed.to_string()),220 (Some("renommee"), _) => Some(tr.toast_cat_renamed.to_string()),
197 (Some("archivee"), _) => Some(tr.toast_cat_archived.to_string()),221 (Some("archivee"), _) => Some(tr.toast_cat_archived.to_string()),
198 (Some("restauree"), _) => Some(tr.toast_cat_restored.to_string()),222 (Some("restauree"), _) => Some(tr.toast_cat_restored.to_string()),
223+ (Some("groupee"), _) => q.nom.as_deref().map(|n| p1(tr.toast_grouped_pat, n)),
224+ (Some("degroupee"), _) => q.nom.as_deref().map(|n| p1(tr.toast_ungrouped_pat, n)),
199 (_, Some("doublon")) => Some(tr.err_cat_dup.to_string()),225 (_, Some("doublon")) => Some(tr.err_cat_dup.to_string()),
200 (_, Some("vide")) => Some(tr.err_cat_empty.to_string()),226 (_, Some("vide")) => Some(tr.err_cat_empty.to_string()),
201 _ => None,227 _ => None,
202 };228 };
203 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;229 let by_creation = categories::list_by_creation(&state.pool, ctx.team_id).await?;
204 let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);230 let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
231+
232+ // Section Groupes : cartes par groupe (archivées incluses — un temps
233+ // groupé reste groupé) + catégories actives proposables au formulaire.
234+ let mut by_group: BTreeMap<String, Vec<GroupChip>> = BTreeMap::new();
235+ for t in &by_creation {
236+ if let Some(g) = &t.group_name {
237+ by_group.entry(g.clone()).or_default().push(GroupChip {
238+ cat_id: t.id,
239+ name: t.name.clone(),
240+ color: colors[&t.id],
241+ remove_aria: p2(tr.ungroup_aria_pat, &t.name, g),
242+ });
243+ }
244+ }
245+ let groups = by_group
246+ .into_iter()
247+ .map(|(name, mut chips)| {
248+ chips.sort_by(|a, b| a.name.cmp(&b.name));
249+ GroupView { name, chips }
250+ })
251+ .collect();
252+ let mut group_options: Vec<GroupOption> = by_creation
253+ .iter()
254+ .filter(|t| !t.archived && t.group_name.is_none())
255+ .map(|t| GroupOption {
256+ id: t.id,
257+ name: t.name.clone(),
258+ })
259+ .collect();
260+ group_options.sort_by(|a, b| a.name.cmp(&b.name));
261+
205 let mut list = by_creation;262 let mut list = by_creation;
206 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));263 list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name)));
207 let categories = list264 let categories = list
@@ -219,6 +276,8 @@ pub async fn categories_page(
219 hint: p1(tr.categories_hint_pat, &ctx.team_name),276 hint: p1(tr.categories_hint_pat, &ctx.team_name),
220 shell: shell(&state, &ctx, tr.title_categories, "add", toast).await?,277 shell: shell(&state, &ctx, tr.title_categories, "add", toast).await?,
221 categories,278 categories,
279+ groups,
280+ group_options,
222 }))281 }))
223 }282 }
224 283
@@ -228,6 +287,47 @@ pub struct CategoryForm {
228 csrf: String,287 csrf: String,
229 }288 }
230 289
290+#[derive(Deserialize)]
291+pub struct GroupForm {
292+ csrf: String,
293+ categorie: Uuid,
294+ /// Vide = retirer du groupe.
295+ groupe: String,
296+ /// Nom de la catégorie, rejoué dans le toast de retrait.
297+ nom: Option<String>,
298+}
299+
300+/// Pose ou retire le groupe d'une catégorie (formulaire « Mettre à part »
301+/// et croix des chips). Garde SQL : 0 ligne = catégorie d'une autre équipe.
302+pub async fn category_set_group(
303+ State(state): State<AppState>,
304+ ctx: ActiveTeam,
305+ Form(form): Form<GroupForm>,
306+) -> Result<Redirect, AppError> {
307+ if !ctx.user.check_csrf(&state, &form.csrf) {
308+ return Err(AppError::Csrf);
309+ }
310+ let groupe = form.groupe.trim();
311+ let n = categories::set_group(
312+ &state.pool,
313+ form.categorie,
314+ ctx.team_id,
315+ (!groupe.is_empty()).then_some(groupe),
316+ )
317+ .await?;
318+ if n == 0 {
319+ return Err(AppError::NotFound);
320+ }
321+ Ok(Redirect::to(&if groupe.is_empty() {
322+ format!(
323+ "/parametres/categories?ok=degroupee&nom={}",
324+ super::urlenc(form.nom.as_deref().unwrap_or_default())
325+ )
326+ } else {
327+ format!("/parametres/categories?ok=groupee&nom={}", super::urlenc(groupe))
328+ }))
329+}
330+
231 pub struct LangView {331 pub struct LangView {
232 pub code: &'static str,332 pub code: &'static str,
233 pub name: &'static str,333 pub name: &'static str,
modified src/routes/stats.rs +181 -93
@@ -1,4 +1,4 @@
1-use std::collections::{BTreeMap, HashSet};
1+use std::collections::{BTreeMap, BTreeSet, HashSet};
22
33 use askama::Template;
44 use axum::extract::{Query, State};
@@ -9,24 +9,44 @@ use crate::charts::{
99 ChartNav, ChartView, DayChartView, LegendItem, Sheet, build_chart, build_day_chart, color_map,
1010 legend, person_color_map,
1111 };
12+use crate::db::stats::{DayStatRow, StatRow};
1213 use crate::db::users::UserRow;
1314 use crate::db::{stats, categories, teams};
1415 use crate::error::AppError;
1516 use crate::period::{Month, Week, Year, paris_today};
1617 use crate::render::{HtmlTemplate, Shell};
17-use crate::routes::shell;
18+use crate::routes::{shell, urlenc};
1819 use crate::state::AppState;
1920 use crate::team::ActiveTeam;
2021
22+/// Pill de navigation entre scopes (équipe puis groupes).
23+pub struct ScopePill {
24+ pub name: String,
25+ /// Ancre du panneau (« p-0 ») — le lien scrolle le carrousel, sans JS.
26+ pub anchor: String,
27+ pub current: bool,
28+}
29+
30+/// Un panneau du carrousel : la page stats complète, rescopée.
31+pub struct StatsPanel {
32+ pub anchor: String,
33+ /// URL canonique du panneau (périodes courantes + groupe) — posée par le
34+ /// JS via history.replaceState au snap.
35+ pub url: String,
36+ /// Nom du scope, affiché seulement s'il y a plusieurs panneaux.
37+ pub title: Option<String>,
38+ pub day_chart: DayChartView,
39+ pub charts: Vec<ChartView>,
40+ pub legend: Vec<LegendItem>,
41+}
42+
2143 #[derive(Template)]
2244 #[template(path = "stats.html")]
2345 pub struct StatsPage {
2446 pub shell: Shell,
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,
29- pub charts: Vec<ChartView>,
47+ pub show_pills: bool,
48+ pub pills: Vec<ScopePill>,
49+ pub panels: Vec<StatsPanel>,
3050 pub sheets: Vec<Sheet>,
3151 pub day_sheets: Vec<Sheet>,
3252 }
@@ -36,6 +56,8 @@ pub struct StatsQuery {
3656 semaine: Option<String>,
3757 mois: Option<String>,
3858 annee: Option<String>,
59+ /// Nom du groupe affiché (panneau actif) — absent = stats de l'équipe.
60+ groupe: Option<String>,
3961 }
4062
4163 fn href(week: Week, month: Month, year: Year) -> String {
@@ -75,18 +97,16 @@ pub async fn stats_page(
7597 stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?;
7698 let year_rows = stats::totals(&state.pool, ctx.team_id, Some(year_from), Some(year_to)).await?;
7799 let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?;
100+ let day_rows = stats::totals_by_day(&state.pool, ctx.team_id, week_from, week_to).await?;
78101
79- // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps
80- // sur les catégories de l'équipe (historique intact), triés par username.
102+ // Barres des 4 graphiques = membres actuels (même à zéro) ∪ ex-membres qui
103+ // ont des temps sur les catégories de l'équipe, triés par username.
81104 let mut by_name: BTreeMap<String, Uuid> = teams::members(&state.pool, ctx.team_id)
82105 .await?
83106 .into_iter()
84107 .map(|u| (u.username, u.id))
85108 .collect();
86- for r in [&week_rows, &month_rows, &total_rows]
87- .into_iter()
88- .flatten()
89- {
109+ for r in [&week_rows, &month_rows, &total_rows].into_iter().flatten() {
90110 by_name.entry(r.username.clone()).or_insert(r.user_id);
91111 }
92112 let all_users: Vec<UserRow> = by_name
@@ -94,12 +114,9 @@ pub async fn stats_page(
94114 .map(|(username, id)| UserRow { id, username })
95115 .collect();
96116
97- let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);
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?;
117+ // Graphique par jour : couleurs par rang d'adhésion, stables dans le
118+ // temps (liste complète : membres actuels + TOUS les ex-membres ayant
119+ // des temps, pas seulement ceux de la semaine affichée).
103120 let mut ranked = teams::members_by_joined(&state.pool, ctx.team_id).await?;
104121 let current: HashSet<Uuid> = ranked.iter().map(|u| u.id).collect();
105122 let ex: BTreeMap<Uuid, String> = total_rows
@@ -110,87 +127,158 @@ pub async fn stats_page(
110127 // BTreeMap → tri par user_id (UUIDv7 ≈ ordre de création des comptes),
111128 // déterministe et insensible aux renommages.
112129 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
130+ let person_colors = person_color_map(&ranked.iter().map(|u| u.id).collect::<Vec<_>>(), ctx.hc);
131+
132+ // Scopes : stats de l'équipe (catégories sans groupe) puis un panneau
133+ // par groupe, tri alphabétique. Archivées incluses : un temps groupé
134+ // reste lisible dans son groupe, l'historique ne se perd jamais.
135+ let groups: Vec<String> = all_tasks
136+ .iter()
137+ .filter_map(|t| t.group_name.clone())
138+ .collect::<BTreeSet<_>>()
119139 .into_iter()
120- .filter(|u| current.contains(&u.id) || week_users.contains(&u.id))
121140 .collect();
141+ let active_idx = q
142+ .groupe
143+ .as_deref()
144+ .and_then(|g| groups.iter().position(|x| x == g).map(|i| i + 1))
145+ .unwrap_or(0);
122146
123147 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- );
136- let (week_chart, mut sheets) = build_chart(
137- "sem",
138- tr.this_week,
139- &week.range_label(ctx.lang),
140- &all_users,
141- &week_rows,
142- &colors,
143- Some(ChartNav {
144- prev: href(week.prev(), month, year),
145- next: (week != Week::current()).then(|| href(week.next(), month, year)),
146- }),
147- tr,
148- );
149- let (month_chart, s2) = build_chart(
150- "mois",
151- tr.this_month,
152- &month.label(ctx.lang),
153- &all_users,
154- &month_rows,
155- &colors,
156- Some(ChartNav {
157- prev: href(week, month.prev(), year),
158- next: (month != Month::current()).then(|| href(week, month.next(), year)),
159- }),
160- tr,
161- );
162- let (year_chart, s3) = build_chart(
163- "an",
164- tr.this_year,
165- &year.label(ctx.lang),
166- &all_users,
167- &year_rows,
168- &colors,
169- Some(ChartNav {
170- prev: href(week, month, year.prev()),
171- next: (year != Year::current()).then(|| href(week, month, year.next())),
172- }),
173- tr,
174- );
175- let (total_chart, s4) = build_chart(
176- "tot",
177- tr.all_time,
178- tr.all_history,
179- &all_users,
180- &total_rows,
181- &colors,
182- None,
183- tr,
184- );
185- sheets.extend(s2);
186- sheets.extend(s3);
187- sheets.extend(s4);
148+ let today = paris_today();
149+ let mut pills = Vec::new();
150+ let mut panels = Vec::new();
151+ let mut sheets: Vec<Sheet> = Vec::new();
152+ let mut day_sheets: Vec<Sheet> = Vec::new();
153+
154+ let scopes: Vec<Option<&String>> =
155+ std::iter::once(None).chain(groups.iter().map(Some)).collect();
156+ let many = scopes.len() > 1;
157+ for (i, scope) in scopes.into_iter().enumerate() {
158+ let scope_name = scope.cloned().unwrap_or_else(|| ctx.team_name.clone());
159+ let anchor = format!("p-{i}");
160+ let qs = scope
161+ .map(|g| format!("&groupe={}", urlenc(g)))
162+ .unwrap_or_default();
163+ // Les liens de période du panneau embarquent groupe + ancre : au
164+ // rechargement on retombe sur le même panneau, JS ou pas.
165+ let phref =
166+ |w: Week, m: Month, y: Year| format!("{}{}#{}", href(w, m, y), qs, anchor);
167+ let key = |base: &str| {
168+ if i == 0 { base.to_string() } else { format!("g{i}{base}") }
169+ };
170+
171+ let cat_ids: HashSet<Uuid> = all_tasks
172+ .iter()
173+ .filter(|t| t.group_name.as_ref() == scope)
174+ .map(|t| t.id)
175+ .collect();
176+ let pick = |rows: &[StatRow]| -> Vec<StatRow> {
177+ rows.iter()
178+ .filter(|r| cat_ids.contains(&r.category_id))
179+ .cloned()
180+ .collect()
181+ };
182+ let wk = pick(&week_rows);
183+ let mo = pick(&month_rows);
184+ let yr = pick(&year_rows);
185+ let tot = pick(&total_rows);
186+ let days: Vec<DayStatRow> = day_rows
187+ .iter()
188+ .filter(|r| r.group_name.as_ref() == scope)
189+ .cloned()
190+ .collect();
191+
192+ // Légende et barres du panneau : membres actuels + seuls les
193+ // ex-membres actifs dans CE scope cette semaine-là.
194+ let week_users: HashSet<Uuid> = days.iter().map(|r| r.user_id).collect();
195+ let shown: Vec<UserRow> = ranked
196+ .iter()
197+ .filter(|u| current.contains(&u.id) || week_users.contains(&u.id))
198+ .cloned()
199+ .collect();
200+
201+ let (day_chart, ds) = build_day_chart(
202+ &key("jour"),
203+ week,
204+ today,
205+ &shown,
206+ &days,
207+ &person_colors,
208+ ChartNav {
209+ prev: phref(week.prev(), month, year),
210+ next: (week != Week::current()).then(|| phref(week.next(), month, year)),
211+ },
212+ ctx.lang,
213+ );
214+ day_sheets.extend(ds);
215+
216+ let (week_chart, s1) = build_chart(
217+ &key("sem"),
218+ tr.this_week,
219+ &week.range_label(ctx.lang),
220+ &all_users,
221+ &wk,
222+ &colors,
223+ Some(ChartNav {
224+ prev: phref(week.prev(), month, year),
225+ next: (week != Week::current()).then(|| phref(week.next(), month, year)),
226+ }),
227+ tr,
228+ );
229+ let (month_chart, s2) = build_chart(
230+ &key("mois"),
231+ tr.this_month,
232+ &month.label(ctx.lang),
233+ &all_users,
234+ &mo,
235+ &colors,
236+ Some(ChartNav {
237+ prev: phref(week, month.prev(), year),
238+ next: (month != Month::current()).then(|| phref(week, month.next(), year)),
239+ }),
240+ tr,
241+ );
242+ let (year_chart, s3) = build_chart(
243+ &key("an"),
244+ tr.this_year,
245+ &year.label(ctx.lang),
246+ &all_users,
247+ &yr,
248+ &colors,
249+ Some(ChartNav {
250+ prev: phref(week, month, year.prev()),
251+ next: (year != Year::current()).then(|| phref(week, month, year.next())),
252+ }),
253+ tr,
254+ );
255+ let (total_chart, s4) =
256+ build_chart(&key("tot"), tr.all_time, tr.all_history, &all_users, &tot, &colors, None, tr);
257+ sheets.extend(s1);
258+ sheets.extend(s2);
259+ sheets.extend(s3);
260+ sheets.extend(s4);
261+
262+ pills.push(ScopePill {
263+ name: scope_name.clone(),
264+ anchor: anchor.clone(),
265+ current: i == active_idx,
266+ });
267+ panels.push(StatsPanel {
268+ url: format!("{}{}", href(week, month, year), qs),
269+ anchor,
270+ title: many.then_some(scope_name),
271+ day_chart,
272+ charts: vec![week_chart, month_chart, year_chart, total_chart],
273+ legend: legend(&[&wk, &mo, &tot], &colors),
274+ });
275+ }
188276
189277 Ok(HtmlTemplate(StatsPage {
190278 shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?,
191- legend,
192- day_chart,
193- charts: vec![week_chart, month_chart, year_chart, total_chart],
279+ show_pills: many,
280+ pills,
281+ panels,
194282 sheets,
195283 day_sheets,
196284 }))
@@ -1,4 +1,4 @@
1-use std::collections::{BTreeMap, HashSet};1+use std::collections::{BTreeMap, BTreeSet, HashSet};
2 2
3 use askama::Template;3 use askama::Template;
4 use axum::extract::{Query, State};4 use axum::extract::{Query, State};
@@ -9,24 +9,44 @@ use crate::charts::{
9 ChartNav, ChartView, DayChartView, LegendItem, Sheet, build_chart, build_day_chart, color_map,9 ChartNav, ChartView, DayChartView, LegendItem, Sheet, build_chart, build_day_chart, color_map,
10 legend, person_color_map,10 legend, person_color_map,
11 };11 };
12+use crate::db::stats::{DayStatRow, StatRow};
12 use crate::db::users::UserRow;13 use crate::db::users::UserRow;
13 use crate::db::{stats, categories, teams};14 use crate::db::{stats, categories, teams};
14 use crate::error::AppError;15 use crate::error::AppError;
15 use crate::period::{Month, Week, Year, paris_today};16 use crate::period::{Month, Week, Year, paris_today};
16 use crate::render::{HtmlTemplate, Shell};17 use crate::render::{HtmlTemplate, Shell};
17-use crate::routes::shell;18+use crate::routes::{shell, urlenc};
18 use crate::state::AppState;19 use crate::state::AppState;
19 use crate::team::ActiveTeam;20 use crate::team::ActiveTeam;
20 21
22+/// Pill de navigation entre scopes (équipe puis groupes).
23+pub struct ScopePill {
24+ pub name: String,
25+ /// Ancre du panneau (« p-0 ») — le lien scrolle le carrousel, sans JS.
26+ pub anchor: String,
27+ pub current: bool,
28+}
29+
30+/// Un panneau du carrousel : la page stats complète, rescopée.
31+pub struct StatsPanel {
32+ pub anchor: String,
33+ /// URL canonique du panneau (périodes courantes + groupe) — posée par le
34+ /// JS via history.replaceState au snap.
35+ pub url: String,
36+ /// Nom du scope, affiché seulement s'il y a plusieurs panneaux.
37+ pub title: Option<String>,
38+ pub day_chart: DayChartView,
39+ pub charts: Vec<ChartView>,
40+ pub legend: Vec<LegendItem>,
41+}
42+
21 #[derive(Template)]43 #[derive(Template)]
22 #[template(path = "stats.html")]44 #[template(path = "stats.html")]
23 pub struct StatsPage {45 pub struct StatsPage {
24 pub shell: Shell,46 pub shell: Shell,
25- pub legend: Vec<LegendItem>,47+ pub show_pills: bool,
26- /// Graphique par jour « Activité de la semaine », hors de la boucle48+ pub pills: Vec<ScopePill>,
27- /// `charts` (colonnes = jours, légende propre).49+ pub panels: Vec<StatsPanel>,
28- pub day_chart: DayChartView,
29- pub charts: Vec<ChartView>,
30 pub sheets: Vec<Sheet>,50 pub sheets: Vec<Sheet>,
31 pub day_sheets: Vec<Sheet>,51 pub day_sheets: Vec<Sheet>,
32 }52 }
@@ -36,6 +56,8 @@ pub struct StatsQuery {
36 semaine: Option<String>,56 semaine: Option<String>,
37 mois: Option<String>,57 mois: Option<String>,
38 annee: Option<String>,58 annee: Option<String>,
59+ /// Nom du groupe affiché (panneau actif) — absent = stats de l'équipe.
60+ groupe: Option<String>,
39 }61 }
40 62
41 fn href(week: Week, month: Month, year: Year) -> String {63 fn href(week: Week, month: Month, year: Year) -> String {
@@ -75,18 +97,16 @@ pub async fn stats_page(
75 stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?;97 stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?;
76 let year_rows = stats::totals(&state.pool, ctx.team_id, Some(year_from), Some(year_to)).await?;98 let year_rows = stats::totals(&state.pool, ctx.team_id, Some(year_from), Some(year_to)).await?;
77 let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?;99 let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?;
100+ let day_rows = stats::totals_by_day(&state.pool, ctx.team_id, week_from, week_to).await?;
78 101
79- // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps102+ // Barres des 4 graphiques = membres actuels (même à zéro) ∪ ex-membres qui
80- // sur les catégories de l'équipe (historique intact), triés par username.103+ // ont des temps sur les catégories de l'équipe, triés par username.
81 let mut by_name: BTreeMap<String, Uuid> = teams::members(&state.pool, ctx.team_id)104 let mut by_name: BTreeMap<String, Uuid> = teams::members(&state.pool, ctx.team_id)
82 .await?105 .await?
83 .into_iter()106 .into_iter()
84 .map(|u| (u.username, u.id))107 .map(|u| (u.username, u.id))
85 .collect();108 .collect();
86- for r in [&week_rows, &month_rows, &total_rows]109+ for r in [&week_rows, &month_rows, &total_rows].into_iter().flatten() {
87- .into_iter()
88- .flatten()
89- {
90 by_name.entry(r.username.clone()).or_insert(r.user_id);110 by_name.entry(r.username.clone()).or_insert(r.user_id);
91 }111 }
92 let all_users: Vec<UserRow> = by_name112 let all_users: Vec<UserRow> = by_name
@@ -94,12 +114,9 @@ pub async fn stats_page(
94 .map(|(username, id)| UserRow { id, username })114 .map(|(username, id)| UserRow { id, username })
95 .collect();115 .collect();
96 116
97- let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);117+ // Graphique par jour : couleurs par rang d'adhésion, stables dans le
98-118+ // temps (liste complète : membres actuels + TOUS les ex-membres ayant
99- // ── Graphique par jour : couleurs par rang d'adhésion, stables dans le119+ // des temps, pas seulement ceux de la semaine affichée).
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?;120 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();121 let current: HashSet<Uuid> = ranked.iter().map(|u| u.id).collect();
105 let ex: BTreeMap<Uuid, String> = total_rows122 let ex: BTreeMap<Uuid, String> = total_rows
@@ -110,87 +127,158 @@ pub async fn stats_page(
110 // BTreeMap → tri par user_id (UUIDv7 ≈ ordre de création des comptes),127 // BTreeMap → tri par user_id (UUIDv7 ≈ ordre de création des comptes),
111 // déterministe et insensible aux renommages.128 // déterministe et insensible aux renommages.
112 ranked.extend(ex.into_iter().map(|(id, username)| UserRow { id, username }));129 ranked.extend(ex.into_iter().map(|(id, username)| UserRow { id, username }));
113- let person_colors =130+ let person_colors = person_color_map(&ranked.iter().map(|u| u.id).collect::<Vec<_>>(), ctx.hc);
114- person_color_map(&ranked.iter().map(|u| u.id).collect::<Vec<_>>(), ctx.hc);131+
115- // Barres et légende : membres actuels + seuls les ex-membres actifs132+ // Scopes : stats de l'équipe (catégories sans groupe) puis un panneau
116- // cette semaine-là (leur couleur vient de la liste complète).133+ // par groupe, tri alphabétique. Archivées incluses : un temps groupé
117- let week_users: HashSet<Uuid> = day_rows.iter().map(|r| r.user_id).collect();134+ // reste lisible dans son groupe, l'historique ne se perd jamais.
118- let shown: Vec<UserRow> = ranked135+ let groups: Vec<String> = all_tasks
136+ .iter()
137+ .filter_map(|t| t.group_name.clone())
138+ .collect::<BTreeSet<_>>()
119 .into_iter()139 .into_iter()
120- .filter(|u| current.contains(&u.id) || week_users.contains(&u.id))
121 .collect();140 .collect();
141+ let active_idx = q
142+ .groupe
143+ .as_deref()
144+ .and_then(|g| groups.iter().position(|x| x == g).map(|i| i + 1))
145+ .unwrap_or(0);
122 146
123 let tr = ctx.tr();147 let tr = ctx.tr();
124- let (day_chart, day_sheets) = build_day_chart(148+ let today = paris_today();
125- week,149+ let mut pills = Vec::new();
126- paris_today(),150+ let mut panels = Vec::new();
127- &shown,151+ let mut sheets: Vec<Sheet> = Vec::new();
128- &day_rows,152+ let mut day_sheets: Vec<Sheet> = Vec::new();
129- &person_colors,153+
130- ChartNav {154+ let scopes: Vec<Option<&String>> =
131- prev: href(week.prev(), month, year),155+ std::iter::once(None).chain(groups.iter().map(Some)).collect();
132- next: (week != Week::current()).then(|| href(week.next(), month, year)),156+ let many = scopes.len() > 1;
133- },157+ for (i, scope) in scopes.into_iter().enumerate() {
134- ctx.lang,158+ let scope_name = scope.cloned().unwrap_or_else(|| ctx.team_name.clone());
135- );159+ let anchor = format!("p-{i}");
136- let (week_chart, mut sheets) = build_chart(160+ let qs = scope
137- "sem",161+ .map(|g| format!("&groupe={}", urlenc(g)))
138- tr.this_week,162+ .unwrap_or_default();
139- &week.range_label(ctx.lang),163+ // Les liens de période du panneau embarquent groupe + ancre : au
140- &all_users,164+ // rechargement on retombe sur le même panneau, JS ou pas.
141- &week_rows,165+ let phref =
142- &colors,166+ |w: Week, m: Month, y: Year| format!("{}{}#{}", href(w, m, y), qs, anchor);
143- Some(ChartNav {167+ let key = |base: &str| {
144- prev: href(week.prev(), month, year),168+ if i == 0 { base.to_string() } else { format!("g{i}{base}") }
145- next: (week != Week::current()).then(|| href(week.next(), month, year)),169+ };
146- }),170+
147- tr,171+ let cat_ids: HashSet<Uuid> = all_tasks
148- );172+ .iter()
149- let (month_chart, s2) = build_chart(173+ .filter(|t| t.group_name.as_ref() == scope)
150- "mois",174+ .map(|t| t.id)
151- tr.this_month,175+ .collect();
152- &month.label(ctx.lang),176+ let pick = |rows: &[StatRow]| -> Vec<StatRow> {
153- &all_users,177+ rows.iter()
154- &month_rows,178+ .filter(|r| cat_ids.contains(&r.category_id))
155- &colors,179+ .cloned()
156- Some(ChartNav {180+ .collect()
157- prev: href(week, month.prev(), year),181+ };
158- next: (month != Month::current()).then(|| href(week, month.next(), year)),182+ let wk = pick(&week_rows);
159- }),183+ let mo = pick(&month_rows);
160- tr,184+ let yr = pick(&year_rows);
161- );185+ let tot = pick(&total_rows);
162- let (year_chart, s3) = build_chart(186+ let days: Vec<DayStatRow> = day_rows
163- "an",187+ .iter()
164- tr.this_year,188+ .filter(|r| r.group_name.as_ref() == scope)
165- &year.label(ctx.lang),189+ .cloned()
166- &all_users,190+ .collect();
167- &year_rows,191+
168- &colors,192+ // Légende et barres du panneau : membres actuels + seuls les
169- Some(ChartNav {193+ // ex-membres actifs dans CE scope cette semaine-là.
170- prev: href(week, month, year.prev()),194+ let week_users: HashSet<Uuid> = days.iter().map(|r| r.user_id).collect();
171- next: (year != Year::current()).then(|| href(week, month, year.next())),195+ let shown: Vec<UserRow> = ranked
172- }),196+ .iter()
173- tr,197+ .filter(|u| current.contains(&u.id) || week_users.contains(&u.id))
174- );198+ .cloned()
175- let (total_chart, s4) = build_chart(199+ .collect();
176- "tot",200+
177- tr.all_time,201+ let (day_chart, ds) = build_day_chart(
178- tr.all_history,202+ &key("jour"),
179- &all_users,203+ week,
180- &total_rows,204+ today,
181- &colors,205+ &shown,
182- None,206+ &days,
183- tr,207+ &person_colors,
184- );208+ ChartNav {
185- sheets.extend(s2);209+ prev: phref(week.prev(), month, year),
186- sheets.extend(s3);210+ next: (week != Week::current()).then(|| phref(week.next(), month, year)),
187- sheets.extend(s4);211+ },
212+ ctx.lang,
213+ );
214+ day_sheets.extend(ds);
215+
216+ let (week_chart, s1) = build_chart(
217+ &key("sem"),
218+ tr.this_week,
219+ &week.range_label(ctx.lang),
220+ &all_users,
221+ &wk,
222+ &colors,
223+ Some(ChartNav {
224+ prev: phref(week.prev(), month, year),
225+ next: (week != Week::current()).then(|| phref(week.next(), month, year)),
226+ }),
227+ tr,
228+ );
229+ let (month_chart, s2) = build_chart(
230+ &key("mois"),
231+ tr.this_month,
232+ &month.label(ctx.lang),
233+ &all_users,
234+ &mo,
235+ &colors,
236+ Some(ChartNav {
237+ prev: phref(week, month.prev(), year),
238+ next: (month != Month::current()).then(|| phref(week, month.next(), year)),
239+ }),
240+ tr,
241+ );
242+ let (year_chart, s3) = build_chart(
243+ &key("an"),
244+ tr.this_year,
245+ &year.label(ctx.lang),
246+ &all_users,
247+ &yr,
248+ &colors,
249+ Some(ChartNav {
250+ prev: phref(week, month, year.prev()),
251+ next: (year != Year::current()).then(|| phref(week, month, year.next())),
252+ }),
253+ tr,
254+ );
255+ let (total_chart, s4) =
256+ build_chart(&key("tot"), tr.all_time, tr.all_history, &all_users, &tot, &colors, None, tr);
257+ sheets.extend(s1);
258+ sheets.extend(s2);
259+ sheets.extend(s3);
260+ sheets.extend(s4);
261+
262+ pills.push(ScopePill {
263+ name: scope_name.clone(),
264+ anchor: anchor.clone(),
265+ current: i == active_idx,
266+ });
267+ panels.push(StatsPanel {
268+ url: format!("{}{}", href(week, month, year), qs),
269+ anchor,
270+ title: many.then_some(scope_name),
271+ day_chart,
272+ charts: vec![week_chart, month_chart, year_chart, total_chart],
273+ legend: legend(&[&wk, &mo, &tot], &colors),
274+ });
275+ }
188 276
189 Ok(HtmlTemplate(StatsPage {277 Ok(HtmlTemplate(StatsPage {
190 shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?,278 shell: shell(&state, &ctx, tr.title_stats, "stats", None).await?,
191- legend,279+ show_pills: many,
192- day_chart,280+ pills,
193- charts: vec![week_chart, month_chart, year_chart, total_chart],281+ panels,
194 sheets,282 sheets,
195 day_sheets,283 day_sheets,
196 }))284 }))
modified src/routes/teams.rs +1 -14
@@ -15,19 +15,6 @@ fn is_unique_violation(e: &sqlx::Error) -> bool {
1515 }
1616
1717 /// Encodage minimal pour rejouer un username dans une query string.
18-fn urlenc(s: &str) -> String {
19- let mut out = String::with_capacity(s.len());
20- for b in s.bytes() {
21- match b {
22- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
23- out.push(b as char)
24- }
25- _ => out.push_str(&format!("%{b:02X}")),
26- }
27- }
28- out
29-}
30-
3118 #[derive(Deserialize)]
3219 pub struct NameForm {
3320 nom: String,
@@ -167,7 +154,7 @@ pub async fn invite_create(
167154 return Err(AppError::Csrf);
168155 }
169156 let username = form.invite.trim();
170- let enc = urlenc(username);
157+ let enc = super::urlenc(username);
171158 let Some(target) = users::find_by_username(&state.pool, username).await? else {
172159 return Ok(Redirect::to(&format!("/parametres?err=inconnu&nom={enc}")));
173160 };
@@ -15,19 +15,6 @@ fn is_unique_violation(e: &sqlx::Error) -> bool {
15 }15 }
16 16
17 /// Encodage minimal pour rejouer un username dans une query string.17 /// Encodage minimal pour rejouer un username dans une query string.
18-fn urlenc(s: &str) -> String {
19- let mut out = String::with_capacity(s.len());
20- for b in s.bytes() {
21- match b {
22- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
23- out.push(b as char)
24- }
25- _ => out.push_str(&format!("%{b:02X}")),
26- }
27- }
28- out
29-}
30-
31 #[derive(Deserialize)]18 #[derive(Deserialize)]
32 pub struct NameForm {19 pub struct NameForm {
33 nom: String,20 nom: String,
@@ -167,7 +154,7 @@ pub async fn invite_create(
167 return Err(AppError::Csrf);154 return Err(AppError::Csrf);
168 }155 }
169 let username = form.invite.trim();156 let username = form.invite.trim();
170- let enc = urlenc(username);157+ let enc = super::urlenc(username);
171 let Some(target) = users::find_by_username(&state.pool, username).await? else {158 let Some(target) = users::find_by_username(&state.pool, username).await? else {
172 return Ok(Redirect::to(&format!("/parametres?err=inconnu&nom={enc}")));159 return Ok(Redirect::to(&format!("/parametres?err=inconnu&nom={enc}")));
173 };160 };
modified templates/categories.html +48 -0
@@ -30,5 +30,53 @@
3030 </div>
3131 {% endfor %}
3232 <p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">{{ shell.tr.rename_help }}</p>
33+
34+<h2 class="groups-title">{{ shell.tr.groups_title }}</h2>
35+<p class="groups-hint">{{ shell.tr.groups_hint }}</p>
36+{% for g in groups %}
37+<div class="group-card">
38+ <div class="group-name">{{ g.name }}</div>
39+ <div class="group-chips">
40+ {% for c in g.chips %}
41+ <span class="group-chip">
42+ <span class="dot" style="background:{{ c.color }}"></span>{{ c.name }}
43+ <form method="post" action="/parametres/categories/groupe" style="margin:0;display:flex">
44+ <input type="hidden" name="csrf" value="{{ csrf }}">
45+ <input type="hidden" name="categorie" value="{{ c.cat_id }}">
46+ <input type="hidden" name="groupe" value="">
47+ <input type="hidden" name="nom" value="{{ c.name }}">
48+ <button type="submit" class="chip-x" aria-label="{{ c.remove_aria }}">
49+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"></path></svg>
50+ </button>
51+ </form>
52+ </span>
53+ {% endfor %}
54+ </div>
55+</div>
56+{% endfor %}
57+{% if !group_options.is_empty() %}
58+<form method="post" action="/parametres/categories/groupe" class="group-form">
59+ <input type="hidden" name="csrf" value="{{ csrf }}">
60+ <div class="field">
61+ <label for="grp-cat">{{ shell.tr.category_label }}</label>
62+ <select class="input" id="grp-cat" name="categorie" required>
63+ {% for o in group_options %}
64+ <option value="{{ o.id }}">{{ o.name }}</option>
65+ {% endfor %}
66+ </select>
67+ </div>
68+ <div class="field">
69+ <label for="grp-nom">{{ shell.tr.group_name_label }}</label>
70+ <input class="input" id="grp-nom" name="groupe" list="grp-suggestions" placeholder="{{ shell.tr.group_name_ph }}" required>
71+ <datalist id="grp-suggestions">
72+ {% for g in groups %}
73+ <option value="{{ g.name }}"></option>
74+ {% endfor %}
75+ </datalist>
76+ </div>
77+ <button type="submit" class="btn btn-secondary">{{ shell.tr.set_apart }}</button>
78+</form>
79+{% endif %}
80+
3381 <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">{{ shell.tr.back_add }}</a>
3482 {% endblock %}
@@ -30,5 +30,53 @@
30 </div>30 </div>
31 {% endfor %}31 {% endfor %}
32 <p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">{{ shell.tr.rename_help }}</p>32 <p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">{{ shell.tr.rename_help }}</p>
33+
34+<h2 class="groups-title">{{ shell.tr.groups_title }}</h2>
35+<p class="groups-hint">{{ shell.tr.groups_hint }}</p>
36+{% for g in groups %}
37+<div class="group-card">
38+ <div class="group-name">{{ g.name }}</div>
39+ <div class="group-chips">
40+ {% for c in g.chips %}
41+ <span class="group-chip">
42+ <span class="dot" style="background:{{ c.color }}"></span>{{ c.name }}
43+ <form method="post" action="/parametres/categories/groupe" style="margin:0;display:flex">
44+ <input type="hidden" name="csrf" value="{{ csrf }}">
45+ <input type="hidden" name="categorie" value="{{ c.cat_id }}">
46+ <input type="hidden" name="groupe" value="">
47+ <input type="hidden" name="nom" value="{{ c.name }}">
48+ <button type="submit" class="chip-x" aria-label="{{ c.remove_aria }}">
49+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"></path></svg>
50+ </button>
51+ </form>
52+ </span>
53+ {% endfor %}
54+ </div>
55+</div>
56+{% endfor %}
57+{% if !group_options.is_empty() %}
58+<form method="post" action="/parametres/categories/groupe" class="group-form">
59+ <input type="hidden" name="csrf" value="{{ csrf }}">
60+ <div class="field">
61+ <label for="grp-cat">{{ shell.tr.category_label }}</label>
62+ <select class="input" id="grp-cat" name="categorie" required>
63+ {% for o in group_options %}
64+ <option value="{{ o.id }}">{{ o.name }}</option>
65+ {% endfor %}
66+ </select>
67+ </div>
68+ <div class="field">
69+ <label for="grp-nom">{{ shell.tr.group_name_label }}</label>
70+ <input class="input" id="grp-nom" name="groupe" list="grp-suggestions" placeholder="{{ shell.tr.group_name_ph }}" required>
71+ <datalist id="grp-suggestions">
72+ {% for g in groups %}
73+ <option value="{{ g.name }}"></option>
74+ {% endfor %}
75+ </datalist>
76+ </div>
77+ <button type="submit" class="btn btn-secondary">{{ shell.tr.set_apart }}</button>
78+</form>
79+{% endif %}
80+
33 <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">{{ shell.tr.back_add }}</a>81 <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/temps/nouveau">{{ shell.tr.back_add }}</a>
34 {% endblock %}82 {% endblock %}
modified templates/stats.html +139 -89
@@ -2,107 +2,157 @@
22 {% block content %}
33 <a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0;font-size:16px;min-height:44px" href="/temps">{{ shell.tr.my_entries_arrow }}</a>
44
5-<section class="chart-section day-chart">
6- <div class="chart-head">
7- <h2>{{ day_chart.title }}</h2>
8- <div class="chart-nav">
9- <a class="btn btn-secondary btn-icon-sm" href="{{ day_chart.prev }}" aria-label="{{ shell.tr.prev_period }}">
10- <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>
11- </a>
12- {% if let Some(next) = day_chart.next %}
13- <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
14- <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>
15- </a>
16- {% else %}
17- <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
18- <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>
19- </span>
5+{% if show_pills %}
6+<nav class="scope-pills" aria-label="{{ shell.tr.scope_pills_aria }}">
7+ {% for pill in pills %}
8+ <a class="scope-pill" href="#{{ pill.anchor }}" {% if pill.current %}aria-current="true"{% endif %}>{{ pill.name }}</a>
9+ {% endfor %}
10+</nav>
11+{% endif %}
12+
13+<div class="scopes" id="scopes">
14+ {% for p in panels %}
15+ <div class="scope-panel" id="{{ p.anchor }}" data-url="{{ p.url }}">
16+ {% if let Some(t) = p.title %}
17+ <p class="scope-title">{{ t }}</p>
18+ {% endif %}
19+
20+ <section class="chart-section day-chart">
21+ <div class="chart-head">
22+ <h2>{{ p.day_chart.title }}</h2>
23+ <div class="chart-nav">
24+ <a class="btn btn-secondary btn-icon-sm" href="{{ p.day_chart.prev }}" aria-label="{{ shell.tr.prev_period }}">
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="M15 18l-6-6 6-6"></path></svg>
26+ </a>
27+ {% if let Some(next) = p.day_chart.next %}
28+ <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
29+ <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>
30+ </a>
31+ {% else %}
32+ <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
33+ <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>
34+ </span>
35+ {% endif %}
36+ </div>
37+ </div>
38+ <p class="chart-range">{{ p.day_chart.range }}</p>
39+ {% if !p.day_chart.legend.is_empty() %}
40+ <ul class="legend legend-people">
41+ {% for item in p.day_chart.legend %}
42+ <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
43+ {% endfor %}
44+ </ul>
2045 {% endif %}
21- </div>
22- </div>
23- <p class="chart-range">{{ day_chart.range }}</p>
24- {% if !day_chart.legend.is_empty() %}
25- <ul class="legend legend-people">
26- {% for item in day_chart.legend %}
27- <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
28- {% endfor %}
29- </ul>
30- {% endif %}
31- {% if day_chart.empty %}
32- <p class="chart-empty">{{ shell.tr.no_time_week }}</p>
33- {% endif %}
34- <div class="cols cols-7">
35- {% for col in day_chart.cols %}
36- <div class="col{% if col.muted %} col-muted{% endif %}">
37- {% if let Some(t) = col.total %}
38- <span class="total">{{ t }}</span>
46+ {% if p.day_chart.empty %}
47+ <p class="chart-empty">{{ shell.tr.no_time_week }}</p>
3948 {% endif %}
40- {% if !col.muted %}
41- <div class="col-bar" style="--h:{{ col.height_pct }}">
42- {% for s in col.segs %}
43- <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"
44- title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>
49+ <div class="cols cols-7">
50+ {% for col in p.day_chart.cols %}
51+ <div class="col{% if col.muted %} col-muted{% endif %}">
52+ {% if let Some(t) = col.total %}
53+ <span class="total">{{ t }}</span>
54+ {% endif %}
55+ {% if !col.muted %}
56+ <div class="col-bar" style="--h:{{ col.height_pct }}">
57+ {% for s in col.segs %}
58+ <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"
59+ title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>
60+ {% endfor %}
61+ </div>
62+ {% endif %}
63+ <span class="user{% if col.today %} day-today{% endif %}">{{ col.label }}</span>
64+ </div>
4565 {% endfor %}
4666 </div>
47- {% endif %}
48- <span class="user{% if col.today %} day-today{% endif %}">{{ col.label }}</span>
49- </div>
50- {% endfor %}
51- </div>
52-</section>
67+ </section>
5368
54-{% if !legend.is_empty() %}
55-<ul class="legend">
56- {% for item in legend %}
57- <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
58- {% endfor %}
59-</ul>
60-{% endif %}
61-
62-{% for c in charts %}
63-<section class="chart-section">
64- <div class="chart-head">
65- <h2>{{ c.title }}</h2>
66- {% if c.nav %}
67- <div class="chart-nav">
68- <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="{{ shell.tr.prev_period }}">
69- <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>
70- </a>
71- {% if let Some(next) = c.next %}
72- <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
73- <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>
74- </a>
75- {% else %}
76- <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
77- <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>
78- </span>
79- {% endif %}
80- </div>
69+ {% if !p.legend.is_empty() %}
70+ <ul class="legend">
71+ {% for item in p.legend %}
72+ <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
73+ {% endfor %}
74+ </ul>
8175 {% endif %}
82- </div>
83- <p class="chart-range">{{ c.range }}</p>
8476
85- {% if c.empty %}
86- <p class="chart-empty">{{ shell.tr.no_time_period }}</p>
87- {% else %}
88- <div class="cols">
89- {% for row in c.rows %}
90- <div class="col">
91- <span class="total">{{ row.total }}</span>
92- <div class="col-bar" style="--h:{{ row.height_pct }}">
93- {% for s in row.segs %}
94- <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"
95- title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>
77+ {% for c in p.charts %}
78+ <section class="chart-section">
79+ <div class="chart-head">
80+ <h2>{{ c.title }}</h2>
81+ {% if c.nav %}
82+ <div class="chart-nav">
83+ <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="{{ shell.tr.prev_period }}">
84+ <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>
85+ </a>
86+ {% if let Some(next) = c.next %}
87+ <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
88+ <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>
89+ </a>
90+ {% else %}
91+ <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
92+ <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>
93+ </span>
94+ {% endif %}
95+ </div>
96+ {% endif %}
97+ </div>
98+ <p class="chart-range">{{ c.range }}</p>
99+
100+ {% if c.empty %}
101+ <p class="chart-empty">{{ shell.tr.no_time_period }}</p>
102+ {% else %}
103+ <div class="cols">
104+ {% for row in c.rows %}
105+ <div class="col">
106+ <span class="total">{{ row.total }}</span>
107+ <div class="col-bar" style="--h:{{ row.height_pct }}">
108+ {% for s in row.segs %}
109+ <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"
110+ title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>
111+ {% endfor %}
112+ </div>
113+ <span class="user">{{ row.user }}</span>
114+ </div>
96115 {% endfor %}
97116 </div>
98- <span class="user">{{ row.user }}</span>
99- </div>
117+ {% endif %}
118+ </section>
100119 {% endfor %}
101120 </div>
102- {% endif %}
103-</section>
104-{% endfor %}
121+ {% endfor %}
122+</div>
105123
124+{% if show_pills %}
125+<script>
126+// Amélioration progressive du carrousel de scopes : sans JS, les pills
127+// (ancres) scrollent le conteneur et les liens de période embarquent
128+// groupe + ancre. Le JS ajoute l'état actif des pills au swipe et pose
129+// l'URL canonique du panneau (history.replaceState — jamais de fetch).
130+(function () {
131+ var snap = document.getElementById('scopes');
132+ var pills = Array.prototype.slice.call(document.querySelectorAll('.scope-pill'));
133+ var panels = Array.prototype.slice.call(snap.children);
134+ function idx() { return Math.round(snap.scrollLeft / Math.max(1, snap.clientWidth)); }
135+ function apply(i) {
136+ pills.forEach(function (p, j) {
137+ if (j === i) { p.setAttribute('aria-current', 'true'); } else { p.removeAttribute('aria-current'); }
138+ });
139+ var url = panels[i] && panels[i].dataset.url;
140+ if (url) { history.replaceState(null, '', url + location.hash); }
141+ }
142+ function settle() { apply(idx()); }
143+ if ('onscrollend' in snap) { snap.addEventListener('scrollend', settle); }
144+ else {
145+ var t;
146+ snap.addEventListener('scroll', function () { clearTimeout(t); t = setTimeout(settle, 120); });
147+ }
148+ // Arrivée avec ?groupe= (pill courante posée par le serveur) sans ancre :
149+ // on se place sur le panneau, instantanément.
150+ var start = 0;
151+ pills.forEach(function (p, j) { if (p.hasAttribute('aria-current')) { start = j; } });
152+ if (start > 0 && !location.hash) { snap.scrollTo({ left: start * snap.clientWidth, behavior: 'instant' }); }
153+})();
154+</script>
155+{% endif %}
106156 {% endblock %}
107157
108158 {% block sheets %}
@@ -2,107 +2,157 @@
2 {% block content %}2 {% block content %}
3 <a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0;font-size:16px;min-height:44px" href="/temps">{{ shell.tr.my_entries_arrow }}</a>3 <a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0;font-size:16px;min-height:44px" href="/temps">{{ shell.tr.my_entries_arrow }}</a>
4 4
5-<section class="chart-section day-chart">5+{% if show_pills %}
6- <div class="chart-head">6+<nav class="scope-pills" aria-label="{{ shell.tr.scope_pills_aria }}">
7- <h2>{{ day_chart.title }}</h2>7+ {% for pill in pills %}
8- <div class="chart-nav">8+ <a class="scope-pill" href="#{{ pill.anchor }}" {% if pill.current %}aria-current="true"{% endif %}>{{ pill.name }}</a>
9- <a class="btn btn-secondary btn-icon-sm" href="{{ day_chart.prev }}" aria-label="{{ shell.tr.prev_period }}">9+ {% endfor %}
10- <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>10+</nav>
11- </a>11+{% endif %}
12- {% if let Some(next) = day_chart.next %}12+
13- <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">13+<div class="scopes" id="scopes">
14- <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>14+ {% for p in panels %}
15- </a>15+ <div class="scope-panel" id="{{ p.anchor }}" data-url="{{ p.url }}">
16- {% else %}16+ {% if let Some(t) = p.title %}
17- <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">17+ <p class="scope-title">{{ t }}</p>
18- <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>18+ {% endif %}
19- </span>19+
20+ <section class="chart-section day-chart">
21+ <div class="chart-head">
22+ <h2>{{ p.day_chart.title }}</h2>
23+ <div class="chart-nav">
24+ <a class="btn btn-secondary btn-icon-sm" href="{{ p.day_chart.prev }}" aria-label="{{ shell.tr.prev_period }}">
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="M15 18l-6-6 6-6"></path></svg>
26+ </a>
27+ {% if let Some(next) = p.day_chart.next %}
28+ <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
29+ <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>
30+ </a>
31+ {% else %}
32+ <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
33+ <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>
34+ </span>
35+ {% endif %}
36+ </div>
37+ </div>
38+ <p class="chart-range">{{ p.day_chart.range }}</p>
39+ {% if !p.day_chart.legend.is_empty() %}
40+ <ul class="legend legend-people">
41+ {% for item in p.day_chart.legend %}
42+ <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
43+ {% endfor %}
44+ </ul>
20 {% endif %}45 {% endif %}
21- </div>46+ {% if p.day_chart.empty %}
22- </div>47+ <p class="chart-empty">{{ shell.tr.no_time_week }}</p>
23- <p class="chart-range">{{ day_chart.range }}</p>
24- {% if !day_chart.legend.is_empty() %}
25- <ul class="legend legend-people">
26- {% for item in day_chart.legend %}
27- <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
28- {% endfor %}
29- </ul>
30- {% endif %}
31- {% if day_chart.empty %}
32- <p class="chart-empty">{{ shell.tr.no_time_week }}</p>
33- {% endif %}
34- <div class="cols cols-7">
35- {% for col in day_chart.cols %}
36- <div class="col{% if col.muted %} col-muted{% endif %}">
37- {% if let Some(t) = col.total %}
38- <span class="total">{{ t }}</span>
39 {% endif %}48 {% endif %}
40- {% if !col.muted %}49+ <div class="cols cols-7">
41- <div class="col-bar" style="--h:{{ col.height_pct }}">50+ {% for col in p.day_chart.cols %}
42- {% for s in col.segs %}51+ <div class="col{% if col.muted %} col-muted{% endif %}">
43- <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"52+ {% if let Some(t) = col.total %}
44- title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>53+ <span class="total">{{ t }}</span>
54+ {% endif %}
55+ {% if !col.muted %}
56+ <div class="col-bar" style="--h:{{ col.height_pct }}">
57+ {% for s in col.segs %}
58+ <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"
59+ title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>
60+ {% endfor %}
61+ </div>
62+ {% endif %}
63+ <span class="user{% if col.today %} day-today{% endif %}">{{ col.label }}</span>
64+ </div>
45 {% endfor %}65 {% endfor %}
46 </div>66 </div>
47- {% endif %}67+ </section>
48- <span class="user{% if col.today %} day-today{% endif %}">{{ col.label }}</span>
49- </div>
50- {% endfor %}
51- </div>
52-</section>
53 68
54-{% if !legend.is_empty() %}69+ {% if !p.legend.is_empty() %}
55-<ul class="legend">70+ <ul class="legend">
56- {% for item in legend %}71+ {% for item in p.legend %}
57- <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>72+ <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li>
58- {% endfor %}73+ {% endfor %}
59-</ul>74+ </ul>
60-{% endif %}
61-
62-{% for c in charts %}
63-<section class="chart-section">
64- <div class="chart-head">
65- <h2>{{ c.title }}</h2>
66- {% if c.nav %}
67- <div class="chart-nav">
68- <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="{{ shell.tr.prev_period }}">
69- <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>
70- </a>
71- {% if let Some(next) = c.next %}
72- <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
73- <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>
74- </a>
75- {% else %}
76- <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
77- <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>
78- </span>
79- {% endif %}
80- </div>
81 {% endif %}75 {% endif %}
82- </div>
83- <p class="chart-range">{{ c.range }}</p>
84 76
85- {% if c.empty %}77+ {% for c in p.charts %}
86- <p class="chart-empty">{{ shell.tr.no_time_period }}</p>78+ <section class="chart-section">
87- {% else %}79+ <div class="chart-head">
88- <div class="cols">80+ <h2>{{ c.title }}</h2>
89- {% for row in c.rows %}81+ {% if c.nav %}
90- <div class="col">82+ <div class="chart-nav">
91- <span class="total">{{ row.total }}</span>83+ <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="{{ shell.tr.prev_period }}">
92- <div class="col-bar" style="--h:{{ row.height_pct }}">84+ <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>
93- {% for s in row.segs %}85+ </a>
94- <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"86+ {% if let Some(next) = c.next %}
95- title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>87+ <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="{{ shell.tr.next_period }}">
88+ <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>
89+ </a>
90+ {% else %}
91+ <span class="btn btn-secondary btn-icon-sm" aria-disabled="true">
92+ <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>
93+ </span>
94+ {% endif %}
95+ </div>
96+ {% endif %}
97+ </div>
98+ <p class="chart-range">{{ c.range }}</p>
99+
100+ {% if c.empty %}
101+ <p class="chart-empty">{{ shell.tr.no_time_period }}</p>
102+ {% else %}
103+ <div class="cols">
104+ {% for row in c.rows %}
105+ <div class="col">
106+ <span class="total">{{ row.total }}</span>
107+ <div class="col-bar" style="--h:{{ row.height_pct }}">
108+ {% for s in row.segs %}
109+ <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}"
110+ title="{{ s.aria }}" aria-label="{{ s.aria }}"></a>
111+ {% endfor %}
112+ </div>
113+ <span class="user">{{ row.user }}</span>
114+ </div>
96 {% endfor %}115 {% endfor %}
97 </div>116 </div>
98- <span class="user">{{ row.user }}</span>117+ {% endif %}
99- </div>118+ </section>
100 {% endfor %}119 {% endfor %}
101 </div>120 </div>
102- {% endif %}121+ {% endfor %}
103-</section>122+</div>
104-{% endfor %}
105 123
124+{% if show_pills %}
125+<script>
126+// Amélioration progressive du carrousel de scopes : sans JS, les pills
127+// (ancres) scrollent le conteneur et les liens de période embarquent
128+// groupe + ancre. Le JS ajoute l'état actif des pills au swipe et pose
129+// l'URL canonique du panneau (history.replaceState — jamais de fetch).
130+(function () {
131+ var snap = document.getElementById('scopes');
132+ var pills = Array.prototype.slice.call(document.querySelectorAll('.scope-pill'));
133+ var panels = Array.prototype.slice.call(snap.children);
134+ function idx() { return Math.round(snap.scrollLeft / Math.max(1, snap.clientWidth)); }
135+ function apply(i) {
136+ pills.forEach(function (p, j) {
137+ if (j === i) { p.setAttribute('aria-current', 'true'); } else { p.removeAttribute('aria-current'); }
138+ });
139+ var url = panels[i] && panels[i].dataset.url;
140+ if (url) { history.replaceState(null, '', url + location.hash); }
141+ }
142+ function settle() { apply(idx()); }
143+ if ('onscrollend' in snap) { snap.addEventListener('scrollend', settle); }
144+ else {
145+ var t;
146+ snap.addEventListener('scroll', function () { clearTimeout(t); t = setTimeout(settle, 120); });
147+ }
148+ // Arrivée avec ?groupe= (pill courante posée par le serveur) sans ancre :
149+ // on se place sur le panneau, instantanément.
150+ var start = 0;
151+ pills.forEach(function (p, j) { if (p.hasAttribute('aria-current')) { start = j; } });
152+ if (start > 0 && !location.hash) { snap.scrollTo({ left: start * snap.clientWidth, behavior: 'instant' }); }
153+})();
154+</script>
155+{% endif %}
106 {% endblock %}156 {% endblock %}
107 157
108 {% block sheets %}158 {% block sheets %}