feat(taches): bloc « Fait récemment » — complétions du jour, repliées avant « À venir »Unverified
Source : l'historique todo_state_changes (état 3), pas l'état courant — une tâche reprogrammée y figure avec la date de sa prochaine activation. Lecture seule, toute l'équipe, 10 lignes au plus (design). Migration 0021 : index partiel des complétions par date.
b12ad2c parent: 36aec84 modified
assets/style.css +13 -0 | @@ -807,6 +807,19 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | ||
| 807 | 807 | display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; |
| 808 | 808 | border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); |
| 809 | 809 | } |
| 810 | +/* « Fait récemment » : complétions du jour, lecture seule (design). */ | |
| 811 | +.done-recent { margin-bottom: var(--space-3); } | |
| 812 | +.recent-row { | |
| 813 | + display: flex; align-items: center; gap: var(--space-3); padding: 7px 0; | |
| 814 | + border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | |
| 815 | +} | |
| 816 | +.recent-dot { width: 9px; height: 9px; border-radius: 999px; flex: none; } | |
| 817 | +.recent-name { flex: 1; min-width: 0; font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 818 | +.recent-next { | |
| 819 | + display: flex; align-items: center; gap: 5px; flex: none; font-size: 0.8125rem; | |
| 820 | + font-style: italic; opacity: .7; font-variant-numeric: tabular-nums; | |
| 821 | +} | |
| 822 | +.recent-next svg { flex: none; } | |
| 810 | 823 | /* Chips radio/checkbox (création : catégorie, assignés). */ |
| 811 | 824 | .chips { display: flex; flex-wrap: wrap; gap: var(--space-2); position: relative; } |
| 812 | 825 | .chip { |
| @@ -807,6 +807,19 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | |||
| 807 | display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; | 807 | display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; |
| 808 | border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | 808 | border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); |
| 809 | } | 809 | } |
| 810 | +/* « Fait récemment » : complétions du jour, lecture seule (design). */ | ||
| 811 | +.done-recent { margin-bottom: var(--space-3); } | ||
| 812 | +.recent-row { | ||
| 813 | + display: flex; align-items: center; gap: var(--space-3); padding: 7px 0; | ||
| 814 | + border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | ||
| 815 | +} | ||
| 816 | +.recent-dot { width: 9px; height: 9px; border-radius: 999px; flex: none; } | ||
| 817 | +.recent-name { flex: 1; min-width: 0; font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 818 | +.recent-next { | ||
| 819 | + display: flex; align-items: center; gap: 5px; flex: none; font-size: 0.8125rem; | ||
| 820 | + font-style: italic; opacity: .7; font-variant-numeric: tabular-nums; | ||
| 821 | +} | ||
| 822 | +.recent-next svg { flex: none; } | ||
| 810 | /* Chips radio/checkbox (création : catégorie, assignés). */ | 823 | /* Chips radio/checkbox (création : catégorie, assignés). */ |
| 811 | .chips { display: flex; flex-wrap: wrap; gap: var(--space-2); position: relative; } | 824 | .chips { display: flex; flex-wrap: wrap; gap: var(--space-2); position: relative; } |
| 812 | .chip { | 825 | .chip { |
added
migrations/0021_index_completions.sql +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +-- Bloc « Fait récemment » : les complétions d'un jour se lisent par date, | |
| 2 | +-- toutes todos confondues — idx_todo_changes (todo_id, changed_at) ne sert | |
| 3 | +-- pas ce filtre. Index partiel : seules les lignes « fait » (état 3). | |
| 4 | +CREATE INDEX idx_todo_changes_done ON todo_state_changes (changed_at) WHERE state_id = 3; | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +-- Bloc « Fait récemment » : les complétions d'un jour se lisent par date, | ||
| 2 | +-- toutes todos confondues — idx_todo_changes (todo_id, changed_at) ne sert | ||
| 3 | +-- pas ce filtre. Index partiel : seules les lignes « fait » (état 3). | ||
| 4 | +CREATE INDEX idx_todo_changes_done ON todo_state_changes (changed_at) WHERE state_id = 3; | ||
modified
src/db/todos.rs +42 -0 | @@ -90,6 +90,48 @@ pub async fn assignees_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<As | ||
| 90 | 90 | .await |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | +#[derive(sqlx::FromRow)] | |
| 94 | +pub struct DoneRow { | |
| 95 | + pub name: String, | |
| 96 | + pub category_id: Uuid, | |
| 97 | + /// Reprogrammée par cette complétion : prochaine date d'activation. | |
| 98 | + pub next_date: Option<NaiveDate>, | |
| 99 | +} | |
| 100 | + | |
| 101 | +/// Complétions de l'équipe dans `[from, to)`, plus récentes d'abord (bloc | |
| 102 | +/// « Fait récemment »). La source est l'historique, pas l'état courant : une | |
| 103 | +/// todo reprogrammée est déjà repassée « à faire » et y figure quand même. | |
| 104 | +/// `next_date` n'accompagne que la DERNIÈRE complétion d'une todo encore en | |
| 105 | +/// attente après `today`. | |
| 106 | +pub async fn done_between( | |
| 107 | + pool: &PgPool, | |
| 108 | + team_id: Uuid, | |
| 109 | + from: DateTime<Utc>, | |
| 110 | + to: DateTime<Utc>, | |
| 111 | + today: NaiveDate, | |
| 112 | +) -> sqlx::Result<Vec<DoneRow>> { | |
| 113 | + sqlx::query_as( | |
| 114 | + "SELECT t.name, t.category_id, | |
| 115 | + CASE WHEN t.state_id = 2 AND t.activation_date > $4 AND NOT EXISTS ( | |
| 116 | + SELECT 1 FROM todo_state_changes n | |
| 117 | + WHERE n.todo_id = t.id AND n.state_id = 3 AND n.changed_at > sc.changed_at) | |
| 118 | + THEN t.activation_date END AS next_date | |
| 119 | + FROM todo_state_changes sc | |
| 120 | + JOIN todos t ON t.id = sc.todo_id | |
| 121 | + JOIN categories c ON c.id = t.category_id | |
| 122 | + WHERE c.team_id = $1 AND sc.state_id = 3 | |
| 123 | + AND sc.changed_at >= $2 AND sc.changed_at < $3 | |
| 124 | + ORDER BY sc.changed_at DESC, sc.id DESC | |
| 125 | + LIMIT 10", | |
| 126 | + ) | |
| 127 | + .bind(team_id) | |
| 128 | + .bind(from) | |
| 129 | + .bind(to) | |
| 130 | + .bind(today) | |
| 131 | + .fetch_all(pool) | |
| 132 | + .await | |
| 133 | +} | |
| 134 | + | |
| 93 | 135 | /// Nombre de todos « à faire » actives (badge de l'onglet Tâches). |
| 94 | 136 | pub async fn open_count(pool: &PgPool, team_id: Uuid, today: NaiveDate) -> sqlx::Result<i64> { |
| 95 | 137 | sqlx::query_scalar( |
| @@ -90,6 +90,48 @@ pub async fn assignees_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<As | |||
| 90 | .await | 90 | .await |
| 91 | } | 91 | } |
| 92 | 92 | ||
| 93 | +#[derive(sqlx::FromRow)] | ||
| 94 | +pub struct DoneRow { | ||
| 95 | + pub name: String, | ||
| 96 | + pub category_id: Uuid, | ||
| 97 | + /// Reprogrammée par cette complétion : prochaine date d'activation. | ||
| 98 | + pub next_date: Option<NaiveDate>, | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +/// Complétions de l'équipe dans `[from, to)`, plus récentes d'abord (bloc | ||
| 102 | +/// « Fait récemment »). La source est l'historique, pas l'état courant : une | ||
| 103 | +/// todo reprogrammée est déjà repassée « à faire » et y figure quand même. | ||
| 104 | +/// `next_date` n'accompagne que la DERNIÈRE complétion d'une todo encore en | ||
| 105 | +/// attente après `today`. | ||
| 106 | +pub async fn done_between( | ||
| 107 | + pool: &PgPool, | ||
| 108 | + team_id: Uuid, | ||
| 109 | + from: DateTime<Utc>, | ||
| 110 | + to: DateTime<Utc>, | ||
| 111 | + today: NaiveDate, | ||
| 112 | +) -> sqlx::Result<Vec<DoneRow>> { | ||
| 113 | + sqlx::query_as( | ||
| 114 | + "SELECT t.name, t.category_id, | ||
| 115 | + CASE WHEN t.state_id = 2 AND t.activation_date > $4 AND NOT EXISTS ( | ||
| 116 | + SELECT 1 FROM todo_state_changes n | ||
| 117 | + WHERE n.todo_id = t.id AND n.state_id = 3 AND n.changed_at > sc.changed_at) | ||
| 118 | + THEN t.activation_date END AS next_date | ||
| 119 | + FROM todo_state_changes sc | ||
| 120 | + JOIN todos t ON t.id = sc.todo_id | ||
| 121 | + JOIN categories c ON c.id = t.category_id | ||
| 122 | + WHERE c.team_id = $1 AND sc.state_id = 3 | ||
| 123 | + AND sc.changed_at >= $2 AND sc.changed_at < $3 | ||
| 124 | + ORDER BY sc.changed_at DESC, sc.id DESC | ||
| 125 | + LIMIT 10", | ||
| 126 | + ) | ||
| 127 | + .bind(team_id) | ||
| 128 | + .bind(from) | ||
| 129 | + .bind(to) | ||
| 130 | + .bind(today) | ||
| 131 | + .fetch_all(pool) | ||
| 132 | + .await | ||
| 133 | +} | ||
| 134 | + | ||
| 93 | /// Nombre de todos « à faire » actives (badge de l'onglet Tâches). | 135 | /// Nombre de todos « à faire » actives (badge de l'onglet Tâches). |
| 94 | pub async fn open_count(pool: &PgPool, team_id: Uuid, today: NaiveDate) -> sqlx::Result<i64> { | 136 | pub async fn open_count(pool: &PgPool, team_id: Uuid, today: NaiveDate) -> sqlx::Result<i64> { |
| 95 | sqlx::query_scalar( | 137 | sqlx::query_scalar( |
modified
src/i18n.rs +10 -0 | @@ -349,6 +349,8 @@ pub struct Tr { | ||
| 349 | 349 | pub doing_filter_empty: &'static str, |
| 350 | 350 | pub open_filter_empty: &'static str, |
| 351 | 351 | pub upcoming: &'static str, |
| 352 | + pub done_recently: &'static str, | |
| 353 | + pub rescheduled: &'static str, | |
| 352 | 354 | pub take: &'static str, |
| 353 | 355 | pub done_check: &'static str, |
| 354 | 356 | pub release: &'static str, |
| @@ -715,6 +717,8 @@ pub static FR: Tr = Tr { | ||
| 715 | 717 | doing_filter_empty: "Aucune tâche en cours ne t'est assignée.", |
| 716 | 718 | open_filter_empty: "Aucune tâche à faire ne t'est assignée.", |
| 717 | 719 | upcoming: "À venir", |
| 720 | + done_recently: "Fait récemment", | |
| 721 | + rescheduled: "Reprogrammée au", | |
| 718 | 722 | take: "Prendre", |
| 719 | 723 | done_check: "Fait ✓", |
| 720 | 724 | release: "Relâcher", |
| @@ -1040,6 +1044,8 @@ pub static EN: Tr = Tr { | ||
| 1040 | 1044 | doing_filter_empty: "No in-progress task is assigned to you.", |
| 1041 | 1045 | open_filter_empty: "No to-do task is assigned to you.", |
| 1042 | 1046 | upcoming: "Upcoming", |
| 1047 | + done_recently: "Done recently", | |
| 1048 | + rescheduled: "Rescheduled to", | |
| 1043 | 1049 | take: "Take", |
| 1044 | 1050 | done_check: "Done ✓", |
| 1045 | 1051 | release: "Release", |
| @@ -1365,6 +1371,8 @@ pub static DE: Tr = Tr { | ||
| 1365 | 1371 | doing_filter_empty: "Dir ist keine laufende Aufgabe zugewiesen.", |
| 1366 | 1372 | open_filter_empty: "Dir ist keine offene Aufgabe zugewiesen.", |
| 1367 | 1373 | upcoming: "Demnächst", |
| 1374 | + done_recently: "Kürzlich erledigt", | |
| 1375 | + rescheduled: "Neu geplant für", | |
| 1368 | 1376 | take: "Übernehmen", |
| 1369 | 1377 | done_check: "Erledigt ✓", |
| 1370 | 1378 | release: "Freigeben", |
| @@ -1690,6 +1698,8 @@ pub static PT_BR: Tr = Tr { | ||
| 1690 | 1698 | doing_filter_empty: "Nenhuma tarefa em andamento está atribuída a você.", |
| 1691 | 1699 | open_filter_empty: "Nenhuma tarefa a fazer está atribuída a você.", |
| 1692 | 1700 | upcoming: "Em breve", |
| 1701 | + done_recently: "Feito recentemente", | |
| 1702 | + rescheduled: "Reagendada para", | |
| 1693 | 1703 | take: "Pegar", |
| 1694 | 1704 | done_check: "Feita ✓", |
| 1695 | 1705 | release: "Soltar", |
| @@ -349,6 +349,8 @@ pub struct Tr { | |||
| 349 | pub doing_filter_empty: &'static str, | 349 | pub doing_filter_empty: &'static str, |
| 350 | pub open_filter_empty: &'static str, | 350 | pub open_filter_empty: &'static str, |
| 351 | pub upcoming: &'static str, | 351 | pub upcoming: &'static str, |
| 352 | + pub done_recently: &'static str, | ||
| 353 | + pub rescheduled: &'static str, | ||
| 352 | pub take: &'static str, | 354 | pub take: &'static str, |
| 353 | pub done_check: &'static str, | 355 | pub done_check: &'static str, |
| 354 | pub release: &'static str, | 356 | pub release: &'static str, |
| @@ -715,6 +717,8 @@ pub static FR: Tr = Tr { | |||
| 715 | doing_filter_empty: "Aucune tâche en cours ne t'est assignée.", | 717 | doing_filter_empty: "Aucune tâche en cours ne t'est assignée.", |
| 716 | open_filter_empty: "Aucune tâche à faire ne t'est assignée.", | 718 | open_filter_empty: "Aucune tâche à faire ne t'est assignée.", |
| 717 | upcoming: "À venir", | 719 | upcoming: "À venir", |
| 720 | + done_recently: "Fait récemment", | ||
| 721 | + rescheduled: "Reprogrammée au", | ||
| 718 | take: "Prendre", | 722 | take: "Prendre", |
| 719 | done_check: "Fait ✓", | 723 | done_check: "Fait ✓", |
| 720 | release: "Relâcher", | 724 | release: "Relâcher", |
| @@ -1040,6 +1044,8 @@ pub static EN: Tr = Tr { | |||
| 1040 | doing_filter_empty: "No in-progress task is assigned to you.", | 1044 | doing_filter_empty: "No in-progress task is assigned to you.", |
| 1041 | open_filter_empty: "No to-do task is assigned to you.", | 1045 | open_filter_empty: "No to-do task is assigned to you.", |
| 1042 | upcoming: "Upcoming", | 1046 | upcoming: "Upcoming", |
| 1047 | + done_recently: "Done recently", | ||
| 1048 | + rescheduled: "Rescheduled to", | ||
| 1043 | take: "Take", | 1049 | take: "Take", |
| 1044 | done_check: "Done ✓", | 1050 | done_check: "Done ✓", |
| 1045 | release: "Release", | 1051 | release: "Release", |
| @@ -1365,6 +1371,8 @@ pub static DE: Tr = Tr { | |||
| 1365 | doing_filter_empty: "Dir ist keine laufende Aufgabe zugewiesen.", | 1371 | doing_filter_empty: "Dir ist keine laufende Aufgabe zugewiesen.", |
| 1366 | open_filter_empty: "Dir ist keine offene Aufgabe zugewiesen.", | 1372 | open_filter_empty: "Dir ist keine offene Aufgabe zugewiesen.", |
| 1367 | upcoming: "Demnächst", | 1373 | upcoming: "Demnächst", |
| 1374 | + done_recently: "Kürzlich erledigt", | ||
| 1375 | + rescheduled: "Neu geplant für", | ||
| 1368 | take: "Übernehmen", | 1376 | take: "Übernehmen", |
| 1369 | done_check: "Erledigt ✓", | 1377 | done_check: "Erledigt ✓", |
| 1370 | release: "Freigeben", | 1378 | release: "Freigeben", |
| @@ -1690,6 +1698,8 @@ pub static PT_BR: Tr = Tr { | |||
| 1690 | doing_filter_empty: "Nenhuma tarefa em andamento está atribuída a você.", | 1698 | doing_filter_empty: "Nenhuma tarefa em andamento está atribuída a você.", |
| 1691 | open_filter_empty: "Nenhuma tarefa a fazer está atribuída a você.", | 1699 | open_filter_empty: "Nenhuma tarefa a fazer está atribuída a você.", |
| 1692 | upcoming: "Em breve", | 1700 | upcoming: "Em breve", |
| 1701 | + done_recently: "Feito recentemente", | ||
| 1702 | + rescheduled: "Reagendada para", | ||
| 1693 | take: "Pegar", | 1703 | take: "Pegar", |
| 1694 | done_check: "Feita ✓", | 1704 | done_check: "Feita ✓", |
| 1695 | release: "Soltar", | 1705 | release: "Soltar", |
modified
src/routes/todos.rs +31 -1 | @@ -4,7 +4,7 @@ use askama::Template; | ||
| 4 | 4 | use axum::Form; |
| 5 | 5 | use axum::extract::{Path, Query, State}; |
| 6 | 6 | use axum::response::Redirect; |
| 7 | -use chrono::{Duration, NaiveDate}; | |
| 7 | +use chrono::{Duration, NaiveDate, NaiveTime, TimeZone, Utc}; | |
| 8 | 8 | use chrono_tz::Europe::Paris; |
| 9 | 9 | use serde::Deserialize; |
| 10 | 10 | use uuid::Uuid; |
| @@ -54,6 +54,13 @@ pub struct TodoCard { | ||
| 54 | 54 | pub high: bool, |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | +pub struct DoneLine { | |
| 58 | + pub name: String, | |
| 59 | + pub color: &'static str, | |
| 60 | + /// « 25 sept. » si cette complétion a reprogrammé la tâche. | |
| 61 | + pub next: Option<String>, | |
| 62 | +} | |
| 63 | + | |
| 57 | 64 | #[derive(Template)] |
| 58 | 65 | #[template(path = "todos.html")] |
| 59 | 66 | pub struct TodosPage { |
| @@ -63,6 +70,8 @@ pub struct TodosPage { | ||
| 63 | 70 | pub doing: Vec<TodoCard>, |
| 64 | 71 | pub open: Vec<TodoCard>, |
| 65 | 72 | pub later: Vec<TodoCard>, |
| 73 | + /// Complétions du jour (bloc replié « Fait récemment »), lecture seule. | |
| 74 | + pub done_recent: Vec<DoneLine>, | |
| 66 | 75 | /// Le bloc existe (même si le filtre le vide). |
| 67 | 76 | pub has_doing: bool, |
| 68 | 77 | pub has_open: bool, |
| @@ -205,6 +214,26 @@ pub async fn todos_page( | ||
| 205 | 214 | let open = select(STATE_TODO, true, open_on); |
| 206 | 215 | let later = select(STATE_TODO, false, false); |
| 207 | 216 | |
| 217 | + // Bornes du jour en Europe/Paris (changed_at est un TIMESTAMPTZ). | |
| 218 | + let paris_midnight = |d: NaiveDate| { | |
| 219 | + Paris | |
| 220 | + .from_local_datetime(&d.and_time(NaiveTime::MIN)) | |
| 221 | + .earliest() | |
| 222 | + .map(|at| at.with_timezone(&Utc)) | |
| 223 | + }; | |
| 224 | + let done_recent = match (paris_midnight(today), today.succ_opt().and_then(paris_midnight)) { | |
| 225 | + (Some(from), Some(to)) => todos::done_between(&state.pool, ctx.team_id, from, to, today) | |
| 226 | + .await? | |
| 227 | + .into_iter() | |
| 228 | + .map(|d| DoneLine { | |
| 229 | + name: d.name, | |
| 230 | + color: colors.get(&d.category_id).copied().unwrap_or("#a19786"), | |
| 231 | + next: d.next_date.map(|n| day_month(ctx.lang, n)), | |
| 232 | + }) | |
| 233 | + .collect(), | |
| 234 | + _ => Vec::new(), | |
| 235 | + }; | |
| 236 | + | |
| 208 | 237 | let shell = shell(&state, &ctx, tr.title_todos, "todos", toast_of(tr, q.ok.as_deref())).await?; |
| 209 | 238 | Ok(HtmlTemplate(TodosPage { |
| 210 | 239 | csrf: ctx.user.csrf_token(&state), |
| @@ -215,6 +244,7 @@ pub async fn todos_page( | ||
| 215 | 244 | doing, |
| 216 | 245 | open, |
| 217 | 246 | later, |
| 247 | + done_recent, | |
| 218 | 248 | doing_filter_on: doing_on, |
| 219 | 249 | open_filter_on: open_on, |
| 220 | 250 | doing_toggle_href: list_href(!doing_on, open_on), |
| @@ -4,7 +4,7 @@ use askama::Template; | |||
| 4 | use axum::Form; | 4 | use axum::Form; |
| 5 | use axum::extract::{Path, Query, State}; | 5 | use axum::extract::{Path, Query, State}; |
| 6 | use axum::response::Redirect; | 6 | use axum::response::Redirect; |
| 7 | -use chrono::{Duration, NaiveDate}; | 7 | +use chrono::{Duration, NaiveDate, NaiveTime, TimeZone, Utc}; |
| 8 | use chrono_tz::Europe::Paris; | 8 | use chrono_tz::Europe::Paris; |
| 9 | use serde::Deserialize; | 9 | use serde::Deserialize; |
| 10 | use uuid::Uuid; | 10 | use uuid::Uuid; |
| @@ -54,6 +54,13 @@ pub struct TodoCard { | |||
| 54 | pub high: bool, | 54 | pub high: bool, |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | +pub struct DoneLine { | ||
| 58 | + pub name: String, | ||
| 59 | + pub color: &'static str, | ||
| 60 | + /// « 25 sept. » si cette complétion a reprogrammé la tâche. | ||
| 61 | + pub next: Option<String>, | ||
| 62 | +} | ||
| 63 | + | ||
| 57 | #[derive(Template)] | 64 | #[derive(Template)] |
| 58 | #[template(path = "todos.html")] | 65 | #[template(path = "todos.html")] |
| 59 | pub struct TodosPage { | 66 | pub struct TodosPage { |
| @@ -63,6 +70,8 @@ pub struct TodosPage { | |||
| 63 | pub doing: Vec<TodoCard>, | 70 | pub doing: Vec<TodoCard>, |
| 64 | pub open: Vec<TodoCard>, | 71 | pub open: Vec<TodoCard>, |
| 65 | pub later: Vec<TodoCard>, | 72 | pub later: Vec<TodoCard>, |
| 73 | + /// Complétions du jour (bloc replié « Fait récemment »), lecture seule. | ||
| 74 | + pub done_recent: Vec<DoneLine>, | ||
| 66 | /// Le bloc existe (même si le filtre le vide). | 75 | /// Le bloc existe (même si le filtre le vide). |
| 67 | pub has_doing: bool, | 76 | pub has_doing: bool, |
| 68 | pub has_open: bool, | 77 | pub has_open: bool, |
| @@ -205,6 +214,26 @@ pub async fn todos_page( | |||
| 205 | let open = select(STATE_TODO, true, open_on); | 214 | let open = select(STATE_TODO, true, open_on); |
| 206 | let later = select(STATE_TODO, false, false); | 215 | let later = select(STATE_TODO, false, false); |
| 207 | 216 | ||
| 217 | + // Bornes du jour en Europe/Paris (changed_at est un TIMESTAMPTZ). | ||
| 218 | + let paris_midnight = |d: NaiveDate| { | ||
| 219 | + Paris | ||
| 220 | + .from_local_datetime(&d.and_time(NaiveTime::MIN)) | ||
| 221 | + .earliest() | ||
| 222 | + .map(|at| at.with_timezone(&Utc)) | ||
| 223 | + }; | ||
| 224 | + let done_recent = match (paris_midnight(today), today.succ_opt().and_then(paris_midnight)) { | ||
| 225 | + (Some(from), Some(to)) => todos::done_between(&state.pool, ctx.team_id, from, to, today) | ||
| 226 | + .await? | ||
| 227 | + .into_iter() | ||
| 228 | + .map(|d| DoneLine { | ||
| 229 | + name: d.name, | ||
| 230 | + color: colors.get(&d.category_id).copied().unwrap_or("#a19786"), | ||
| 231 | + next: d.next_date.map(|n| day_month(ctx.lang, n)), | ||
| 232 | + }) | ||
| 233 | + .collect(), | ||
| 234 | + _ => Vec::new(), | ||
| 235 | + }; | ||
| 236 | + | ||
| 208 | let shell = shell(&state, &ctx, tr.title_todos, "todos", toast_of(tr, q.ok.as_deref())).await?; | 237 | let shell = shell(&state, &ctx, tr.title_todos, "todos", toast_of(tr, q.ok.as_deref())).await?; |
| 209 | Ok(HtmlTemplate(TodosPage { | 238 | Ok(HtmlTemplate(TodosPage { |
| 210 | csrf: ctx.user.csrf_token(&state), | 239 | csrf: ctx.user.csrf_token(&state), |
| @@ -215,6 +244,7 @@ pub async fn todos_page( | |||
| 215 | doing, | 244 | doing, |
| 216 | open, | 245 | open, |
| 217 | later, | 246 | later, |
| 247 | + done_recent, | ||
| 218 | doing_filter_on: doing_on, | 248 | doing_filter_on: doing_on, |
| 219 | open_filter_on: open_on, | 249 | open_filter_on: open_on, |
| 220 | doing_toggle_href: list_href(!doing_on, open_on), | 250 | doing_toggle_href: list_href(!doing_on, open_on), |
modified
templates/todos.html +21 -0 | @@ -105,6 +105,27 @@ | ||
| 105 | 105 | {% endif %} |
| 106 | 106 | {% endif %} |
| 107 | 107 | |
| 108 | +{% if !done_recent.is_empty() %} | |
| 109 | +<details class="later done-recent"> | |
| 110 | + <summary>{{ shell.tr.done_recently }} · {{ done_recent.len() }}</summary> | |
| 111 | + <div style="margin-top:var(--space-3)"> | |
| 112 | + {% for d in done_recent %} | |
| 113 | + <div class="recent-row"> | |
| 114 | + <span class="recent-dot" style="background:{{ d.color }}"></span> | |
| 115 | + <div class="recent-name">{{ d.name }}</div> | |
| 116 | + {% if let Some(next) = d.next %} | |
| 117 | + <div class="recent-next"> | |
| 118 | + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M12 7v5l3.5 2"></path></svg> | |
| 119 | + <span class="visually-hidden">{{ shell.tr.rescheduled }}</span> | |
| 120 | + {{ next }} | |
| 121 | + </div> | |
| 122 | + {% endif %} | |
| 123 | + </div> | |
| 124 | + {% endfor %} | |
| 125 | + </div> | |
| 126 | +</details> | |
| 127 | +{% endif %} | |
| 128 | + | |
| 108 | 129 | {% if !later.is_empty() %} |
| 109 | 130 | <details class="later"> |
| 110 | 131 | <summary>{{ shell.tr.upcoming }} · {{ later.len() }}</summary> |
| @@ -105,6 +105,27 @@ | |||
| 105 | {% endif %} | 105 | {% endif %} |
| 106 | {% endif %} | 106 | {% endif %} |
| 107 | 107 | ||
| 108 | +{% if !done_recent.is_empty() %} | ||
| 109 | +<details class="later done-recent"> | ||
| 110 | + <summary>{{ shell.tr.done_recently }} · {{ done_recent.len() }}</summary> | ||
| 111 | + <div style="margin-top:var(--space-3)"> | ||
| 112 | + {% for d in done_recent %} | ||
| 113 | + <div class="recent-row"> | ||
| 114 | + <span class="recent-dot" style="background:{{ d.color }}"></span> | ||
| 115 | + <div class="recent-name">{{ d.name }}</div> | ||
| 116 | + {% if let Some(next) = d.next %} | ||
| 117 | + <div class="recent-next"> | ||
| 118 | + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M12 7v5l3.5 2"></path></svg> | ||
| 119 | + <span class="visually-hidden">{{ shell.tr.rescheduled }}</span> | ||
| 120 | + {{ next }} | ||
| 121 | + </div> | ||
| 122 | + {% endif %} | ||
| 123 | + </div> | ||
| 124 | + {% endfor %} | ||
| 125 | + </div> | ||
| 126 | +</details> | ||
| 127 | +{% endif %} | ||
| 128 | + | ||
| 108 | {% if !later.is_empty() %} | 129 | {% if !later.is_empty() %} |
| 109 | <details class="later"> | 130 | <details class="later"> |
| 110 | <summary>{{ shell.tr.upcoming }} · {{ later.len() }}</summary> | 131 | <summary>{{ shell.tr.upcoming }} · {{ later.len() }}</summary> |