judu/ex-egopublic Fork
⑂ ac0c9d7
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

Merge branch 'feat/repartition-minuteur' into 'main'

feat(minuteur): répartir une mesure sur plusieurs catégories

See merge request juuduu/ex-ego!1
Julien Durillon committed 2026-09-04T17:48:14Z Browse files
ac0c9d7 parents: 642b973 5426ecb
modified assets/style.css +10 -0
@@ -471,6 +471,9 @@ a.team-pill:hover { filter: brightness(.96); }
471471 .category-pick a:hover { border-color: var(--color-accent); }
472472 .category-pick .tname { flex: 1; }
473473 .category-pick .tweek { font-size: 0.75rem; opacity: .7; }
474+/* Minutes déjà créditées depuis la mesure en cours de répartition (0018). */
475+.category-pick .tsplit { font-size: 0.75rem; color: var(--color-accent-700); font-weight: 600; }
476+.category-pick .tweek:not(:empty) + .tsplit::before { content: "· "; color: var(--color-text); opacity: .7; }
474477 .dot { border-radius: 999px; flex: none; display: inline-block; }
475478 .dot-14 { width: 14px; height: 14px; }
476479 .dot-12 { width: 12px; height: 12px; }
@@ -819,6 +822,13 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
819822 .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); }
820823 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
821824 .done-note { font-size: 0.8125rem; opacity: .7; }
825+/* Toggle « Répartir le temps restant » de l'étape 2 (0018). Inactif : les
826+ aplats s'effacent, le texte reste au plancher AA (0,7, un seul niveau). */
827+.split-row { margin: 0 0 var(--space-6); }
828+.split-row .swt-check { margin-left: 0; justify-content: space-between; font-size: 0.875rem; min-height: 24px; }
829+.split-row .swt-check:has(input:disabled) { cursor: default; }
830+.split-row .swt-check:has(input:disabled) .split-label { opacity: .7; }
831+.split-row .swt-check:has(input:disabled) .swt { opacity: .45; }
822832 .done-sec .done-body { transition: opacity .15s ease; }
823833 /* Section désactivée : atténuée, mais elle reste lisible et focusable — donc
824834 soumise à l'AA. .35 tombait à 2:1, et à 1,6:1 pour les textes déjà à .7.
@@ -471,6 +471,9 @@ a.team-pill:hover { filter: brightness(.96); }
471 .category-pick a:hover { border-color: var(--color-accent); }471 .category-pick a:hover { border-color: var(--color-accent); }
472 .category-pick .tname { flex: 1; }472 .category-pick .tname { flex: 1; }
473 .category-pick .tweek { font-size: 0.75rem; opacity: .7; }473 .category-pick .tweek { font-size: 0.75rem; opacity: .7; }
474+/* Minutes déjà créditées depuis la mesure en cours de répartition (0018). */
475+.category-pick .tsplit { font-size: 0.75rem; color: var(--color-accent-700); font-weight: 600; }
476+.category-pick .tweek:not(:empty) + .tsplit::before { content: "· "; color: var(--color-text); opacity: .7; }
474 .dot { border-radius: 999px; flex: none; display: inline-block; }477 .dot { border-radius: 999px; flex: none; display: inline-block; }
475 .dot-14 { width: 14px; height: 14px; }478 .dot-14 { width: 14px; height: 14px; }
476 .dot-12 { width: 12px; height: 12px; }479 .dot-12 { width: 12px; height: 12px; }
@@ -819,6 +822,13 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
819 .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); }822 .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); }
820 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }823 .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
821 .done-note { font-size: 0.8125rem; opacity: .7; }824 .done-note { font-size: 0.8125rem; opacity: .7; }
825+/* Toggle « Répartir le temps restant » de l'étape 2 (0018). Inactif : les
826+ aplats s'effacent, le texte reste au plancher AA (0,7, un seul niveau). */
827+.split-row { margin: 0 0 var(--space-6); }
828+.split-row .swt-check { margin-left: 0; justify-content: space-between; font-size: 0.875rem; min-height: 24px; }
829+.split-row .swt-check:has(input:disabled) { cursor: default; }
830+.split-row .swt-check:has(input:disabled) .split-label { opacity: .7; }
831+.split-row .swt-check:has(input:disabled) .swt { opacity: .45; }
822 .done-sec .done-body { transition: opacity .15s ease; }832 .done-sec .done-body { transition: opacity .15s ease; }
823 /* Section désactivée : atténuée, mais elle reste lisible et focusable — donc833 /* Section désactivée : atténuée, mais elle reste lisible et focusable — donc
824 soumise à l'AA. .35 tombait à 2:1, et à 1,6:1 pour les textes déjà à .7.834 soumise à l'AA. .35 tombait à 2:1, et à 1,6:1 pour les textes déjà à .7.
added migrations/0018_timer_repartition.sql +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+-- Répartition d'une mesure de minuteur sur plusieurs catégories.
2+--
3+-- Une mesure en attente peut être créditée en plusieurs saisies : chaque
4+-- saisie qui laisse un reste est consignée ici, et le reste à placer vaut
5+-- la mesure moins la somme de ces minutes. Le JSON est la SEULE vérité
6+-- (pas de colonne de cumul à tenir en cohérence) : une liste d'objets
7+-- {"category_id": <uuid>, "minutes": <int>}, une entrée par saisie — servir
8+-- deux fois la même catégorie fait deux entrées, cumulées à la lecture.
9+-- Vide = mesure fraîche, comportement d'avant cette migration.
10+ALTER TABLE timers ADD COLUMN split JSONB NOT NULL DEFAULT '[]'::jsonb;
new file mode 100644
@@ -0,0 +1,10 @@
1+-- Répartition d'une mesure de minuteur sur plusieurs catégories.
2+--
3+-- Une mesure en attente peut être créditée en plusieurs saisies : chaque
4+-- saisie qui laisse un reste est consignée ici, et le reste à placer vaut
5+-- la mesure moins la somme de ces minutes. Le JSON est la SEULE vérité
6+-- (pas de colonne de cumul à tenir en cohérence) : une liste d'objets
7+-- {"category_id": <uuid>, "minutes": <int>}, une entrée par saisie — servir
8+-- deux fois la même catégorie fait deux entrées, cumulées à la lecture.
9+-- Vide = mesure fraîche, comportement d'avant cette migration.
10+ALTER TABLE timers ADD COLUMN split JSONB NOT NULL DEFAULT '[]'::jsonb;
added migrations/0019_changelog_repartition.sql +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+-- Annonce de changelog : répartition d'une mesure de minuteur (cf. 0016
2+-- pour le mécanisme, 0017 pour le premier contenu).
3+--
4+-- Id UUIDv7 généré à la main ; date éditoriale = jour de la mise en prod
5+-- (à ajuster avant de déployer : seuls les comptes créés STRICTEMENT avant
6+-- cette date la voient). La version est du texte libre affiché dans le
7+-- titre (« Nouveautés de la v0.4.0 »). Une ligne de texte = un paragraphe.
8+
9+INSERT INTO changelog_announcements (id, date, version, text_fr, text_en, text_de, text_ptbr) VALUES
10+(
11+ '01a06d03-1927-769e-aac1-c9da5a4407ef',
12+ DATE '2026-09-04',
13+ 'v0.4.0',
14+ E'Le minuteur sait maintenant se répartir entre plusieurs catégories. Quand tu valides le temps mesuré, diminue la durée : un interrupteur « Répartir le temps restant » apparaît. Coche-le, valide, et l''app te ramène au choix de catégorie pour le reste, autant de fois qu''il faut.\nTant qu''il reste du temps à placer, tu le retrouves sur l''onglet Ajouter et dans le bandeau du minuteur, avec un bouton pour l’abandonner si tu changes d''avis.\nAbandonner le temps restant n''efface pas les temps déjà répartis.',
15+ E'The timer can now be split across several categories. When you confirm the measured time, lower the duration: a "Split the remaining time" switch appears. Turn it on, confirm, and the app takes you back to the category choice for what''s left, as many times as needed.\nWhile some time is still waiting to be placed, you''ll find it on the Add tab and in the timer bar, with a button to drop it if you change your mind.\nDropping the remaining time doesn''t erase the time you''ve already split.',
16+ E'Der Timer lässt sich jetzt auf mehrere Kategorien aufteilen. Wenn du die gemessene Zeit bestätigst, verringere die Dauer: ein Schalter „Restzeit aufteilen“ erscheint. Schalte ihn ein, bestätige, und die App bringt dich für den Rest zurück zur Kategorienwahl, so oft wie nötig.\nSolange noch Zeit zu verteilen ist, findest du sie im Tab Erfassen und in der Timer-Leiste, mit einem Knopf zum Aufgeben, falls du es dir anders überlegst.\nDie Restzeit aufzugeben löscht die bereits verteilten Zeiten nicht.',
17+ E'O cronômetro agora pode ser repartido entre várias categorias. Ao confirmar o tempo medido, diminua a duração: aparece uma chave « Repartir o tempo restante ». Ative, confirme, e o app te leva de volta à escolha da categoria para o que sobrou, quantas vezes for preciso.\nEnquanto ainda houver tempo a distribuir, você o encontra na aba Adicionar e na barra do cronômetro, com um botão para abandoná-lo se mudar de ideia.\nAbandonar o tempo restante não apaga os tempos já repartidos.'
18+);
new file mode 100644
@@ -0,0 +1,18 @@
1+-- Annonce de changelog : répartition d'une mesure de minuteur (cf. 0016
2+-- pour le mécanisme, 0017 pour le premier contenu).
3+--
4+-- Id UUIDv7 généré à la main ; date éditoriale = jour de la mise en prod
5+-- (à ajuster avant de déployer : seuls les comptes créés STRICTEMENT avant
6+-- cette date la voient). La version est du texte libre affiché dans le
7+-- titre (« Nouveautés de la v0.4.0 »). Une ligne de texte = un paragraphe.
8+
9+INSERT INTO changelog_announcements (id, date, version, text_fr, text_en, text_de, text_ptbr) VALUES
10+(
11+ '01a06d03-1927-769e-aac1-c9da5a4407ef',
12+ DATE '2026-09-04',
13+ 'v0.4.0',
14+ E'Le minuteur sait maintenant se répartir entre plusieurs catégories. Quand tu valides le temps mesuré, diminue la durée : un interrupteur « Répartir le temps restant » apparaît. Coche-le, valide, et l''app te ramène au choix de catégorie pour le reste, autant de fois qu''il faut.\nTant qu''il reste du temps à placer, tu le retrouves sur l''onglet Ajouter et dans le bandeau du minuteur, avec un bouton pour l’abandonner si tu changes d''avis.\nAbandonner le temps restant n''efface pas les temps déjà répartis.',
15+ E'The timer can now be split across several categories. When you confirm the measured time, lower the duration: a "Split the remaining time" switch appears. Turn it on, confirm, and the app takes you back to the category choice for what''s left, as many times as needed.\nWhile some time is still waiting to be placed, you''ll find it on the Add tab and in the timer bar, with a button to drop it if you change your mind.\nDropping the remaining time doesn''t erase the time you''ve already split.',
16+ E'Der Timer lässt sich jetzt auf mehrere Kategorien aufteilen. Wenn du die gemessene Zeit bestätigst, verringere die Dauer: ein Schalter „Restzeit aufteilen“ erscheint. Schalte ihn ein, bestätige, und die App bringt dich für den Rest zurück zur Kategorienwahl, so oft wie nötig.\nSolange noch Zeit zu verteilen ist, findest du sie im Tab Erfassen und in der Timer-Leiste, mit einem Knopf zum Aufgeben, falls du es dir anders überlegst.\nDie Restzeit aufzugeben löscht die bereits verteilten Zeiten nicht.',
17+ E'O cronômetro agora pode ser repartido entre várias categorias. Ao confirmar o tempo medido, diminua a duração: aparece uma chave « Repartir o tempo restante ». Ative, confirme, e o app te leva de volta à escolha da categoria para o que sobrou, quantas vezes for preciso.\nEnquanto ainda houver tempo a distribuir, você o encontra na aba Adicionar e na barra do cronômetro, com um botão para abandoná-lo se mudar de ideia.\nAbandonar o tempo restante não apaga os tempos já repartidos.'
18+);
modified src/db/timers.rs +85 -1
@@ -1,8 +1,17 @@
11 use chrono::{DateTime, NaiveDate, Utc};
22 use chrono_tz::Europe::Paris;
3+use serde::{Deserialize, Serialize};
34 use sqlx::PgPool;
5+use sqlx::types::Json;
46 use uuid::Uuid;
57
8+/// Une saisie déjà créditée sur la mesure (cf. migration 0018).
9+#[derive(Serialize, Deserialize, Clone, Debug)]
10+pub struct SplitPart {
11+ pub category_id: Uuid,
12+ pub minutes: i32,
13+}
14+
615 /// Le timer d'un utilisateur (au plus un) : en cours tant que `stopped_at`
716 /// est NULL, mesure en attente d'enregistrement ensuite.
817 #[derive(sqlx::FromRow)]
@@ -11,9 +20,36 @@ pub struct TimerRow {
1120 pub team_name: String,
1221 pub started_at: DateTime<Utc>,
1322 pub stopped_at: Option<DateTime<Utc>>,
23+ /// Saisies déjà créditées sur la mesure — vide pour une mesure fraîche.
24+ pub split: Json<Vec<SplitPart>>,
1425 }
1526
1627 impl TimerRow {
28+ /// Minutes déjà placées par la répartition en cours.
29+ pub fn split_minutes(&self) -> i64 {
30+ self.split.iter().map(|p| p.minutes as i64).sum()
31+ }
32+
33+ /// Une répartition est entamée (au moins une saisie créditée).
34+ pub fn split_started(&self) -> bool {
35+ !self.split.is_empty()
36+ }
37+
38+ /// Minutes de la mesure encore à placer : la mesure moins ce qui a déjà
39+ /// été crédité (min 1 — une mesure soldée est supprimée, pas gardée à 0).
40+ /// None si le timer court encore.
41+ pub fn remaining_minutes(&self) -> Option<i64> {
42+ self.measured_minutes().map(|m| (m - self.split_minutes()).max(1))
43+ }
44+
45+ /// Nombre de catégories distinctes déjà servies.
46+ pub fn split_categories(&self) -> usize {
47+ let mut ids: Vec<Uuid> = self.split.iter().map(|p| p.category_id).collect();
48+ ids.sort();
49+ ids.dedup();
50+ ids.len()
51+ }
52+
1753 /// Minutes écoulées depuis le départ, arrondies à la minute.
1854 pub fn elapsed_minutes(&self, now: DateTime<Utc>) -> i64 {
1955 round_minutes(now - self.started_at).max(0)
@@ -53,7 +89,7 @@ fn round_minutes(d: chrono::Duration) -> i64 {
5389
5490 pub async fn get(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Option<TimerRow>> {
5591 sqlx::query_as(
56- "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at
92+ "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at, t.split
5793 FROM timers t
5894 JOIN teams e ON e.id = t.team_id
5995 WHERE t.user_id = $1",
@@ -116,6 +152,30 @@ pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx
116152 Ok(res.rows_affected())
117153 }
118154
155+/// Crédite une saisie sur la mesure en attente de cette équipe (répartition) :
156+/// la mesure reste en attente, amputée de ces minutes à la lecture. 0 ligne
157+/// si aucune mesure n'attend là.
158+pub async fn credit_pending(
159+ pool: &PgPool,
160+ user_id: Uuid,
161+ team_id: Uuid,
162+ category_id: Uuid,
163+ minutes: i32,
164+) -> sqlx::Result<u64> {
165+ let res = sqlx::query(
166+ "UPDATE timers
167+ SET split = split || jsonb_build_array(jsonb_build_object('category_id', $3::uuid, 'minutes', $4::int))
168+ WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NOT NULL",
169+ )
170+ .bind(user_id)
171+ .bind(team_id)
172+ .bind(category_id)
173+ .bind(minutes)
174+ .execute(pool)
175+ .await?;
176+ Ok(res.rows_affected())
177+}
178+
119179 /// Supprime la mesure en attente de cette équipe — au moment de l'annuler
120180 /// ou une fois transformée en entrée de temps.
121181 pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
@@ -146,9 +206,33 @@ mod tests {
146206 team_name: String::new(),
147207 started_at: parse(started),
148208 stopped_at: stopped.map(parse),
209+ split: Json(Vec::new()),
149210 }
150211 }
151212
213+ #[test]
214+ fn remaining_is_measure_minus_split() {
215+ let mut r = row("2026-08-07 10:00:00", Some("2026-08-07 11:00:00"));
216+ assert_eq!(r.remaining_minutes(), Some(60));
217+ assert!(!r.split_started());
218+ let a = Uuid::now_v7();
219+ let b = Uuid::now_v7();
220+ r.split = Json(vec![
221+ SplitPart { category_id: a, minutes: 25 },
222+ SplitPart { category_id: b, minutes: 10 },
223+ SplitPart { category_id: a, minutes: 5 },
224+ ]);
225+ assert!(r.split_started());
226+ assert_eq!(r.split_minutes(), 40);
227+ assert_eq!(r.remaining_minutes(), Some(20));
228+ assert_eq!(r.split_categories(), 2);
229+ // Timer en cours : rien à placer.
230+ assert_eq!(row("2026-08-07 10:00:00", None).remaining_minutes(), None);
231+ // Jamais sous 1 : un dépassement solde la mesure, il ne la rend pas négative.
232+ r.split = Json(vec![SplitPart { category_id: a, minutes: 90 }]);
233+ assert_eq!(r.remaining_minutes(), Some(1));
234+ }
235+
152236 #[test]
153237 fn measured_rounds_to_nearest_minute_min_one() {
154238 // 20 s → 1 min (plancher), 89 s → 1 min, 90 s → 2 min.
@@ -1,8 +1,17 @@
1 use chrono::{DateTime, NaiveDate, Utc};1 use chrono::{DateTime, NaiveDate, Utc};
2 use chrono_tz::Europe::Paris;2 use chrono_tz::Europe::Paris;
3+use serde::{Deserialize, Serialize};
3 use sqlx::PgPool;4 use sqlx::PgPool;
5+use sqlx::types::Json;
4 use uuid::Uuid;6 use uuid::Uuid;
5 7
8+/// Une saisie déjà créditée sur la mesure (cf. migration 0018).
9+#[derive(Serialize, Deserialize, Clone, Debug)]
10+pub struct SplitPart {
11+ pub category_id: Uuid,
12+ pub minutes: i32,
13+}
14+
6 /// Le timer d'un utilisateur (au plus un) : en cours tant que `stopped_at`15 /// Le timer d'un utilisateur (au plus un) : en cours tant que `stopped_at`
7 /// est NULL, mesure en attente d'enregistrement ensuite.16 /// est NULL, mesure en attente d'enregistrement ensuite.
8 #[derive(sqlx::FromRow)]17 #[derive(sqlx::FromRow)]
@@ -11,9 +20,36 @@ pub struct TimerRow {
11 pub team_name: String,20 pub team_name: String,
12 pub started_at: DateTime<Utc>,21 pub started_at: DateTime<Utc>,
13 pub stopped_at: Option<DateTime<Utc>>,22 pub stopped_at: Option<DateTime<Utc>>,
23+ /// Saisies déjà créditées sur la mesure — vide pour une mesure fraîche.
24+ pub split: Json<Vec<SplitPart>>,
14 }25 }
15 26
16 impl TimerRow {27 impl TimerRow {
28+ /// Minutes déjà placées par la répartition en cours.
29+ pub fn split_minutes(&self) -> i64 {
30+ self.split.iter().map(|p| p.minutes as i64).sum()
31+ }
32+
33+ /// Une répartition est entamée (au moins une saisie créditée).
34+ pub fn split_started(&self) -> bool {
35+ !self.split.is_empty()
36+ }
37+
38+ /// Minutes de la mesure encore à placer : la mesure moins ce qui a déjà
39+ /// été crédité (min 1 — une mesure soldée est supprimée, pas gardée à 0).
40+ /// None si le timer court encore.
41+ pub fn remaining_minutes(&self) -> Option<i64> {
42+ self.measured_minutes().map(|m| (m - self.split_minutes()).max(1))
43+ }
44+
45+ /// Nombre de catégories distinctes déjà servies.
46+ pub fn split_categories(&self) -> usize {
47+ let mut ids: Vec<Uuid> = self.split.iter().map(|p| p.category_id).collect();
48+ ids.sort();
49+ ids.dedup();
50+ ids.len()
51+ }
52+
17 /// Minutes écoulées depuis le départ, arrondies à la minute.53 /// Minutes écoulées depuis le départ, arrondies à la minute.
18 pub fn elapsed_minutes(&self, now: DateTime<Utc>) -> i64 {54 pub fn elapsed_minutes(&self, now: DateTime<Utc>) -> i64 {
19 round_minutes(now - self.started_at).max(0)55 round_minutes(now - self.started_at).max(0)
@@ -53,7 +89,7 @@ fn round_minutes(d: chrono::Duration) -> i64 {
53 89
54 pub async fn get(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Option<TimerRow>> {90 pub async fn get(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Option<TimerRow>> {
55 sqlx::query_as(91 sqlx::query_as(
56- "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at92+ "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at, t.split
57 FROM timers t93 FROM timers t
58 JOIN teams e ON e.id = t.team_id94 JOIN teams e ON e.id = t.team_id
59 WHERE t.user_id = $1",95 WHERE t.user_id = $1",
@@ -116,6 +152,30 @@ pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx
116 Ok(res.rows_affected())152 Ok(res.rows_affected())
117 }153 }
118 154
155+/// Crédite une saisie sur la mesure en attente de cette équipe (répartition) :
156+/// la mesure reste en attente, amputée de ces minutes à la lecture. 0 ligne
157+/// si aucune mesure n'attend là.
158+pub async fn credit_pending(
159+ pool: &PgPool,
160+ user_id: Uuid,
161+ team_id: Uuid,
162+ category_id: Uuid,
163+ minutes: i32,
164+) -> sqlx::Result<u64> {
165+ let res = sqlx::query(
166+ "UPDATE timers
167+ SET split = split || jsonb_build_array(jsonb_build_object('category_id', $3::uuid, 'minutes', $4::int))
168+ WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NOT NULL",
169+ )
170+ .bind(user_id)
171+ .bind(team_id)
172+ .bind(category_id)
173+ .bind(minutes)
174+ .execute(pool)
175+ .await?;
176+ Ok(res.rows_affected())
177+}
178+
119 /// Supprime la mesure en attente de cette équipe — au moment de l'annuler179 /// Supprime la mesure en attente de cette équipe — au moment de l'annuler
120 /// ou une fois transformée en entrée de temps.180 /// ou une fois transformée en entrée de temps.
121 pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {181 pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
@@ -146,9 +206,33 @@ mod tests {
146 team_name: String::new(),206 team_name: String::new(),
147 started_at: parse(started),207 started_at: parse(started),
148 stopped_at: stopped.map(parse),208 stopped_at: stopped.map(parse),
209+ split: Json(Vec::new()),
149 }210 }
150 }211 }
151 212
213+ #[test]
214+ fn remaining_is_measure_minus_split() {
215+ let mut r = row("2026-08-07 10:00:00", Some("2026-08-07 11:00:00"));
216+ assert_eq!(r.remaining_minutes(), Some(60));
217+ assert!(!r.split_started());
218+ let a = Uuid::now_v7();
219+ let b = Uuid::now_v7();
220+ r.split = Json(vec![
221+ SplitPart { category_id: a, minutes: 25 },
222+ SplitPart { category_id: b, minutes: 10 },
223+ SplitPart { category_id: a, minutes: 5 },
224+ ]);
225+ assert!(r.split_started());
226+ assert_eq!(r.split_minutes(), 40);
227+ assert_eq!(r.remaining_minutes(), Some(20));
228+ assert_eq!(r.split_categories(), 2);
229+ // Timer en cours : rien à placer.
230+ assert_eq!(row("2026-08-07 10:00:00", None).remaining_minutes(), None);
231+ // Jamais sous 1 : un dépassement solde la mesure, il ne la rend pas négative.
232+ r.split = Json(vec![SplitPart { category_id: a, minutes: 90 }]);
233+ assert_eq!(r.remaining_minutes(), Some(1));
234+ }
235+
152 #[test]236 #[test]
153 fn measured_rounds_to_nearest_minute_min_one() {237 fn measured_rounds_to_nearest_minute_min_one() {
154 // 20 s → 1 min (plancher), 89 s → 1 min, 90 s → 2 min.238 // 20 s → 1 min (plancher), 89 s → 1 min, 90 s → 2 min.
modified src/i18n.rs +85 -0
@@ -140,6 +140,30 @@ pub struct Tr {
140140 pub finish_timer: &'static str,
141141 pub cancel_timer: &'static str,
142142 pub toast_measure_discarded: &'static str,
143+ /// Répartition d'une mesure sur plusieurs catégories (0018).
144+ /// Toggle de l'étape 2, inactif : « Répartir le temps restant ».
145+ pub split_idle: &'static str,
146+ /// Toggle actif : « Répartir les {} restantes ».
147+ pub split_pat: &'static str,
148+ /// Bouton d'envoi quand la case est cochée.
149+ pub add_continue: &'static str,
150+ /// Carte de l'étape 1 pendant une répartition : « {} restantes à placer ».
151+ pub remaining_pick_pat: &'static str,
152+ pub cancel_remaining: &'static str,
153+ /// Marque « +{} » sur une pill déjà servie, et son libellé accessible.
154+ pub split_mark_pat: &'static str,
155+ pub split_mark_aria_pat: &'static str,
156+ /// Toast de boucle : « {} ajouté ✓ — reste {} à répartir ».
157+ pub added_remaining_pat: &'static str,
158+ /// Toast de bilan : « {} ajouté ✓ — minuteur réparti : {} sur {} ».
159+ pub split_done_pat: &'static str,
160+ pub category_one: &'static str,
161+ pub category_many: &'static str,
162+ /// Dock : « {} restantes » / « à répartir ».
163+ pub remaining_pat: &'static str,
164+ pub split_pending_note: &'static str,
165+ /// Landmark du dock du minuteur (role=region, hors <main> et <header>).
166+ pub timer_dock_aria: &'static str,
143167 pub toast_timer_cancelled: &'static str,
144168 // ─── stats ───
145169 pub title_stats: &'static str,
@@ -416,6 +440,11 @@ impl Tr {
416440 format!("{n} {}", if n > 1 { self.member_many } else { self.member_one })
417441 }
418442
443+ /// « 3 catégories » / "3 categories" / „3 Kategorien“.
444+ pub fn categories_count(&self, n: i64) -> String {
445+ format!("{n} {}", if n > 1 { self.category_many } else { self.category_one })
446+ }
447+
419448 /// « 3 saisies » / "3 entries" / „3 Einträge“.
420449 pub fn entries_count(&self, n: i64) -> String {
421450 format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one })
@@ -500,6 +529,20 @@ pub static FR: Tr = Tr {
500529 finish_timer: "Finir le minuteur",
501530 cancel_timer: "Annuler le minuteur",
502531 toast_measure_discarded: "Temps mesuré annulé",
532+ split_idle: "Répartir le temps restant",
533+ split_pat: "Répartir les {} restantes",
534+ add_continue: "Ajouter et continuer",
535+ remaining_pick_pat: "{} restantes à placer",
536+ cancel_remaining: "Jeter le temps restant",
537+ split_mark_pat: "+{}",
538+ split_mark_aria_pat: "{} déjà réparties depuis ce minuteur",
539+ added_remaining_pat: "{} ajouté ✓ — reste {} à répartir",
540+ split_done_pat: "{} ajouté ✓ — minuteur réparti : {} sur {}",
541+ category_one: "catégorie",
542+ category_many: "catégories",
543+ remaining_pat: "{} restantes",
544+ split_pending_note: "à répartir",
545+ timer_dock_aria: "Minuteur",
503546 toast_timer_cancelled: "Minuteur annulé",
504547 title_stats: "Statistiques",
505548 my_entries_arrow: "Mes saisies →",
@@ -804,6 +847,20 @@ pub static EN: Tr = Tr {
804847 finish_timer: "Finish the timer",
805848 cancel_timer: "Cancel the timer",
806849 toast_measure_discarded: "Measured time discarded",
850+ split_idle: "Split the remaining time",
851+ split_pat: "Split the remaining {}",
852+ add_continue: "Add and continue",
853+ remaining_pick_pat: "{} left to place",
854+ cancel_remaining: "Discard the remaining time",
855+ split_mark_pat: "+{}",
856+ split_mark_aria_pat: "{} already split from this timer",
857+ added_remaining_pat: "{} added ✓ — {} left to split",
858+ split_done_pat: "{} added ✓ — timer split: {} across {}",
859+ category_one: "category",
860+ category_many: "categories",
861+ remaining_pat: "{} left",
862+ split_pending_note: "to split",
863+ timer_dock_aria: "Timer",
807864 toast_timer_cancelled: "Timer cancelled",
808865 title_stats: "Statistics",
809866 my_entries_arrow: "My entries →",
@@ -1108,6 +1165,20 @@ pub static DE: Tr = Tr {
11081165 finish_timer: "Timer beenden",
11091166 cancel_timer: "Timer abbrechen",
11101167 toast_measure_discarded: "Gemessene Zeit verworfen",
1168+ split_idle: "Restzeit aufteilen",
1169+ split_pat: "Restliche {} aufteilen",
1170+ add_continue: "Erfassen und weiter",
1171+ remaining_pick_pat: "{} noch zu verteilen",
1172+ cancel_remaining: "Restzeit verwerfen",
1173+ split_mark_pat: "+{}",
1174+ split_mark_aria_pat: "{} bereits aus diesem Timer verteilt",
1175+ added_remaining_pat: "{} erfasst ✓ — noch {} zu verteilen",
1176+ split_done_pat: "{} erfasst ✓ — Timer aufgeteilt: {} auf {}",
1177+ category_one: "Kategorie",
1178+ category_many: "Kategorien",
1179+ remaining_pat: "{} übrig",
1180+ split_pending_note: "zu verteilen",
1181+ timer_dock_aria: "Timer",
11111182 toast_timer_cancelled: "Timer abgebrochen",
11121183 title_stats: "Statistik",
11131184 my_entries_arrow: "Meine Einträge →",
@@ -1412,6 +1483,20 @@ pub static PT_BR: Tr = Tr {
14121483 finish_timer: "Encerrar o cronômetro",
14131484 cancel_timer: "Cancelar o cronômetro",
14141485 toast_measure_discarded: "Tempo medido descartado",
1486+ split_idle: "Repartir o tempo restante",
1487+ split_pat: "Repartir os {} restantes",
1488+ add_continue: "Adicionar e continuar",
1489+ remaining_pick_pat: "{} restantes a distribuir",
1490+ cancel_remaining: "Descartar o tempo restante",
1491+ split_mark_pat: "+{}",
1492+ split_mark_aria_pat: "{} já repartidos deste cronômetro",
1493+ added_remaining_pat: "{} adicionado ✓ — restam {} a repartir",
1494+ split_done_pat: "{} adicionado ✓ — cronômetro repartido: {} em {}",
1495+ category_one: "categoria",
1496+ category_many: "categorias",
1497+ remaining_pat: "{} restantes",
1498+ split_pending_note: "a repartir",
1499+ timer_dock_aria: "Cronômetro",
14151500 toast_timer_cancelled: "Cronômetro cancelado",
14161501 title_stats: "Estatísticas",
14171502 my_entries_arrow: "Meus registros →",
@@ -140,6 +140,30 @@ pub struct Tr {
140 pub finish_timer: &'static str,140 pub finish_timer: &'static str,
141 pub cancel_timer: &'static str,141 pub cancel_timer: &'static str,
142 pub toast_measure_discarded: &'static str,142 pub toast_measure_discarded: &'static str,
143+ /// Répartition d'une mesure sur plusieurs catégories (0018).
144+ /// Toggle de l'étape 2, inactif : « Répartir le temps restant ».
145+ pub split_idle: &'static str,
146+ /// Toggle actif : « Répartir les {} restantes ».
147+ pub split_pat: &'static str,
148+ /// Bouton d'envoi quand la case est cochée.
149+ pub add_continue: &'static str,
150+ /// Carte de l'étape 1 pendant une répartition : « {} restantes à placer ».
151+ pub remaining_pick_pat: &'static str,
152+ pub cancel_remaining: &'static str,
153+ /// Marque « +{} » sur une pill déjà servie, et son libellé accessible.
154+ pub split_mark_pat: &'static str,
155+ pub split_mark_aria_pat: &'static str,
156+ /// Toast de boucle : « {} ajouté ✓ — reste {} à répartir ».
157+ pub added_remaining_pat: &'static str,
158+ /// Toast de bilan : « {} ajouté ✓ — minuteur réparti : {} sur {} ».
159+ pub split_done_pat: &'static str,
160+ pub category_one: &'static str,
161+ pub category_many: &'static str,
162+ /// Dock : « {} restantes » / « à répartir ».
163+ pub remaining_pat: &'static str,
164+ pub split_pending_note: &'static str,
165+ /// Landmark du dock du minuteur (role=region, hors <main> et <header>).
166+ pub timer_dock_aria: &'static str,
143 pub toast_timer_cancelled: &'static str,167 pub toast_timer_cancelled: &'static str,
144 // ─── stats ───168 // ─── stats ───
145 pub title_stats: &'static str,169 pub title_stats: &'static str,
@@ -416,6 +440,11 @@ impl Tr {
416 format!("{n} {}", if n > 1 { self.member_many } else { self.member_one })440 format!("{n} {}", if n > 1 { self.member_many } else { self.member_one })
417 }441 }
418 442
443+ /// « 3 catégories » / "3 categories" / „3 Kategorien“.
444+ pub fn categories_count(&self, n: i64) -> String {
445+ format!("{n} {}", if n > 1 { self.category_many } else { self.category_one })
446+ }
447+
419 /// « 3 saisies » / "3 entries" / „3 Einträge“.448 /// « 3 saisies » / "3 entries" / „3 Einträge“.
420 pub fn entries_count(&self, n: i64) -> String {449 pub fn entries_count(&self, n: i64) -> String {
421 format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one })450 format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one })
@@ -500,6 +529,20 @@ pub static FR: Tr = Tr {
500 finish_timer: "Finir le minuteur",529 finish_timer: "Finir le minuteur",
501 cancel_timer: "Annuler le minuteur",530 cancel_timer: "Annuler le minuteur",
502 toast_measure_discarded: "Temps mesuré annulé",531 toast_measure_discarded: "Temps mesuré annulé",
532+ split_idle: "Répartir le temps restant",
533+ split_pat: "Répartir les {} restantes",
534+ add_continue: "Ajouter et continuer",
535+ remaining_pick_pat: "{} restantes à placer",
536+ cancel_remaining: "Jeter le temps restant",
537+ split_mark_pat: "+{}",
538+ split_mark_aria_pat: "{} déjà réparties depuis ce minuteur",
539+ added_remaining_pat: "{} ajouté ✓ — reste {} à répartir",
540+ split_done_pat: "{} ajouté ✓ — minuteur réparti : {} sur {}",
541+ category_one: "catégorie",
542+ category_many: "catégories",
543+ remaining_pat: "{} restantes",
544+ split_pending_note: "à répartir",
545+ timer_dock_aria: "Minuteur",
503 toast_timer_cancelled: "Minuteur annulé",546 toast_timer_cancelled: "Minuteur annulé",
504 title_stats: "Statistiques",547 title_stats: "Statistiques",
505 my_entries_arrow: "Mes saisies →",548 my_entries_arrow: "Mes saisies →",
@@ -804,6 +847,20 @@ pub static EN: Tr = Tr {
804 finish_timer: "Finish the timer",847 finish_timer: "Finish the timer",
805 cancel_timer: "Cancel the timer",848 cancel_timer: "Cancel the timer",
806 toast_measure_discarded: "Measured time discarded",849 toast_measure_discarded: "Measured time discarded",
850+ split_idle: "Split the remaining time",
851+ split_pat: "Split the remaining {}",
852+ add_continue: "Add and continue",
853+ remaining_pick_pat: "{} left to place",
854+ cancel_remaining: "Discard the remaining time",
855+ split_mark_pat: "+{}",
856+ split_mark_aria_pat: "{} already split from this timer",
857+ added_remaining_pat: "{} added ✓ — {} left to split",
858+ split_done_pat: "{} added ✓ — timer split: {} across {}",
859+ category_one: "category",
860+ category_many: "categories",
861+ remaining_pat: "{} left",
862+ split_pending_note: "to split",
863+ timer_dock_aria: "Timer",
807 toast_timer_cancelled: "Timer cancelled",864 toast_timer_cancelled: "Timer cancelled",
808 title_stats: "Statistics",865 title_stats: "Statistics",
809 my_entries_arrow: "My entries →",866 my_entries_arrow: "My entries →",
@@ -1108,6 +1165,20 @@ pub static DE: Tr = Tr {
1108 finish_timer: "Timer beenden",1165 finish_timer: "Timer beenden",
1109 cancel_timer: "Timer abbrechen",1166 cancel_timer: "Timer abbrechen",
1110 toast_measure_discarded: "Gemessene Zeit verworfen",1167 toast_measure_discarded: "Gemessene Zeit verworfen",
1168+ split_idle: "Restzeit aufteilen",
1169+ split_pat: "Restliche {} aufteilen",
1170+ add_continue: "Erfassen und weiter",
1171+ remaining_pick_pat: "{} noch zu verteilen",
1172+ cancel_remaining: "Restzeit verwerfen",
1173+ split_mark_pat: "+{}",
1174+ split_mark_aria_pat: "{} bereits aus diesem Timer verteilt",
1175+ added_remaining_pat: "{} erfasst ✓ — noch {} zu verteilen",
1176+ split_done_pat: "{} erfasst ✓ — Timer aufgeteilt: {} auf {}",
1177+ category_one: "Kategorie",
1178+ category_many: "Kategorien",
1179+ remaining_pat: "{} übrig",
1180+ split_pending_note: "zu verteilen",
1181+ timer_dock_aria: "Timer",
1111 toast_timer_cancelled: "Timer abgebrochen",1182 toast_timer_cancelled: "Timer abgebrochen",
1112 title_stats: "Statistik",1183 title_stats: "Statistik",
1113 my_entries_arrow: "Meine Einträge →",1184 my_entries_arrow: "Meine Einträge →",
@@ -1412,6 +1483,20 @@ pub static PT_BR: Tr = Tr {
1412 finish_timer: "Encerrar o cronômetro",1483 finish_timer: "Encerrar o cronômetro",
1413 cancel_timer: "Cancelar o cronômetro",1484 cancel_timer: "Cancelar o cronômetro",
1414 toast_measure_discarded: "Tempo medido descartado",1485 toast_measure_discarded: "Tempo medido descartado",
1486+ split_idle: "Repartir o tempo restante",
1487+ split_pat: "Repartir os {} restantes",
1488+ add_continue: "Adicionar e continuar",
1489+ remaining_pick_pat: "{} restantes a distribuir",
1490+ cancel_remaining: "Descartar o tempo restante",
1491+ split_mark_pat: "+{}",
1492+ split_mark_aria_pat: "{} já repartidos deste cronômetro",
1493+ added_remaining_pat: "{} adicionado ✓ — restam {} a repartir",
1494+ split_done_pat: "{} adicionado ✓ — cronômetro repartido: {} em {}",
1495+ category_one: "categoria",
1496+ category_many: "categorias",
1497+ remaining_pat: "{} restantes",
1498+ split_pending_note: "a repartir",
1499+ timer_dock_aria: "Cronômetro",
1415 toast_timer_cancelled: "Cronômetro cancelado",1500 toast_timer_cancelled: "Cronômetro cancelado",
1416 title_stats: "Estatísticas",1501 title_stats: "Estatísticas",
1417 my_entries_arrow: "Meus registros →",1502 my_entries_arrow: "Meus registros →",
modified src/routes/entries.rs +105 -10
@@ -13,7 +13,7 @@ use crate::db::entries::{self, EntryRow};
1313 use crate::db::categories::{self, CategoryRow};
1414 use crate::db::timers;
1515 use crate::error::AppError;
16-use crate::i18n::p1;
16+use crate::i18n::{p1, p2};
1717 use crate::period::{DateVocab, Week, day_label, paris_today};
1818 use crate::render::{HtmlTemplate, Shell};
1919 use crate::routes::{Preset, duration_presets, shell};
@@ -30,13 +30,20 @@ pub struct PickCategory {
3030 pub name: String,
3131 /// « 1 h 30 cette semaine » (mon temps, semaine en cours) — vide sinon.
3232 pub week_label: String,
33+ /// « +25 min » déjà crédités depuis la mesure en cours de répartition —
34+ /// vide sinon. `split_aria` porte la version parlée.
35+ pub split_label: String,
36+ pub split_aria: String,
3337 }
3438
35-/// Bandeau « X min mesurées » d'une mesure de timer en attente.
39+/// Bandeau « X min mesurées » d'une mesure de timer en attente — ou
40+/// « X min restantes à placer » quand une répartition est entamée.
3641 pub struct MeasuredView {
3742 pub title: String,
3843 /// Mesure > 4 h : probable timer oublié.
3944 pub warning: bool,
45+ /// « Annuler ce temps » / « Jeter le temps restant ».
46+ pub cancel_label: &'static str,
4047 }
4148
4249 /// Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle.
@@ -99,6 +106,10 @@ pub struct AddStep2Page {
99106 pub day_prev: String,
100107 /// None = aujourd'hui, flèche désactivée.
101108 pub day_next: Option<String>,
109+ /// Minutes d'une mesure de minuteur en attente (reste à placer) : la
110+ /// durée en est préremplie et le toggle « Répartir » existe. None pour
111+ /// une saisie manuelle.
112+ pub remaining: Option<i64>,
102113 }
103114
104115 #[derive(Deserialize)]
@@ -110,6 +121,11 @@ pub struct NewQuery {
110121 date: Option<NaiveDate>,
111122 /// minutes de la saisie qui vient d'aboutir (toast).
112123 ok: Option<i64>,
124+ /// avec `ok` : reste à répartir après cette saisie (toast de boucle).
125+ reste: Option<i64>,
126+ /// avec `ok` : total réparti et nombre de catégories (toast de bilan).
127+ reparti: Option<i64>,
128+ cats: Option<i64>,
113129 /// mesure de timer jetée (toast).
114130 annule: Option<u8>,
115131 /// minuteur annulé sans mesure (toast).
@@ -132,7 +148,15 @@ pub async fn entry_new(
132148 } else if q.abandonne.is_some() {
133149 Some(tr.toast_timer_cancelled.to_string())
134150 } else {
135- q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m)))
151+ q.ok.map(|m| match (q.reste, q.reparti.zip(q.cats)) {
152+ (Some(r), _) => p2(tr.added_remaining_pat, fmt_minutes(m), fmt_minutes(r)),
153+ (None, Some((total, n))) => p2(
154+ &p1(tr.split_done_pat, fmt_minutes(m)),
155+ fmt_minutes(total),
156+ tr.categories_count(n),
157+ ),
158+ _ => p1(tr.added_pat, fmt_minutes(m)),
159+ })
136160 };
137161 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
138162 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
@@ -187,10 +211,10 @@ pub async fn entry_new(
187211 .categorie
188212 .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived))
189213 {
190- let measured = pending.as_ref().and_then(|t| t.measured_minutes());
214+ let remaining = pending.as_ref().and_then(|t| t.remaining_minutes());
191215 let minutes = q
192216 .minutes
193- .or(measured.map(|m| m.min(i32::MAX as i64) as i32))
217+ .or(remaining.map(|m| m.min(i32::MAX as i64) as i32))
194218 .unwrap_or(30)
195219 .max(1);
196220 let date = q
@@ -219,6 +243,7 @@ pub async fn entry_new(
219243 day_prev: step2_href(picked.id, minutes, date - Duration::days(1)),
220244 day_next: (date < paris_today())
221245 .then(|| step2_href(picked.id, minutes, date + Duration::days(1))),
246+ remaining,
222247 })
223248 .into_response());
224249 }
@@ -230,6 +255,13 @@ pub async fn entry_new(
230255 .await?
231256 .into_iter()
232257 .collect();
258+ // Minutes déjà créditées par catégorie dans la répartition en cours.
259+ let mut split: HashMap<Uuid, i64> = HashMap::new();
260+ if let Some(t) = pending.as_ref() {
261+ for p in t.split.iter() {
262+ *split.entry(p.category_id).or_default() += p.minutes as i64;
263+ }
264+ }
233265 let mut active: Vec<&CategoryRow> = all_tasks.iter().filter(|t| !t.archived).collect();
234266 active.sort_by(|a, b| a.name.cmp(&b.name));
235267 let categories = active
@@ -242,13 +274,34 @@ pub async fn entry_new(
242274 .get(&t.id)
243275 .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m)))
244276 .unwrap_or_default(),
277+ split_label: split
278+ .get(&t.id)
279+ .map(|m| p1(tr.split_mark_pat, fmt_minutes(*m)))
280+ .unwrap_or_default(),
281+ split_aria: split
282+ .get(&t.id)
283+ .map(|m| p1(tr.split_mark_aria_pat, fmt_minutes(*m)))
284+ .unwrap_or_default(),
245285 })
246286 .collect();
247287 let can_back = pending.is_none();
248- let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView {
249- title: p1(tr.measured_pick_pat, fmt_minutes(m)),
250- warning: m > 240,
251- });
288+ let measured = pending.as_ref().and_then(|t| t.remaining_minutes().map(|m| (t, m))).map(
289+ |(t, m)| {
290+ if t.split_started() {
291+ MeasuredView {
292+ title: p1(tr.remaining_pick_pat, fmt_minutes(m)),
293+ warning: false,
294+ cancel_label: tr.cancel_remaining,
295+ }
296+ } else {
297+ MeasuredView {
298+ title: p1(tr.measured_pick_pat, fmt_minutes(m)),
299+ warning: m > 240,
300+ cancel_label: tr.cancel_measured,
301+ }
302+ }
303+ },
304+ );
252305 let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?;
253306 Ok(HtmlTemplate(AddStep1Page {
254307 shell: sh,
@@ -265,6 +318,10 @@ pub struct EntryForm {
265318 categorie: Uuid,
266319 date: NaiveDate,
267320 csrf: String,
321+ /// Case « Répartir le temps restant » (étape 2 alimentée par une mesure,
322+ /// JS seulement) : garder la mesure amputée de cette saisie plutôt que
323+ /// la consommer. Ignorée si la durée n'est pas inférieure au reste.
324+ repartir: Option<String>,
268325 }
269326
270327 fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> {
@@ -297,8 +354,46 @@ pub async fn entry_create(
297354 ctx.tr().err_cat_team.into(),
298355 ));
299356 }
300- // Une mesure de timer en attente est consommée par l'enregistrement.
357+ // Une mesure de timer en attente est consommée par l'enregistrement —
358+ // sauf répartition demandée avec une durée inférieure au reste : la
359+ // mesure survit, créditée, et la boucle repart au choix de catégorie.
360+ let pending = timers::get(&state.pool, ctx.user.id)
361+ .await?
362+ .filter(|t| t.team_id == ctx.team_id)
363+ .and_then(|t| t.remaining_minutes().map(|r| (t, r)));
364+ let Some((timer, remaining)) = pending else {
365+ return Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)));
366+ };
367+ let wants_split = form.repartir.as_deref().is_some_and(|v| v == "1");
368+ if wants_split && (form.minutes as i64) < remaining {
369+ timers::credit_pending(
370+ &state.pool,
371+ ctx.user.id,
372+ ctx.team_id,
373+ form.categorie,
374+ form.minutes,
375+ )
376+ .await?;
377+ return Ok(Redirect::to(&format!(
378+ "/temps/nouveau?ok={}&reste={}",
379+ form.minutes,
380+ remaining - form.minutes as i64
381+ )));
382+ }
301383 timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?;
384+ // Bilan quand une répartition entamée se solde (le reste tombe à zéro) ;
385+ // un reste abandonné sans cocher part en silence, toast ordinaire.
386+ if timer.split_started() && (form.minutes as i64) >= remaining {
387+ let total = timer.split_minutes() + form.minutes as i64;
388+ let mut cats = timer.split_categories() as i64;
389+ if !timer.split.iter().any(|p| p.category_id == form.categorie) {
390+ cats += 1;
391+ }
392+ return Ok(Redirect::to(&format!(
393+ "/temps/nouveau?ok={}&reparti={total}&cats={cats}",
394+ form.minutes
395+ )));
396+ }
302397 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))
303398 }
304399
@@ -13,7 +13,7 @@ use crate::db::entries::{self, EntryRow};
13 use crate::db::categories::{self, CategoryRow};13 use crate::db::categories::{self, CategoryRow};
14 use crate::db::timers;14 use crate::db::timers;
15 use crate::error::AppError;15 use crate::error::AppError;
16-use crate::i18n::p1;16+use crate::i18n::{p1, p2};
17 use crate::period::{DateVocab, Week, day_label, paris_today};17 use crate::period::{DateVocab, Week, day_label, paris_today};
18 use crate::render::{HtmlTemplate, Shell};18 use crate::render::{HtmlTemplate, Shell};
19 use crate::routes::{Preset, duration_presets, shell};19 use crate::routes::{Preset, duration_presets, shell};
@@ -30,13 +30,20 @@ pub struct PickCategory {
30 pub name: String,30 pub name: String,
31 /// « 1 h 30 cette semaine » (mon temps, semaine en cours) — vide sinon.31 /// « 1 h 30 cette semaine » (mon temps, semaine en cours) — vide sinon.
32 pub week_label: String,32 pub week_label: String,
33+ /// « +25 min » déjà crédités depuis la mesure en cours de répartition —
34+ /// vide sinon. `split_aria` porte la version parlée.
35+ pub split_label: String,
36+ pub split_aria: String,
33 }37 }
34 38
35-/// Bandeau « X min mesurées » d'une mesure de timer en attente.39+/// Bandeau « X min mesurées » d'une mesure de timer en attente — ou
40+/// « X min restantes à placer » quand une répartition est entamée.
36 pub struct MeasuredView {41 pub struct MeasuredView {
37 pub title: String,42 pub title: String,
38 /// Mesure > 4 h : probable timer oublié.43 /// Mesure > 4 h : probable timer oublié.
39 pub warning: bool,44 pub warning: bool,
45+ /// « Annuler ce temps » / « Jeter le temps restant ».
46+ pub cancel_label: &'static str,
40 }47 }
41 48
42 /// Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle.49 /// Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle.
@@ -99,6 +106,10 @@ pub struct AddStep2Page {
99 pub day_prev: String,106 pub day_prev: String,
100 /// None = aujourd'hui, flèche désactivée.107 /// None = aujourd'hui, flèche désactivée.
101 pub day_next: Option<String>,108 pub day_next: Option<String>,
109+ /// Minutes d'une mesure de minuteur en attente (reste à placer) : la
110+ /// durée en est préremplie et le toggle « Répartir » existe. None pour
111+ /// une saisie manuelle.
112+ pub remaining: Option<i64>,
102 }113 }
103 114
104 #[derive(Deserialize)]115 #[derive(Deserialize)]
@@ -110,6 +121,11 @@ pub struct NewQuery {
110 date: Option<NaiveDate>,121 date: Option<NaiveDate>,
111 /// minutes de la saisie qui vient d'aboutir (toast).122 /// minutes de la saisie qui vient d'aboutir (toast).
112 ok: Option<i64>,123 ok: Option<i64>,
124+ /// avec `ok` : reste à répartir après cette saisie (toast de boucle).
125+ reste: Option<i64>,
126+ /// avec `ok` : total réparti et nombre de catégories (toast de bilan).
127+ reparti: Option<i64>,
128+ cats: Option<i64>,
113 /// mesure de timer jetée (toast).129 /// mesure de timer jetée (toast).
114 annule: Option<u8>,130 annule: Option<u8>,
115 /// minuteur annulé sans mesure (toast).131 /// minuteur annulé sans mesure (toast).
@@ -132,7 +148,15 @@ pub async fn entry_new(
132 } else if q.abandonne.is_some() {148 } else if q.abandonne.is_some() {
133 Some(tr.toast_timer_cancelled.to_string())149 Some(tr.toast_timer_cancelled.to_string())
134 } else {150 } else {
135- q.ok.map(|m| p1(tr.added_pat, fmt_minutes(m)))151+ q.ok.map(|m| match (q.reste, q.reparti.zip(q.cats)) {
152+ (Some(r), _) => p2(tr.added_remaining_pat, fmt_minutes(m), fmt_minutes(r)),
153+ (None, Some((total, n))) => p2(
154+ &p1(tr.split_done_pat, fmt_minutes(m)),
155+ fmt_minutes(total),
156+ tr.categories_count(n),
157+ ),
158+ _ => p1(tr.added_pat, fmt_minutes(m)),
159+ })
136 };160 };
137 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;161 let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?;
138 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);162 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>(), ctx.hc);
@@ -187,10 +211,10 @@ pub async fn entry_new(
187 .categorie211 .categorie
188 .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived))212 .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived))
189 {213 {
190- let measured = pending.as_ref().and_then(|t| t.measured_minutes());214+ let remaining = pending.as_ref().and_then(|t| t.remaining_minutes());
191 let minutes = q215 let minutes = q
192 .minutes216 .minutes
193- .or(measured.map(|m| m.min(i32::MAX as i64) as i32))217+ .or(remaining.map(|m| m.min(i32::MAX as i64) as i32))
194 .unwrap_or(30)218 .unwrap_or(30)
195 .max(1);219 .max(1);
196 let date = q220 let date = q
@@ -219,6 +243,7 @@ pub async fn entry_new(
219 day_prev: step2_href(picked.id, minutes, date - Duration::days(1)),243 day_prev: step2_href(picked.id, minutes, date - Duration::days(1)),
220 day_next: (date < paris_today())244 day_next: (date < paris_today())
221 .then(|| step2_href(picked.id, minutes, date + Duration::days(1))),245 .then(|| step2_href(picked.id, minutes, date + Duration::days(1))),
246+ remaining,
222 })247 })
223 .into_response());248 .into_response());
224 }249 }
@@ -230,6 +255,13 @@ pub async fn entry_new(
230 .await?255 .await?
231 .into_iter()256 .into_iter()
232 .collect();257 .collect();
258+ // Minutes déjà créditées par catégorie dans la répartition en cours.
259+ let mut split: HashMap<Uuid, i64> = HashMap::new();
260+ if let Some(t) = pending.as_ref() {
261+ for p in t.split.iter() {
262+ *split.entry(p.category_id).or_default() += p.minutes as i64;
263+ }
264+ }
233 let mut active: Vec<&CategoryRow> = all_tasks.iter().filter(|t| !t.archived).collect();265 let mut active: Vec<&CategoryRow> = all_tasks.iter().filter(|t| !t.archived).collect();
234 active.sort_by(|a, b| a.name.cmp(&b.name));266 active.sort_by(|a, b| a.name.cmp(&b.name));
235 let categories = active267 let categories = active
@@ -242,13 +274,34 @@ pub async fn entry_new(
242 .get(&t.id)274 .get(&t.id)
243 .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m)))275 .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m)))
244 .unwrap_or_default(),276 .unwrap_or_default(),
277+ split_label: split
278+ .get(&t.id)
279+ .map(|m| p1(tr.split_mark_pat, fmt_minutes(*m)))
280+ .unwrap_or_default(),
281+ split_aria: split
282+ .get(&t.id)
283+ .map(|m| p1(tr.split_mark_aria_pat, fmt_minutes(*m)))
284+ .unwrap_or_default(),
245 })285 })
246 .collect();286 .collect();
247 let can_back = pending.is_none();287 let can_back = pending.is_none();
248- let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView {288+ let measured = pending.as_ref().and_then(|t| t.remaining_minutes().map(|m| (t, m))).map(
249- title: p1(tr.measured_pick_pat, fmt_minutes(m)),289+ |(t, m)| {
250- warning: m > 240,290+ if t.split_started() {
251- });291+ MeasuredView {
292+ title: p1(tr.remaining_pick_pat, fmt_minutes(m)),
293+ warning: false,
294+ cancel_label: tr.cancel_remaining,
295+ }
296+ } else {
297+ MeasuredView {
298+ title: p1(tr.measured_pick_pat, fmt_minutes(m)),
299+ warning: m > 240,
300+ cancel_label: tr.cancel_measured,
301+ }
302+ }
303+ },
304+ );
252 let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?;305 let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?;
253 Ok(HtmlTemplate(AddStep1Page {306 Ok(HtmlTemplate(AddStep1Page {
254 shell: sh,307 shell: sh,
@@ -265,6 +318,10 @@ pub struct EntryForm {
265 categorie: Uuid,318 categorie: Uuid,
266 date: NaiveDate,319 date: NaiveDate,
267 csrf: String,320 csrf: String,
321+ /// Case « Répartir le temps restant » (étape 2 alimentée par une mesure,
322+ /// JS seulement) : garder la mesure amputée de cette saisie plutôt que
323+ /// la consommer. Ignorée si la durée n'est pas inférieure au reste.
324+ repartir: Option<String>,
268 }325 }
269 326
270 fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> {327 fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> {
@@ -297,8 +354,46 @@ pub async fn entry_create(
297 ctx.tr().err_cat_team.into(),354 ctx.tr().err_cat_team.into(),
298 ));355 ));
299 }356 }
300- // Une mesure de timer en attente est consommée par l'enregistrement.357+ // Une mesure de timer en attente est consommée par l'enregistrement —
358+ // sauf répartition demandée avec une durée inférieure au reste : la
359+ // mesure survit, créditée, et la boucle repart au choix de catégorie.
360+ let pending = timers::get(&state.pool, ctx.user.id)
361+ .await?
362+ .filter(|t| t.team_id == ctx.team_id)
363+ .and_then(|t| t.remaining_minutes().map(|r| (t, r)));
364+ let Some((timer, remaining)) = pending else {
365+ return Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)));
366+ };
367+ let wants_split = form.repartir.as_deref().is_some_and(|v| v == "1");
368+ if wants_split && (form.minutes as i64) < remaining {
369+ timers::credit_pending(
370+ &state.pool,
371+ ctx.user.id,
372+ ctx.team_id,
373+ form.categorie,
374+ form.minutes,
375+ )
376+ .await?;
377+ return Ok(Redirect::to(&format!(
378+ "/temps/nouveau?ok={}&reste={}",
379+ form.minutes,
380+ remaining - form.minutes as i64
381+ )));
382+ }
301 timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?;383 timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?;
384+ // Bilan quand une répartition entamée se solde (le reste tombe à zéro) ;
385+ // un reste abandonné sans cocher part en silence, toast ordinaire.
386+ if timer.split_started() && (form.minutes as i64) >= remaining {
387+ let total = timer.split_minutes() + form.minutes as i64;
388+ let mut cats = timer.split_categories() as i64;
389+ if !timer.split.iter().any(|p| p.category_id == form.categorie) {
390+ cats += 1;
391+ }
392+ return Ok(Redirect::to(&format!(
393+ "/temps/nouveau?ok={}&reparti={total}&cats={cats}",
394+ form.minutes
395+ )));
396+ }
302 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))397 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))
303 }398 }
304 399
modified src/routes/mod.rs +7 -2
@@ -208,16 +208,21 @@ pub async fn shell(
208208 // l'information que le dock est seul à porter (le clic y bascule au
209209 // passage). Le libellé « 47 min mesurées » dit déjà de quoi il s'agit
210210 // pour une mesure ; un minuteur en cours, non, d'où le motif long.
211+ // Une répartition entamée (0018) parle de reste, pas de mesure.
211212 let note = match (foreign, pending) {
212213 (true, true) => crate::i18n::p1(tr.in_team_pat, &t.team_name),
213214 (true, false) => crate::i18n::p1(tr.timer_in_team_pat, &t.team_name),
215+ (false, true) if t.split_started() => tr.split_pending_note.to_string(),
214216 (false, true) => tr.measure_pending_note.to_string(),
215217 (false, false) => tr.timer_running_note.to_string(),
216218 };
217- let (running, label, cta, action) = match t.measured_minutes() {
219+ let (running, label, cta, action) = match t.remaining_minutes() {
218220 Some(m) => (
219221 false,
220- crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),
222+ crate::i18n::p1(
223+ if t.split_started() { tr.remaining_pat } else { tr.measured_pat },
224+ fmt_minutes(m),
225+ ),
221226 tr.record_cta,
222227 tr.record_measured_aria,
223228 ),
@@ -208,16 +208,21 @@ pub async fn shell(
208 // l'information que le dock est seul à porter (le clic y bascule au208 // l'information que le dock est seul à porter (le clic y bascule au
209 // passage). Le libellé « 47 min mesurées » dit déjà de quoi il s'agit209 // passage). Le libellé « 47 min mesurées » dit déjà de quoi il s'agit
210 // pour une mesure ; un minuteur en cours, non, d'où le motif long.210 // pour une mesure ; un minuteur en cours, non, d'où le motif long.
211+ // Une répartition entamée (0018) parle de reste, pas de mesure.
211 let note = match (foreign, pending) {212 let note = match (foreign, pending) {
212 (true, true) => crate::i18n::p1(tr.in_team_pat, &t.team_name),213 (true, true) => crate::i18n::p1(tr.in_team_pat, &t.team_name),
213 (true, false) => crate::i18n::p1(tr.timer_in_team_pat, &t.team_name),214 (true, false) => crate::i18n::p1(tr.timer_in_team_pat, &t.team_name),
215+ (false, true) if t.split_started() => tr.split_pending_note.to_string(),
214 (false, true) => tr.measure_pending_note.to_string(),216 (false, true) => tr.measure_pending_note.to_string(),
215 (false, false) => tr.timer_running_note.to_string(),217 (false, false) => tr.timer_running_note.to_string(),
216 };218 };
217- let (running, label, cta, action) = match t.measured_minutes() {219+ let (running, label, cta, action) = match t.remaining_minutes() {
218 Some(m) => (220 Some(m) => (
219 false,221 false,
220- crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),222+ crate::i18n::p1(
223+ if t.split_started() { tr.remaining_pat } else { tr.measured_pat },
224+ fmt_minutes(m),
225+ ),
221 tr.record_cta,226 tr.record_cta,
222 tr.record_measured_aria,227 tr.record_measured_aria,
223 ),228 ),
modified templates/add_step1.html +2 -1
@@ -9,7 +9,7 @@
99 {% if m.warning %}<p class="warn">{{ shell.tr.forgot_timer }}</p>{% endif %}
1010 <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0">
1111 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
12- <button type="submit" class="btn btn-ghost" style="font-size:0.8125rem;padding-left:0;color:var(--color-accent-900)">{{ shell.tr.cancel_measured }}</button>
12+ <button type="submit" class="btn btn-ghost" style="font-size:0.8125rem;padding-left:0;color:var(--color-accent-900)">{{ m.cancel_label }}</button>
1313 </form>
1414 </div>
1515 {% endif %}
@@ -23,6 +23,7 @@
2323 <span class="dot dot-14" style="background:{{ t.color }}"></span>
2424 <span class="tname">{{ t.name }}</span>
2525 <span class="tweek">{{ t.week_label }}</span>
26+ {% if !t.split_label.is_empty() %}<span class="tsplit"><span aria-hidden="true">{{ t.split_label }}</span><span class="visually-hidden">{{ t.split_aria }}</span></span>{% endif %}
2627 </a>
2728 {% endfor %}
2829 </div>
@@ -9,7 +9,7 @@
9 {% if m.warning %}<p class="warn">{{ shell.tr.forgot_timer }}</p>{% endif %}9 {% if m.warning %}<p class="warn">{{ shell.tr.forgot_timer }}</p>{% endif %}
10 <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0">10 <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0">
11 <input type="hidden" name="csrf" value="{{ shell.csrf }}">11 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
12- <button type="submit" class="btn btn-ghost" style="font-size:0.8125rem;padding-left:0;color:var(--color-accent-900)">{{ shell.tr.cancel_measured }}</button>12+ <button type="submit" class="btn btn-ghost" style="font-size:0.8125rem;padding-left:0;color:var(--color-accent-900)">{{ m.cancel_label }}</button>
13 </form>13 </form>
14 </div>14 </div>
15 {% endif %}15 {% endif %}
@@ -23,6 +23,7 @@
23 <span class="dot dot-14" style="background:{{ t.color }}"></span>23 <span class="dot dot-14" style="background:{{ t.color }}"></span>
24 <span class="tname">{{ t.name }}</span>24 <span class="tname">{{ t.name }}</span>
25 <span class="tweek">{{ t.week_label }}</span>25 <span class="tweek">{{ t.week_label }}</span>
26+ {% if !t.split_label.is_empty() %}<span class="tsplit"><span aria-hidden="true">{{ t.split_label }}</span><span class="visually-hidden">{{ t.split_aria }}</span></span>{% endif %}
26 </a>27 </a>
27 {% endfor %}28 {% endfor %}
28 </div>29 </div>
modified templates/add_step2.html +15 -1
@@ -31,6 +31,20 @@
3131 data-months="{{ vocab.months }}" data-today="{{ vocab.today }}"
3232 data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p>
3333
34- <button type="submit" class="btn btn-primary btn-block btn-lg">{{ shell.tr.title_add }}</button>
34+ {% if let Some(r) = remaining %}
35+ {#- Répartition d'une mesure (0018) — réservée au JS, écart assumé à la
36+ règle zéro-JS : rendu hidden + case disabled, le JS révèle le bloc et
37+ pilote la case d'après la durée saisie (app.html, form[data-duration]).
38+ Sans JS le bloc n'existe pas et la mesure est consommée comme avant. -#}
39+ <div class="split-row" hidden data-split data-remaining="{{ r }}">
40+ <label class="swt-check split-check">
41+ <span class="split-label" data-pat="{{ shell.tr.split_pat }}" data-idle="{{ shell.tr.split_idle }}">{{ shell.tr.split_idle }}</span>
42+ <input type="checkbox" name="repartir" value="1" disabled>
43+ <span class="swt"><span class="knob"></span></span>
44+ </label>
45+ </div>
46+ {% endif %}
47+
48+ <button type="submit" class="btn btn-primary btn-block btn-lg" data-label-add="{{ shell.tr.title_add }}" data-label-continue="{{ shell.tr.add_continue }}">{{ shell.tr.title_add }}</button>
3549 </form>
3650 {% endblock %}
@@ -31,6 +31,20 @@
31 data-months="{{ vocab.months }}" data-today="{{ vocab.today }}"31 data-months="{{ vocab.months }}" data-today="{{ vocab.today }}"
32 data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p>32 data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p>
33 33
34- <button type="submit" class="btn btn-primary btn-block btn-lg">{{ shell.tr.title_add }}</button>34+ {% if let Some(r) = remaining %}
35+ {#- Répartition d'une mesure (0018) — réservée au JS, écart assumé à la
36+ règle zéro-JS : rendu hidden + case disabled, le JS révèle le bloc et
37+ pilote la case d'après la durée saisie (app.html, form[data-duration]).
38+ Sans JS le bloc n'existe pas et la mesure est consommée comme avant. -#}
39+ <div class="split-row" hidden data-split data-remaining="{{ r }}">
40+ <label class="swt-check split-check">
41+ <span class="split-label" data-pat="{{ shell.tr.split_pat }}" data-idle="{{ shell.tr.split_idle }}">{{ shell.tr.split_idle }}</span>
42+ <input type="checkbox" name="repartir" value="1" disabled>
43+ <span class="swt"><span class="knob"></span></span>
44+ </label>
45+ </div>
46+ {% endif %}
47+
48+ <button type="submit" class="btn btn-primary btn-block btn-lg" data-label-add="{{ shell.tr.title_add }}" data-label-continue="{{ shell.tr.add_continue }}">{{ shell.tr.title_add }}</button>
35 </form>49 </form>
36 {% endblock %}50 {% endblock %}
modified templates/app.html +33 -4
@@ -26,7 +26,7 @@
2626 chrono ou le bandeau « X min mesurées »), sauf quand le timer tourne dans
2727 une AUTRE équipe — là, rien d'autre ne le signale. -#}
2828 {% if let Some(t) = shell.timer %}{% if shell.tab != "add" || t.foreign %}
29-<div class="timer-dock">
29+<div class="timer-dock" role="region" aria-label="{{ shell.tr.timer_dock_aria }}">
3030 {% if t.running || t.foreign %}
3131 {#- Arrêter : bascule au passage sur l'équipe du timer si besoin. -#}
3232 <form method="post" action="/timer/arreter" style="margin:0">
@@ -174,17 +174,45 @@
174174 // sheet injectée par l'écran Tâches) : steppers, presets et libellé sans
175175 // recharger. Délégation : fonctionne aussi sur du contenu injecté.
176176 (function () {
177+ function fmtMinutes(v) {
178+ var h = Math.floor(v / 60), r = v % 60;
179+ return !v ? '0 min' : !h ? r + ' min' : r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
180+ }
181+ // Répartition d'une mesure de minuteur (réservée au JS) : le toggle vit
182+ // quand la durée saisie laisse un reste, se décoche sinon, et le bouton
183+ // d'envoi annonce la suite. Les textes viennent des data-* du serveur.
184+ function refreshSplit(root, v) {
185+ var box = root.querySelector('[data-split]');
186+ if (!box) return;
187+ var cb = box.querySelector('input[type="checkbox"]');
188+ var label = box.querySelector('.split-label');
189+ var rest = (+box.dataset.remaining) - v;
190+ var on = v > 0 && rest > 0;
191+ cb.disabled = !on;
192+ if (!on) cb.checked = false;
193+ label.textContent = on ? label.dataset.pat.replace('{}', fmtMinutes(rest)) : label.dataset.idle;
194+ refreshSubmit(root);
195+ }
196+ function refreshSubmit(root) {
197+ var btn = root.querySelector('button[type="submit"][data-label-continue]');
198+ var cb = root.querySelector('[data-split] input[type="checkbox"]');
199+ if (!btn || !cb) return;
200+ btn.textContent = cb.checked && !cb.disabled ? btn.dataset.labelContinue : btn.dataset.labelAdd;
201+ }
177202 function refreshMinutes(root) {
178203 var min = root.querySelector('#minutes');
179204 var v = Math.round(+min.value) || 0;
180- var h = Math.floor(v / 60), r = v % 60;
181- root.querySelector('#minutes-pretty').textContent =
182- !v ? '0 min' : !h ? r + ' min' : r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
205+ root.querySelector('#minutes-pretty').textContent = fmtMinutes(v);
183206 root.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
184207 if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); }
185208 else { p.removeAttribute('aria-current'); }
186209 });
210+ refreshSplit(root, v);
187211 }
212+ document.querySelectorAll('form[data-duration] [data-split][hidden]').forEach(function (box) {
213+ box.hidden = false;
214+ refreshMinutes(box.closest('form[data-duration]'));
215+ });
188216 // Tout en UTC : les steppers restent justes en travers d'un changement d'heure.
189217 function parseDate(v) {
190218 var p = String(v || '').split('-');
@@ -240,6 +268,7 @@
240268 if (!root) return;
241269 if (e.target.id === 'minutes') refreshMinutes(root);
242270 else if (e.target.type === 'date') refreshDate(root, e.target);
271+ else if (e.target.closest('[data-split]')) refreshSubmit(root);
243272 });
244273 });
245274 })();
@@ -26,7 +26,7 @@
26 chrono ou le bandeau « X min mesurées »), sauf quand le timer tourne dans26 chrono ou le bandeau « X min mesurées »), sauf quand le timer tourne dans
27 une AUTRE équipe — là, rien d'autre ne le signale. -#}27 une AUTRE équipe — là, rien d'autre ne le signale. -#}
28 {% if let Some(t) = shell.timer %}{% if shell.tab != "add" || t.foreign %}28 {% if let Some(t) = shell.timer %}{% if shell.tab != "add" || t.foreign %}
29-<div class="timer-dock">29+<div class="timer-dock" role="region" aria-label="{{ shell.tr.timer_dock_aria }}">
30 {% if t.running || t.foreign %}30 {% if t.running || t.foreign %}
31 {#- Arrêter : bascule au passage sur l'équipe du timer si besoin. -#}31 {#- Arrêter : bascule au passage sur l'équipe du timer si besoin. -#}
32 <form method="post" action="/timer/arreter" style="margin:0">32 <form method="post" action="/timer/arreter" style="margin:0">
@@ -174,17 +174,45 @@
174 // sheet injectée par l'écran Tâches) : steppers, presets et libellé sans174 // sheet injectée par l'écran Tâches) : steppers, presets et libellé sans
175 // recharger. Délégation : fonctionne aussi sur du contenu injecté.175 // recharger. Délégation : fonctionne aussi sur du contenu injecté.
176 (function () {176 (function () {
177+ function fmtMinutes(v) {
178+ var h = Math.floor(v / 60), r = v % 60;
179+ return !v ? '0 min' : !h ? r + ' min' : r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
180+ }
181+ // Répartition d'une mesure de minuteur (réservée au JS) : le toggle vit
182+ // quand la durée saisie laisse un reste, se décoche sinon, et le bouton
183+ // d'envoi annonce la suite. Les textes viennent des data-* du serveur.
184+ function refreshSplit(root, v) {
185+ var box = root.querySelector('[data-split]');
186+ if (!box) return;
187+ var cb = box.querySelector('input[type="checkbox"]');
188+ var label = box.querySelector('.split-label');
189+ var rest = (+box.dataset.remaining) - v;
190+ var on = v > 0 && rest > 0;
191+ cb.disabled = !on;
192+ if (!on) cb.checked = false;
193+ label.textContent = on ? label.dataset.pat.replace('{}', fmtMinutes(rest)) : label.dataset.idle;
194+ refreshSubmit(root);
195+ }
196+ function refreshSubmit(root) {
197+ var btn = root.querySelector('button[type="submit"][data-label-continue]');
198+ var cb = root.querySelector('[data-split] input[type="checkbox"]');
199+ if (!btn || !cb) return;
200+ btn.textContent = cb.checked && !cb.disabled ? btn.dataset.labelContinue : btn.dataset.labelAdd;
201+ }
177 function refreshMinutes(root) {202 function refreshMinutes(root) {
178 var min = root.querySelector('#minutes');203 var min = root.querySelector('#minutes');
179 var v = Math.round(+min.value) || 0;204 var v = Math.round(+min.value) || 0;
180- var h = Math.floor(v / 60), r = v % 60;205+ root.querySelector('#minutes-pretty').textContent = fmtMinutes(v);
181- root.querySelector('#minutes-pretty').textContent =
182- !v ? '0 min' : !h ? r + ' min' : r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
183 root.querySelectorAll('.preset[data-minutes]').forEach(function (p) {206 root.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
184 if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); }207 if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); }
185 else { p.removeAttribute('aria-current'); }208 else { p.removeAttribute('aria-current'); }
186 });209 });
210+ refreshSplit(root, v);
187 }211 }
212+ document.querySelectorAll('form[data-duration] [data-split][hidden]').forEach(function (box) {
213+ box.hidden = false;
214+ refreshMinutes(box.closest('form[data-duration]'));
215+ });
188 // Tout en UTC : les steppers restent justes en travers d'un changement d'heure.216 // Tout en UTC : les steppers restent justes en travers d'un changement d'heure.
189 function parseDate(v) {217 function parseDate(v) {
190 var p = String(v || '').split('-');218 var p = String(v || '').split('-');
@@ -240,6 +268,7 @@
240 if (!root) return;268 if (!root) return;
241 if (e.target.id === 'minutes') refreshMinutes(root);269 if (e.target.id === 'minutes') refreshMinutes(root);
242 else if (e.target.type === 'date') refreshDate(root, e.target);270 else if (e.target.type === 'date') refreshDate(root, e.target);
271+ else if (e.target.closest('[data-split]')) refreshSubmit(root);
243 });272 });
244 });273 });
245 })();274 })();