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.
1a03e03 parent: 16d2bee modified
src/i18n.rs +5 -0 | @@ -236,6 +236,7 @@ pub struct Tr { | ||
| 236 | 236 | pub toast_grouped_pat: &'static str, |
| 237 | 237 | pub toast_ungrouped_pat: &'static str, |
| 238 | 238 | pub scope_pills_aria: &'static str, |
| 239 | + pub scope_all: &'static str, | |
| 239 | 240 | // ─── réglages ─── |
| 240 | 241 | pub title_settings: &'static str, |
| 241 | 242 | pub invitation_kicker: &'static str, |
| @@ -618,6 +619,7 @@ pub static FR: Tr = Tr { | ||
| 618 | 619 | toast_grouped_pat: "Mise à part dans « {} »", |
| 619 | 620 | toast_ungrouped_pat: "{} revient dans les statistiques de l'équipe", |
| 620 | 621 | scope_pills_aria: "Périmètre des statistiques", |
| 622 | + scope_all: "Toutes les catégories", | |
| 621 | 623 | title_settings: "Paramètres", |
| 622 | 624 | invitation_kicker: "Invitation", |
| 623 | 625 | invite_text_pat: "{} t’invite à rejoindre l’équipe « {} ».", |
| @@ -945,6 +947,7 @@ pub static EN: Tr = Tr { | ||
| 945 | 947 | toast_grouped_pat: "Set apart in “{}”", |
| 946 | 948 | toast_ungrouped_pat: "{} returns to the team's statistics", |
| 947 | 949 | scope_pills_aria: "Statistics scope", |
| 950 | + scope_all: "All categories", | |
| 948 | 951 | title_settings: "Settings", |
| 949 | 952 | invitation_kicker: "Invitation", |
| 950 | 953 | invite_text_pat: "{} invites you to join the team “{}”.", |
| @@ -1272,6 +1275,7 @@ pub static DE: Tr = Tr { | ||
| 1272 | 1275 | toast_grouped_pat: "Beiseitegestellt in „{}“", |
| 1273 | 1276 | toast_ungrouped_pat: "{} kehrt in die Team-Statistik zurück", |
| 1274 | 1277 | scope_pills_aria: "Umfang der Statistik", |
| 1278 | + scope_all: "Alle Kategorien", | |
| 1275 | 1279 | title_settings: "Einstellungen", |
| 1276 | 1280 | invitation_kicker: "Einladung", |
| 1277 | 1281 | invite_text_pat: "{} lädt dich ins Team „{}“ ein.", |
| @@ -1599,6 +1603,7 @@ pub static PT_BR: Tr = Tr { | ||
| 1599 | 1603 | toast_grouped_pat: "Separada em “{}”", |
| 1600 | 1604 | toast_ungrouped_pat: "{} volta para as estatísticas da equipe", |
| 1601 | 1605 | scope_pills_aria: "Escopo das estatísticas", |
| 1606 | + scope_all: "Todas as categorias", | |
| 1602 | 1607 | title_settings: "Configurações", |
| 1603 | 1608 | invitation_kicker: "Convite", |
| 1604 | 1609 | 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 { | ||
| 60 | 60 | annee: Option<String>, |
| 61 | 61 | /// Nom du groupe affiché (panneau actif) — absent = stats de l'équipe. |
| 62 | 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 | 86 | fn href(week: Week, month: Month, year: Year) -> String { |
| @@ -144,11 +165,14 @@ pub async fn stats_page( | ||
| 144 | 165 | .collect::<BTreeSet<_>>() |
| 145 | 166 | .into_iter() |
| 146 | 167 | .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 | + }; | |
| 152 | 176 | |
| 153 | 177 | let tr = ctx.tr(); |
| 154 | 178 | let today_label = day_full(ctx.lang, today); |
| @@ -157,15 +181,25 @@ pub async fn stats_page( | ||
| 157 | 181 | let mut sheets: Vec<Sheet> = Vec::new(); |
| 158 | 182 | let mut day_sheets: Vec<Sheet> = Vec::new(); |
| 159 | 183 | |
| 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(); | |
| 162 | 187 | let many = scopes.len() > 1; |
| 188 | + if many { | |
| 189 | + scopes.push(Scope::All); | |
| 190 | + } | |
| 163 | 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 | 197 | 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 | + }; | |
| 169 | 203 | // Les liens de période du panneau embarquent groupe + ancre : au |
| 170 | 204 | // rechargement on retombe sur le même panneau, JS ou pas. |
| 171 | 205 | let phref = |
| @@ -176,7 +210,7 @@ pub async fn stats_page( | ||
| 176 | 210 | |
| 177 | 211 | let cat_ids: HashSet<Uuid> = all_tasks |
| 178 | 212 | .iter() |
| 179 | - .filter(|t| t.group_name.as_ref() == scope) | |
| 213 | + .filter(|t| scope.holds(t.group_name.as_ref())) | |
| 180 | 214 | .map(|t| t.id) |
| 181 | 215 | .collect(); |
| 182 | 216 | let pick = |rows: &[StatRow]| -> Vec<StatRow> { |
| @@ -190,11 +224,19 @@ pub async fn stats_page( | ||
| 190 | 224 | let mo = pick(&month_rows); |
| 191 | 225 | let yr = pick(&year_rows); |
| 192 | 226 | 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(); | |
| 198 | 240 | |
| 199 | 241 | // Légende et barres du panneau : membres actuels + seuls les |
| 200 | 242 | // 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 = q | 168 | + let active_idx = if q.tout.is_some() && !groups.is_empty() { |
| 148 | - .groupe | 169 | + 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 = scope | 198 | + 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 : au | 203 | // 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_tasks | 211 | 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_rows | 227 | + // 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 les | 241 | // 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à. |