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

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

Download ZIP

feat(stats): graphique « Cette année » entre le mois et l'historiqueUnverified

Julien Durillon committed 2026-08-07T12:36:05+02:00 Browse files
254a88c parent: f3c2f2c
modified src/period.rs +60 -0
@@ -201,6 +201,55 @@ impl fmt::Display for Month {
201201 }
202202 }
203203
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+
204253 #[cfg(test)]
205254 mod tests {
206255 use super::*;
@@ -255,6 +304,17 @@ mod tests {
255304 assert_eq!(Month::parse("2026-01").unwrap().prev(), dec);
256305 }
257306
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+
258318 #[test]
259319 fn labels() {
260320 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
99 use crate::db::users::UserRow;
1010 use crate::db::{stats, tasks, teams};
1111 use crate::error::AppError;
12-use crate::period::{Month, Week};
12+use crate::period::{Month, Week, Year};
1313 use crate::render::{HtmlTemplate, Shell};
1414 use crate::routes::shell;
1515 use crate::state::AppState;
@@ -28,10 +28,11 @@ pub struct StatsPage {
2828 pub struct StatsQuery {
2929 semaine: Option<String>,
3030 mois: Option<String>,
31+ annee: Option<String>,
3132 }
3233
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}")
3536 }
3637
3738 pub async fn stats_page(
@@ -49,9 +50,15 @@ pub async fn stats_page(
4950 .as_deref()
5051 .and_then(Month::parse)
5152 .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);
5258
5359 let (week_from, week_to) = week.bounds();
5460 let (month_from, month_to) = month.bounds();
61+ let (year_from, year_to) = year.bounds();
5562
5663 let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
5764 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
@@ -59,6 +66,7 @@ pub async fn stats_page(
5966 let week_rows = stats::totals(&state.pool, ctx.team_id, Some(week_from), Some(week_to)).await?;
6067 let month_rows =
6168 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?;
6270 let total_rows = stats::totals(&state.pool, ctx.team_id, None, None).await?;
6371
6472 // Barres = membres actuels (même à zéro) ∪ ex-membres qui ont des temps
@@ -89,8 +97,8 @@ pub async fn stats_page(
8997 &week_rows,
9098 &colors,
9199 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)),
94102 }),
95103 );
96104 let (month_chart, s2) = build_chart(
@@ -101,11 +109,23 @@ pub async fn stats_page(
101109 &month_rows,
102110 &colors,
103111 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())),
106126 }),
107127 );
108- let (total_chart, s3) = build_chart(
128+ let (total_chart, s4) = build_chart(
109129 "tot",
110130 "Depuis le début",
111131 "Tout l'historique",
@@ -116,11 +136,12 @@ pub async fn stats_page(
116136 );
117137 sheets.extend(s2);
118138 sheets.extend(s3);
139+ sheets.extend(s4);
119140
120141 Ok(HtmlTemplate(StatsPage {
121142 shell: shell(&state, &ctx, "Statistiques", "stats", None).await?,
122143 legend,
123- charts: vec![week_chart, month_chart, total_chart],
144+ charts: vec![week_chart, month_chart, year_chart, total_chart],
124145 sheets,
125146 }))
126147 }
@@ -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 temps72 // 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 }