judu/ex-egopublic Fork
⑂ 1a03e03
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): panneau « Toutes les catégories » en dernière position du carrouselUnverified

Ignore les groupes : toutes les catégories de l'équipe, archivées
incluses. N'apparaît que si l'équipe a au moins un groupe (sinon il
doublerait le panneau de l'équipe). URL ?tout=1, prioritaire sur
?groupe=. Les lignes par jour, séparées par groupe en SQL, sont
fusionnées par (jour, personne) avant le graphique de la semaine.
Julien Durillon committed 2026-09-21T23:48:55+02:00 Browse files
1a03e03 parent: 16d2bee
modified src/i18n.rs +5 -0
@@ -236,6 +236,7 @@ pub struct Tr {
236236 pub toast_grouped_pat: &'static str,
237237 pub toast_ungrouped_pat: &'static str,
238238 pub scope_pills_aria: &'static str,
239+ pub scope_all: &'static str,
239240 // ─── réglages ───
240241 pub title_settings: &'static str,
241242 pub invitation_kicker: &'static str,
@@ -618,6 +619,7 @@ pub static FR: Tr = Tr {
618619 toast_grouped_pat: "Mise à part dans « {} »",
619620 toast_ungrouped_pat: "{} revient dans les statistiques de l'équipe",
620621 scope_pills_aria: "Périmètre des statistiques",
622+ scope_all: "Toutes les catégories",
621623 title_settings: "Paramètres",
622624 invitation_kicker: "Invitation",
623625 invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».",
@@ -945,6 +947,7 @@ pub static EN: Tr = Tr {
945947 toast_grouped_pat: "Set apart in “{}”",
946948 toast_ungrouped_pat: "{} returns to the team's statistics",
947949 scope_pills_aria: "Statistics scope",
950+ scope_all: "All categories",
948951 title_settings: "Settings",
949952 invitation_kicker: "Invitation",
950953 invite_text_pat: "{} invites you to join the team “{}”.",
@@ -1272,6 +1275,7 @@ pub static DE: Tr = Tr {
12721275 toast_grouped_pat: "Beiseitegestellt in „{}“",
12731276 toast_ungrouped_pat: "{} kehrt in die Team-Statistik zurück",
12741277 scope_pills_aria: "Umfang der Statistik",
1278+ scope_all: "Alle Kategorien",
12751279 title_settings: "Einstellungen",
12761280 invitation_kicker: "Einladung",
12771281 invite_text_pat: "{} lädt dich ins Team „{}“ ein.",
@@ -1599,6 +1603,7 @@ pub static PT_BR: Tr = Tr {
15991603 toast_grouped_pat: "Separada em “{}”",
16001604 toast_ungrouped_pat: "{} volta para as estatísticas da equipe",
16011605 scope_pills_aria: "Escopo das estatísticas",
1606+ scope_all: "Todas as categorias",
16021607 title_settings: "Configurações",
16031608 invitation_kicker: "Convite",
16041609 invite_text_pat: "{} convida você para entrar na equipe “{}”.",
@@ -236,6 +236,7 @@ pub struct Tr {
236 pub toast_grouped_pat: &'static str,236 pub toast_grouped_pat: &'static str,
237 pub toast_ungrouped_pat: &'static str,237 pub toast_ungrouped_pat: &'static str,
238 pub scope_pills_aria: &'static str,238 pub scope_pills_aria: &'static str,
239+ pub scope_all: &'static str,
239 // ─── réglages ───240 // ─── réglages ───
240 pub title_settings: &'static str,241 pub title_settings: &'static str,
241 pub invitation_kicker: &'static str,242 pub invitation_kicker: &'static str,
@@ -618,6 +619,7 @@ pub static FR: Tr = Tr {
618 toast_grouped_pat: "Mise à part dans « {} »",619 toast_grouped_pat: "Mise à part dans « {} »",
619 toast_ungrouped_pat: "{} revient dans les statistiques de l'équipe",620 toast_ungrouped_pat: "{} revient dans les statistiques de l'équipe",
620 scope_pills_aria: "Périmètre des statistiques",621 scope_pills_aria: "Périmètre des statistiques",
622+ scope_all: "Toutes les catégories",
621 title_settings: "Paramètres",623 title_settings: "Paramètres",
622 invitation_kicker: "Invitation",624 invitation_kicker: "Invitation",
623 invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».",625 invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».",
@@ -945,6 +947,7 @@ pub static EN: Tr = Tr {
945 toast_grouped_pat: "Set apart in “{}”",947 toast_grouped_pat: "Set apart in “{}”",
946 toast_ungrouped_pat: "{} returns to the team's statistics",948 toast_ungrouped_pat: "{} returns to the team's statistics",
947 scope_pills_aria: "Statistics scope",949 scope_pills_aria: "Statistics scope",
950+ scope_all: "All categories",
948 title_settings: "Settings",951 title_settings: "Settings",
949 invitation_kicker: "Invitation",952 invitation_kicker: "Invitation",
950 invite_text_pat: "{} invites you to join the team “{}”.",953 invite_text_pat: "{} invites you to join the team “{}”.",
@@ -1272,6 +1275,7 @@ pub static DE: Tr = Tr {
1272 toast_grouped_pat: "Beiseitegestellt in „{}“",1275 toast_grouped_pat: "Beiseitegestellt in „{}“",
1273 toast_ungrouped_pat: "{} kehrt in die Team-Statistik zurück",1276 toast_ungrouped_pat: "{} kehrt in die Team-Statistik zurück",
1274 scope_pills_aria: "Umfang der Statistik",1277 scope_pills_aria: "Umfang der Statistik",
1278+ scope_all: "Alle Kategorien",
1275 title_settings: "Einstellungen",1279 title_settings: "Einstellungen",
1276 invitation_kicker: "Einladung",1280 invitation_kicker: "Einladung",
1277 invite_text_pat: "{} lädt dich ins Team „{}“ ein.",1281 invite_text_pat: "{} lädt dich ins Team „{}“ ein.",
@@ -1599,6 +1603,7 @@ pub static PT_BR: Tr = Tr {
1599 toast_grouped_pat: "Separada em “{}”",1603 toast_grouped_pat: "Separada em “{}”",
1600 toast_ungrouped_pat: "{} volta para as estatísticas da equipe",1604 toast_ungrouped_pat: "{} volta para as estatísticas da equipe",
1601 scope_pills_aria: "Escopo das estatísticas",1605 scope_pills_aria: "Escopo das estatísticas",
1606+ scope_all: "Todas as categorias",
1602 title_settings: "Configurações",1607 title_settings: "Configurações",
1603 invitation_kicker: "Convite",1608 invitation_kicker: "Convite",
1604 invite_text_pat: "{} convida você para entrar na equipe “{}”.",1609 invite_text_pat: "{} convida você para entrar na equipe “{}”.",
modified src/routes/stats.rs +59 -17
@@ -60,6 +60,27 @@ pub struct StatsQuery {
6060 annee: Option<String>,
6161 /// Nom du groupe affiché (panneau actif) — absent = stats de l'équipe.
6262 groupe: Option<String>,
63+ /// Panneau « Toutes les catégories » (dernier) — prime sur `groupe`.
64+ tout: Option<String>,
65+}
66+
67+/// Périmètre d'un panneau. `All` ignore les groupes : il n'existe que si
68+/// l'équipe en a (sinon il doublerait le panneau de l'équipe).
69+#[derive(Clone, Copy)]
70+enum Scope<'a> {
71+ Ungrouped,
72+ Group(&'a String),
73+ All,
74+}
75+
76+impl Scope<'_> {
77+ fn holds(&self, group: Option<&String>) -> bool {
78+ match self {
79+ Scope::Ungrouped => group.is_none(),
80+ Scope::Group(g) => group == Some(*g),
81+ Scope::All => true,
82+ }
83+ }
6384 }
6485
6586 fn href(week: Week, month: Month, year: Year) -> String {
@@ -144,11 +165,14 @@ pub async fn stats_page(
144165 .collect::<BTreeSet<_>>()
145166 .into_iter()
146167 .collect();
147- let active_idx = q
148- .groupe
149- .as_deref()
150- .and_then(|g| groups.iter().position(|x| x == g).map(|i| i + 1))
151- .unwrap_or(0);
168+ let active_idx = if q.tout.is_some() && !groups.is_empty() {
169+ groups.len() + 1
170+ } else {
171+ q.groupe
172+ .as_deref()
173+ .and_then(|g| groups.iter().position(|x| x == g).map(|i| i + 1))
174+ .unwrap_or(0)
175+ };
152176
153177 let tr = ctx.tr();
154178 let today_label = day_full(ctx.lang, today);
@@ -157,15 +181,25 @@ pub async fn stats_page(
157181 let mut sheets: Vec<Sheet> = Vec::new();
158182 let mut day_sheets: Vec<Sheet> = Vec::new();
159183
160- let scopes: Vec<Option<&String>> =
161- std::iter::once(None).chain(groups.iter().map(Some)).collect();
184+ let mut scopes: Vec<Scope> = std::iter::once(Scope::Ungrouped)
185+ .chain(groups.iter().map(Scope::Group))
186+ .collect();
162187 let many = scopes.len() > 1;
188+ if many {
189+ scopes.push(Scope::All);
190+ }
163191 for (i, scope) in scopes.into_iter().enumerate() {
164- let scope_name = scope.cloned().unwrap_or_else(|| ctx.team_name.clone());
192+ let scope_name = match scope {
193+ Scope::Ungrouped => ctx.team_name.clone(),
194+ Scope::Group(g) => g.clone(),
195+ Scope::All => tr.scope_all.to_string(),
196+ };
165197 let anchor = format!("p-{i}");
166- let qs = scope
167- .map(|g| format!("&groupe={}", urlenc(g)))
168- .unwrap_or_default();
198+ let qs = match scope {
199+ Scope::Ungrouped => String::new(),
200+ Scope::Group(g) => format!("&groupe={}", urlenc(g)),
201+ Scope::All => "&tout=1".to_string(),
202+ };
169203 // Les liens de période du panneau embarquent groupe + ancre : au
170204 // rechargement on retombe sur le même panneau, JS ou pas.
171205 let phref =
@@ -176,7 +210,7 @@ pub async fn stats_page(
176210
177211 let cat_ids: HashSet<Uuid> = all_tasks
178212 .iter()
179- .filter(|t| t.group_name.as_ref() == scope)
213+ .filter(|t| scope.holds(t.group_name.as_ref()))
180214 .map(|t| t.id)
181215 .collect();
182216 let pick = |rows: &[StatRow]| -> Vec<StatRow> {
@@ -190,11 +224,19 @@ pub async fn stats_page(
190224 let mo = pick(&month_rows);
191225 let yr = pick(&year_rows);
192226 let tot = pick(&total_rows);
193- let days: Vec<DayStatRow> = day_rows
194- .iter()
195- .filter(|r| r.group_name.as_ref() == scope)
196- .cloned()
197- .collect();
227+ // Une ligne par (jour, personne) : « Tout » fusionne celles que le
228+ // SQL a séparées par groupe.
229+ let mut merged: BTreeMap<(chrono::NaiveDate, Uuid), DayStatRow> = BTreeMap::new();
230+ for r in day_rows.iter().filter(|r| scope.holds(r.group_name.as_ref())) {
231+ merged
232+ .entry((r.entry_date, r.user_id))
233+ .and_modify(|m| {
234+ m.total += r.total;
235+ m.entries += r.entries;
236+ })
237+ .or_insert_with(|| r.clone());
238+ }
239+ let days: Vec<DayStatRow> = merged.into_values().collect();
198240
199241 // Légende et barres du panneau : membres actuels + seuls les
200242 // ex-membres actifs dans CE scope cette semaine-là.
@@ -60,6 +60,27 @@ pub struct StatsQuery {
60 annee: Option<String>,60 annee: Option<String>,
61 /// Nom du groupe affiché (panneau actif) — absent = stats de l'équipe.61 /// Nom du groupe affiché (panneau actif) — absent = stats de l'équipe.
62 groupe: Option<String>,62 groupe: Option<String>,
63+ /// Panneau « Toutes les catégories » (dernier) — prime sur `groupe`.
64+ tout: Option<String>,
65+}
66+
67+/// Périmètre d'un panneau. `All` ignore les groupes : il n'existe que si
68+/// l'équipe en a (sinon il doublerait le panneau de l'équipe).
69+#[derive(Clone, Copy)]
70+enum Scope<'a> {
71+ Ungrouped,
72+ Group(&'a String),
73+ All,
74+}
75+
76+impl Scope<'_> {
77+ fn holds(&self, group: Option<&String>) -> bool {
78+ match self {
79+ Scope::Ungrouped => group.is_none(),
80+ Scope::Group(g) => group == Some(*g),
81+ Scope::All => true,
82+ }
83+ }
63 }84 }
64 85
65 fn href(week: Week, month: Month, year: Year) -> String {86 fn href(week: Week, month: Month, year: Year) -> String {
@@ -144,11 +165,14 @@ pub async fn stats_page(
144 .collect::<BTreeSet<_>>()165 .collect::<BTreeSet<_>>()
145 .into_iter()166 .into_iter()
146 .collect();167 .collect();
147- let active_idx = q168+ let active_idx = if q.tout.is_some() && !groups.is_empty() {
148- .groupe169+ groups.len() + 1
149- .as_deref()170+ } else {
150- .and_then(|g| groups.iter().position(|x| x == g).map(|i| i + 1))171+ q.groupe
151- .unwrap_or(0);172+ .as_deref()
173+ .and_then(|g| groups.iter().position(|x| x == g).map(|i| i + 1))
174+ .unwrap_or(0)
175+ };
152 176
153 let tr = ctx.tr();177 let tr = ctx.tr();
154 let today_label = day_full(ctx.lang, today);178 let today_label = day_full(ctx.lang, today);
@@ -157,15 +181,25 @@ pub async fn stats_page(
157 let mut sheets: Vec<Sheet> = Vec::new();181 let mut sheets: Vec<Sheet> = Vec::new();
158 let mut day_sheets: Vec<Sheet> = Vec::new();182 let mut day_sheets: Vec<Sheet> = Vec::new();
159 183
160- let scopes: Vec<Option<&String>> =184+ let mut scopes: Vec<Scope> = std::iter::once(Scope::Ungrouped)
161- std::iter::once(None).chain(groups.iter().map(Some)).collect();185+ .chain(groups.iter().map(Scope::Group))
186+ .collect();
162 let many = scopes.len() > 1;187 let many = scopes.len() > 1;
188+ if many {
189+ scopes.push(Scope::All);
190+ }
163 for (i, scope) in scopes.into_iter().enumerate() {191 for (i, scope) in scopes.into_iter().enumerate() {
164- let scope_name = scope.cloned().unwrap_or_else(|| ctx.team_name.clone());192+ let scope_name = match scope {
193+ Scope::Ungrouped => ctx.team_name.clone(),
194+ Scope::Group(g) => g.clone(),
195+ Scope::All => tr.scope_all.to_string(),
196+ };
165 let anchor = format!("p-{i}");197 let anchor = format!("p-{i}");
166- let qs = scope198+ let qs = match scope {
167- .map(|g| format!("&groupe={}", urlenc(g)))199+ Scope::Ungrouped => String::new(),
168- .unwrap_or_default();200+ Scope::Group(g) => format!("&groupe={}", urlenc(g)),
201+ Scope::All => "&tout=1".to_string(),
202+ };
169 // Les liens de période du panneau embarquent groupe + ancre : au203 // Les liens de période du panneau embarquent groupe + ancre : au
170 // rechargement on retombe sur le même panneau, JS ou pas.204 // rechargement on retombe sur le même panneau, JS ou pas.
171 let phref =205 let phref =
@@ -176,7 +210,7 @@ pub async fn stats_page(
176 210
177 let cat_ids: HashSet<Uuid> = all_tasks211 let cat_ids: HashSet<Uuid> = all_tasks
178 .iter()212 .iter()
179- .filter(|t| t.group_name.as_ref() == scope)213+ .filter(|t| scope.holds(t.group_name.as_ref()))
180 .map(|t| t.id)214 .map(|t| t.id)
181 .collect();215 .collect();
182 let pick = |rows: &[StatRow]| -> Vec<StatRow> {216 let pick = |rows: &[StatRow]| -> Vec<StatRow> {
@@ -190,11 +224,19 @@ pub async fn stats_page(
190 let mo = pick(&month_rows);224 let mo = pick(&month_rows);
191 let yr = pick(&year_rows);225 let yr = pick(&year_rows);
192 let tot = pick(&total_rows);226 let tot = pick(&total_rows);
193- let days: Vec<DayStatRow> = day_rows227+ // Une ligne par (jour, personne) : « Tout » fusionne celles que le
194- .iter()228+ // SQL a séparées par groupe.
195- .filter(|r| r.group_name.as_ref() == scope)229+ let mut merged: BTreeMap<(chrono::NaiveDate, Uuid), DayStatRow> = BTreeMap::new();
196- .cloned()230+ for r in day_rows.iter().filter(|r| scope.holds(r.group_name.as_ref())) {
197- .collect();231+ merged
232+ .entry((r.entry_date, r.user_id))
233+ .and_modify(|m| {
234+ m.total += r.total;
235+ m.entries += r.entries;
236+ })
237+ .or_insert_with(|| r.clone());
238+ }
239+ let days: Vec<DayStatRow> = merged.into_values().collect();
198 240
199 // Légende et barres du panneau : membres actuels + seuls les241 // Légende et barres du panneau : membres actuels + seuls les
200 // ex-membres actifs dans CE scope cette semaine-là.242 // ex-membres actifs dans CE scope cette semaine-là.