feat(stats): graphique « Cette année » entre le mois et l'historiqueUnverified
254a88c parent: f3c2f2c modified
src/period.rs +60 -0 | @@ -201,6 +201,55 @@ impl fmt::Display for Month { | ||
| 201 | 201 | } |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | +/// Année civile, format query `2026`. | |
| 205 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 206 | +pub struct Year { | |
| 207 | + pub year: i32, | |
| 208 | +} | |
| 209 | + | |
| 210 | +impl Year { | |
| 211 | + pub fn parse(s: &str) -> Option<Self> { | |
| 212 | + let year: i32 = s.parse().ok()?; | |
| 213 | + // Borne de confort : évite les dates invalides et les URL fantaisistes. | |
| 214 | + (1970..=9999).contains(&year).then_some(Self { year }) | |
| 215 | + } | |
| 216 | + | |
| 217 | + pub fn current() -> Self { | |
| 218 | + Self { | |
| 219 | + year: paris_today().year(), | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + /// [1er janvier, 1er janvier suivant) | |
| 224 | + pub fn bounds(&self) -> (NaiveDate, NaiveDate) { | |
| 225 | + let first = |y| NaiveDate::from_ymd_opt(y, 1, 1).expect("1er janvier toujours valide"); | |
| 226 | + (first(self.year), first(self.year + 1)) | |
| 227 | + } | |
| 228 | + | |
| 229 | + pub fn prev(&self) -> Self { | |
| 230 | + Self { | |
| 231 | + year: self.year - 1, | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + pub fn next(&self) -> Self { | |
| 236 | + Self { | |
| 237 | + year: self.year + 1, | |
| 238 | + } | |
| 239 | + } | |
| 240 | + | |
| 241 | + /// « Année 2026 » | |
| 242 | + pub fn label(&self) -> String { | |
| 243 | + format!("Année {}", self.year) | |
| 244 | + } | |
| 245 | +} | |
| 246 | + | |
| 247 | +impl fmt::Display for Year { | |
| 248 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 249 | + write!(f, "{}", self.year) | |
| 250 | + } | |
| 251 | +} | |
| 252 | + | |
| 204 | 253 | #[cfg(test)] |
| 205 | 254 | mod tests { |
| 206 | 255 | use super::*; |
| @@ -255,6 +304,17 @@ mod tests { | ||
| 255 | 304 | assert_eq!(Month::parse("2026-01").unwrap().prev(), dec); |
| 256 | 305 | } |
| 257 | 306 | |
| 307 | + #[test] | |
| 308 | + fn year_parse_bounds_nav() { | |
| 309 | + let y = Year::parse("2026").unwrap(); | |
| 310 | + assert_eq!(y.bounds(), (d(2026, 1, 1), d(2027, 1, 1))); | |
| 311 | + assert_eq!(y.to_string(), "2026"); | |
| 312 | + assert_eq!(y.label(), "Année 2026"); | |
| 313 | + assert_eq!(y.prev().next(), y); | |
| 314 | + assert!(Year::parse("abc").is_none()); | |
| 315 | + assert!(Year::parse("12026").is_none()); | |
| 316 | + } | |
| 317 | + | |
| 258 | 318 | #[test] |
| 259 | 319 | fn labels() { |
| 260 | 320 | assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); |
| @@ -201,6 +201,55 @@ impl fmt::Display for Month { | |||
| 201 | } | 201 | } |
| 202 | } | 202 | } |
| 203 | 203 | ||
| 204 | +/// Année civile, format query `2026`. | ||
| 205 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | ||
| 206 | +pub struct Year { | ||
| 207 | + pub year: i32, | ||
| 208 | +} | ||
| 209 | + | ||
| 210 | +impl Year { | ||
| 211 | + pub fn parse(s: &str) -> Option<Self> { | ||
| 212 | + let year: i32 = s.parse().ok()?; | ||
| 213 | + // Borne de confort : évite les dates invalides et les URL fantaisistes. | ||
| 214 | + (1970..=9999).contains(&year).then_some(Self { year }) | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + pub fn current() -> Self { | ||
| 218 | + Self { | ||
| 219 | + year: paris_today().year(), | ||
| 220 | + } | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + /// [1er janvier, 1er janvier suivant) | ||
| 224 | + pub fn bounds(&self) -> (NaiveDate, NaiveDate) { | ||
| 225 | + let first = |y| NaiveDate::from_ymd_opt(y, 1, 1).expect("1er janvier toujours valide"); | ||
| 226 | + (first(self.year), first(self.year + 1)) | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + pub fn prev(&self) -> Self { | ||
| 230 | + Self { | ||
| 231 | + year: self.year - 1, | ||
| 232 | + } | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + pub fn next(&self) -> Self { | ||
| 236 | + Self { | ||
| 237 | + year: self.year + 1, | ||
| 238 | + } | ||
| 239 | + } | ||
| 240 | + | ||
| 241 | + /// « Année 2026 » | ||
| 242 | + pub fn label(&self) -> String { | ||
| 243 | + format!("Année {}", self.year) | ||
| 244 | + } | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +impl fmt::Display for Year { | ||
| 248 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 249 | + write!(f, "{}", self.year) | ||
| 250 | + } | ||
| 251 | +} | ||
| 252 | + | ||
| 204 | #[cfg(test)] | 253 | #[cfg(test)] |
| 205 | mod tests { | 254 | mod tests { |
| 206 | use super::*; | 255 | use super::*; |
| @@ -255,6 +304,17 @@ mod tests { | |||
| 255 | assert_eq!(Month::parse("2026-01").unwrap().prev(), dec); | 304 | assert_eq!(Month::parse("2026-01").unwrap().prev(), dec); |
| 256 | } | 305 | } |
| 257 | 306 | ||
| 307 | + #[test] | ||
| 308 | + fn year_parse_bounds_nav() { | ||
| 309 | + let y = Year::parse("2026").unwrap(); | ||
| 310 | + assert_eq!(y.bounds(), (d(2026, 1, 1), d(2027, 1, 1))); | ||
| 311 | + assert_eq!(y.to_string(), "2026"); | ||
| 312 | + assert_eq!(y.label(), "Année 2026"); | ||
| 313 | + assert_eq!(y.prev().next(), y); | ||
| 314 | + assert!(Year::parse("abc").is_none()); | ||
| 315 | + assert!(Year::parse("12026").is_none()); | ||
| 316 | + } | ||
| 317 | + | ||
| 258 | #[test] | 318 | #[test] |
| 259 | fn labels() { | 319 | fn labels() { |
| 260 | assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); | 320 | assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); |
modified
src/routes/stats.rs +30 -9 | @@ -9,7 +9,7 @@ use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_m | ||
| 9 | 9 | use crate::db::users::UserRow; |
| 10 | 10 | use crate::db::{stats, tasks, teams}; |
| 11 | 11 | use crate::error::AppError; |
| 12 | -use crate::period::{Month, Week}; | |
| 12 | +use crate::period::{Month, Week, Year}; | |
| 13 | 13 | use crate::render::{HtmlTemplate, Shell}; |
| 14 | 14 | use crate::routes::shell; |
| 15 | 15 | use crate::state::AppState; |
| @@ -28,10 +28,11 @@ pub struct StatsPage { | ||
| 28 | 28 | pub struct StatsQuery { |
| 29 | 29 | semaine: Option<String>, |
| 30 | 30 | mois: Option<String>, |
| 31 | + annee: Option<String>, | |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | -fn href(week: Week, month: Month) -> String { | |
| 34 | - format!("/stats?semaine={week}&mois={month}") | |
| 34 | +fn href(week: Week, month: Month, year: Year) -> String { | |
| 35 | + format!("/stats?semaine={week}&mois={month}&annee={year}") | |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | pub async fn stats_page( |
| @@ -49,9 +50,15 @@ pub async fn stats_page( | ||
| 49 | 50 | .as_deref() |
| 50 | 51 | .and_then(Month::parse) |
| 51 | 52 | .unwrap_or_else(Month::current); |
| 53 | + let year = q | |
| 54 | + .annee | |
| 55 | + .as_deref() | |
| 56 | + .and_then(Year::parse) | |
| 57 | + .unwrap_or_else(Year::current); | |
| 52 | 58 | |
| 53 | 59 | let (week_from, week_to) = week.bounds(); |
| 54 | 60 | let (month_from, month_to) = month.bounds(); |
| 61 | + let (year_from, year_to) = year.bounds(); | |
| 55 | 62 | |
| 56 | 63 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; |
| 57 | 64 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| @@ -59,6 +66,7 @@ pub async fn stats_page( | ||
| 59 | 66 | let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?; |
| 60 | 67 | let month_rows = |
| 61 | 68 | stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?; |
| 69 | + let year_rows = stats::totals(&state.pool, ctx.team_id, Some(year_from), Some(year_to)).await?; | |
| 62 | 70 | let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?; |
| 63 | 71 | |
| 64 | 72 | // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps |
| @@ -89,8 +97,8 @@ pub async fn stats_page( | ||
| 89 | 97 | &week_rows, |
| 90 | 98 | &colors, |
| 91 | 99 | Some(ChartNav { |
| 92 | - prev: href(week.prev(), month), | |
| 93 | - next: (week != Week::current()).then(|| href(week.next(), month)), | |
| 100 | + prev: href(week.prev(), month, year), | |
| 101 | + next: (week != Week::current()).then(|| href(week.next(), month, year)), | |
| 94 | 102 | }), |
| 95 | 103 | ); |
| 96 | 104 | let (month_chart, s2) = build_chart( |
| @@ -101,11 +109,23 @@ pub async fn stats_page( | ||
| 101 | 109 | &month_rows, |
| 102 | 110 | &colors, |
| 103 | 111 | Some(ChartNav { |
| 104 | - prev: href(week, month.prev()), | |
| 105 | - next: (month != Month::current()).then(|| href(week, month.next())), | |
| 112 | + prev: href(week, month.prev(), year), | |
| 113 | + next: (month != Month::current()).then(|| href(week, month.next(), year)), | |
| 114 | + }), | |
| 115 | + ); | |
| 116 | + let (year_chart, s3) = build_chart( | |
| 117 | + "an", | |
| 118 | + "Cette année", | |
| 119 | + &year.label(), | |
| 120 | + &all_users, | |
| 121 | + &year_rows, | |
| 122 | + &colors, | |
| 123 | + Some(ChartNav { | |
| 124 | + prev: href(week, month, year.prev()), | |
| 125 | + next: (year != Year::current()).then(|| href(week, month, year.next())), | |
| 106 | 126 | }), |
| 107 | 127 | ); |
| 108 | - let (total_chart, s3) = build_chart( | |
| 128 | + let (total_chart, s4) = build_chart( | |
| 109 | 129 | "tot", |
| 110 | 130 | "Depuis le début", |
| 111 | 131 | "Tout l'historique", |
| @@ -116,11 +136,12 @@ pub async fn stats_page( | ||
| 116 | 136 | ); |
| 117 | 137 | sheets.extend(s2); |
| 118 | 138 | sheets.extend(s3); |
| 139 | + sheets.extend(s4); | |
| 119 | 140 | |
| 120 | 141 | Ok(HtmlTemplate(StatsPage { |
| 121 | 142 | shell: shell(&state, &ctx, "Statistiques", "stats", None).await?, |
| 122 | 143 | legend, |
| 123 | - charts: vec![week_chart, month_chart, total_chart], | |
| 144 | + charts: vec![week_chart, month_chart, year_chart, total_chart], | |
| 124 | 145 | sheets, |
| 125 | 146 | })) |
| 126 | 147 | } |
| @@ -9,7 +9,7 @@ use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_m | |||
| 9 | use crate::db::users::UserRow; | 9 | use crate::db::users::UserRow; |
| 10 | use crate::db::{stats, tasks, teams}; | 10 | use crate::db::{stats, tasks, teams}; |
| 11 | use crate::error::AppError; | 11 | use crate::error::AppError; |
| 12 | -use crate::period::{Month, Week}; | 12 | +use crate::period::{Month, Week, Year}; |
| 13 | use crate::render::{HtmlTemplate, Shell}; | 13 | use crate::render::{HtmlTemplate, Shell}; |
| 14 | use crate::routes::shell; | 14 | use crate::routes::shell; |
| 15 | use crate::state::AppState; | 15 | use crate::state::AppState; |
| @@ -28,10 +28,11 @@ pub struct StatsPage { | |||
| 28 | pub struct StatsQuery { | 28 | pub struct StatsQuery { |
| 29 | semaine: Option<String>, | 29 | semaine: Option<String>, |
| 30 | mois: Option<String>, | 30 | mois: Option<String>, |
| 31 | + annee: Option<String>, | ||
| 31 | } | 32 | } |
| 32 | 33 | ||
| 33 | -fn href(week: Week, month: Month) -> String { | 34 | +fn href(week: Week, month: Month, year: Year) -> String { |
| 34 | - format!("/stats?semaine={week}&mois={month}") | 35 | + format!("/stats?semaine={week}&mois={month}&annee={year}") |
| 35 | } | 36 | } |
| 36 | 37 | ||
| 37 | pub async fn stats_page( | 38 | pub async fn stats_page( |
| @@ -49,9 +50,15 @@ pub async fn stats_page( | |||
| 49 | .as_deref() | 50 | .as_deref() |
| 50 | .and_then(Month::parse) | 51 | .and_then(Month::parse) |
| 51 | .unwrap_or_else(Month::current); | 52 | .unwrap_or_else(Month::current); |
| 53 | + let year = q | ||
| 54 | + .annee | ||
| 55 | + .as_deref() | ||
| 56 | + .and_then(Year::parse) | ||
| 57 | + .unwrap_or_else(Year::current); | ||
| 52 | 58 | ||
| 53 | let (week_from, week_to) = week.bounds(); | 59 | let (week_from, week_to) = week.bounds(); |
| 54 | let (month_from, month_to) = month.bounds(); | 60 | let (month_from, month_to) = month.bounds(); |
| 61 | + let (year_from, year_to) = year.bounds(); | ||
| 55 | 62 | ||
| 56 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; | 63 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; |
| 57 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | 64 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| @@ -59,6 +66,7 @@ pub async fn stats_page( | |||
| 59 | let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?; | 66 | let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?; |
| 60 | let month_rows = | 67 | let month_rows = |
| 61 | stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?; | 68 | stats::totals(&state.pool, ctx.team_id, Some(month_from), Some(month_to)).await?; |
| 69 | + let year_rows = stats::totals(&state.pool, ctx.team_id, Some(year_from), Some(year_to)).await?; | ||
| 62 | let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?; | 70 | let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?; |
| 63 | 71 | ||
| 64 | // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps | 72 | // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps |
| @@ -89,8 +97,8 @@ pub async fn stats_page( | |||
| 89 | &week_rows, | 97 | &week_rows, |
| 90 | &colors, | 98 | &colors, |
| 91 | Some(ChartNav { | 99 | Some(ChartNav { |
| 92 | - prev: href(week.prev(), month), | 100 | + prev: href(week.prev(), month, year), |
| 93 | - next: (week != Week::current()).then(|| href(week.next(), month)), | 101 | + next: (week != Week::current()).then(|| href(week.next(), month, year)), |
| 94 | }), | 102 | }), |
| 95 | ); | 103 | ); |
| 96 | let (month_chart, s2) = build_chart( | 104 | let (month_chart, s2) = build_chart( |
| @@ -101,11 +109,23 @@ pub async fn stats_page( | |||
| 101 | &month_rows, | 109 | &month_rows, |
| 102 | &colors, | 110 | &colors, |
| 103 | Some(ChartNav { | 111 | Some(ChartNav { |
| 104 | - prev: href(week, month.prev()), | 112 | + prev: href(week, month.prev(), year), |
| 105 | - next: (month != Month::current()).then(|| href(week, month.next())), | 113 | + next: (month != Month::current()).then(|| href(week, month.next(), year)), |
| 114 | + }), | ||
| 115 | + ); | ||
| 116 | + let (year_chart, s3) = build_chart( | ||
| 117 | + "an", | ||
| 118 | + "Cette année", | ||
| 119 | + &year.label(), | ||
| 120 | + &all_users, | ||
| 121 | + &year_rows, | ||
| 122 | + &colors, | ||
| 123 | + Some(ChartNav { | ||
| 124 | + prev: href(week, month, year.prev()), | ||
| 125 | + next: (year != Year::current()).then(|| href(week, month, year.next())), | ||
| 106 | }), | 126 | }), |
| 107 | ); | 127 | ); |
| 108 | - let (total_chart, s3) = build_chart( | 128 | + let (total_chart, s4) = build_chart( |
| 109 | "tot", | 129 | "tot", |
| 110 | "Depuis le début", | 130 | "Depuis le début", |
| 111 | "Tout l'historique", | 131 | "Tout l'historique", |
| @@ -116,11 +136,12 @@ pub async fn stats_page( | |||
| 116 | ); | 136 | ); |
| 117 | sheets.extend(s2); | 137 | sheets.extend(s2); |
| 118 | sheets.extend(s3); | 138 | sheets.extend(s3); |
| 139 | + sheets.extend(s4); | ||
| 119 | 140 | ||
| 120 | Ok(HtmlTemplate(StatsPage { | 141 | Ok(HtmlTemplate(StatsPage { |
| 121 | shell: shell(&state, &ctx, "Statistiques", "stats", None).await?, | 142 | shell: shell(&state, &ctx, "Statistiques", "stats", None).await?, |
| 122 | legend, | 143 | legend, |
| 123 | - charts: vec![week_chart, month_chart, total_chart], | 144 | + charts: vec![week_chart, month_chart, year_chart, total_chart], |
| 124 | sheets, | 145 | sheets, |
| 125 | })) | 146 | })) |
| 126 | } | 147 | } |