judu/ex-egopublic Fork
⑂ b12ad2c
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(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.
Julien Durillon committed 2026-09-18T15:08:29+02:00 Browse files
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
807807 display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0;
808808 border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
809809 }
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; }
810823 /* Chips radio/checkbox (création : catégorie, assignés). */
811824 .chips { display: flex; flex-wrap: wrap; gap: var(--space-2); position: relative; }
812825 .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
9090 .await
9191 }
9292
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+
93135 /// Nombre de todos « à faire » actives (badge de l'onglet Tâches).
94136 pub async fn open_count(pool: &PgPool, team_id: Uuid, today: NaiveDate) -> sqlx::Result<i64> {
95137 sqlx::query_scalar(
@@ -90,6 +90,48 @@ pub async fn assignees_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<As
90 .await90 .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 {
349349 pub doing_filter_empty: &'static str,
350350 pub open_filter_empty: &'static str,
351351 pub upcoming: &'static str,
352+ pub done_recently: &'static str,
353+ pub rescheduled: &'static str,
352354 pub take: &'static str,
353355 pub done_check: &'static str,
354356 pub release: &'static str,
@@ -715,6 +717,8 @@ pub static FR: Tr = Tr {
715717 doing_filter_empty: "Aucune tâche en cours ne t'est assignée.",
716718 open_filter_empty: "Aucune tâche à faire ne t'est assignée.",
717719 upcoming: "À venir",
720+ done_recently: "Fait récemment",
721+ rescheduled: "Reprogrammée au",
718722 take: "Prendre",
719723 done_check: "Fait ✓",
720724 release: "Relâcher",
@@ -1040,6 +1044,8 @@ pub static EN: Tr = Tr {
10401044 doing_filter_empty: "No in-progress task is assigned to you.",
10411045 open_filter_empty: "No to-do task is assigned to you.",
10421046 upcoming: "Upcoming",
1047+ done_recently: "Done recently",
1048+ rescheduled: "Rescheduled to",
10431049 take: "Take",
10441050 done_check: "Done ✓",
10451051 release: "Release",
@@ -1365,6 +1371,8 @@ pub static DE: Tr = Tr {
13651371 doing_filter_empty: "Dir ist keine laufende Aufgabe zugewiesen.",
13661372 open_filter_empty: "Dir ist keine offene Aufgabe zugewiesen.",
13671373 upcoming: "Demnächst",
1374+ done_recently: "Kürzlich erledigt",
1375+ rescheduled: "Neu geplant für",
13681376 take: "Übernehmen",
13691377 done_check: "Erledigt ✓",
13701378 release: "Freigeben",
@@ -1690,6 +1698,8 @@ pub static PT_BR: Tr = Tr {
16901698 doing_filter_empty: "Nenhuma tarefa em andamento está atribuída a você.",
16911699 open_filter_empty: "Nenhuma tarefa a fazer está atribuída a você.",
16921700 upcoming: "Em breve",
1701+ done_recently: "Feito recentemente",
1702+ rescheduled: "Reagendada para",
16931703 take: "Pegar",
16941704 done_check: "Feita ✓",
16951705 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;
44 use axum::Form;
55 use axum::extract::{Path, Query, State};
66 use axum::response::Redirect;
7-use chrono::{Duration, NaiveDate};
7+use chrono::{Duration, NaiveDate, NaiveTime, TimeZone, Utc};
88 use chrono_tz::Europe::Paris;
99 use serde::Deserialize;
1010 use uuid::Uuid;
@@ -54,6 +54,13 @@ pub struct TodoCard {
5454 pub high: bool,
5555 }
5656
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+
5764 #[derive(Template)]
5865 #[template(path = "todos.html")]
5966 pub struct TodosPage {
@@ -63,6 +70,8 @@ pub struct TodosPage {
6370 pub doing: Vec<TodoCard>,
6471 pub open: Vec<TodoCard>,
6572 pub later: Vec<TodoCard>,
73+ /// Complétions du jour (bloc replié « Fait récemment »), lecture seule.
74+ pub done_recent: Vec<DoneLine>,
6675 /// Le bloc existe (même si le filtre le vide).
6776 pub has_doing: bool,
6877 pub has_open: bool,
@@ -205,6 +214,26 @@ pub async fn todos_page(
205214 let open = select(STATE_TODO, true, open_on);
206215 let later = select(STATE_TODO, false, false);
207216
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+
208237 let shell = shell(&state, &ctx, tr.title_todos, "todos", toast_of(tr, q.ok.as_deref())).await?;
209238 Ok(HtmlTemplate(TodosPage {
210239 csrf: ctx.user.csrf_token(&state),
@@ -215,6 +244,7 @@ pub async fn todos_page(
215244 doing,
216245 open,
217246 later,
247+ done_recent,
218248 doing_filter_on: doing_on,
219249 open_filter_on: open_on,
220250 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 @@
105105 {% endif %}
106106 {% endif %}
107107
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+
108129 {% if !later.is_empty() %}
109130 <details class="later">
110131 <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>