feat(minuteur): répartir une mesure sur plusieurs catégoriesUnverified
Sur l'étape 2 alimentée par une mesure de minuteur, un toggle « Répartir
le temps restant » s'active dès que la durée saisie descend sous le
reste (libellé chiffré, décoché d'office si la durée remonte). Coché,
l'enregistrement garde la mesure amputée de la saisie et renvoie au choix
de catégorie pour un tour de plus, jusqu'à solder le reste ou ne plus
cocher.
Migration 0018 : colonne timers.split JSONB, une entrée {category_id,
minutes} par saisie créditée — seule vérité, reste = mesure − somme.
Pendant la boucle : carte « 22 min restantes à placer » + « Jeter le
temps restant », marque « +25 min » sur les pills déjà servies, dock
« 22 min restantes / à répartir », toast de boucle avec le reste. Fin :
dépassement autorisé et toast de bilan (« minuteur réparti : 1 h 15 sur
2 catégories ») ; case non cochée = reste jeté en silence.
Réservé au JS (écart assumé, motif WebAuthn) : bloc rendu hidden + case
disabled, révélé et piloté par le JS de form[data-duration] ; sans JS le
flux d'avant est intact, le serveur ignore la case si la durée n'est pas
inférieure au reste. Chaînes dans les quatre langues, test unitaire sur
le calcul du reste, audit a11y sans violation sérieuse.
Claude-Session: https://claude.ai/code/session_01T6va3mdY9tqrDQYJBVYgT68c47448 parent: 642b973 modified
assets/style.css +10 -0 | @@ -471,6 +471,9 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 471 | 471 | .category-pick a:hover { border-color: var(--color-accent); } |
| 472 | 472 | .category-pick .tname { flex: 1; } |
| 473 | 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 | 477 | .dot { border-radius: 999px; flex: none; display: inline-block; } |
| 475 | 478 | .dot-14 { width: 14px; height: 14px; } |
| 476 | 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 | 822 | .swt-check:has(input:checked) .swt .knob { transform: translateX(18px); } |
| 820 | 823 | .swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; } |
| 821 | 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 | 832 | .done-sec .done-body { transition: opacity .15s ease; } |
| 823 | 833 | /* Section désactivée : atténuée, mais elle reste lisible et focusable — donc |
| 824 | 834 | 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 — donc | 833 | /* 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; | ||
modified
src/db/timers.rs +85 -1 | @@ -1,8 +1,17 @@ | ||
| 1 | 1 | use chrono::{DateTime, NaiveDate, Utc}; |
| 2 | 2 | use chrono_tz::Europe::Paris; |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 3 | 4 | use sqlx::PgPool; |
| 5 | +use sqlx::types::Json; | |
| 4 | 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 | 15 | /// Le timer d'un utilisateur (au plus un) : en cours tant que `stopped_at` |
| 7 | 16 | /// est NULL, mesure en attente d'enregistrement ensuite. |
| 8 | 17 | #[derive(sqlx::FromRow)] |
| @@ -11,9 +20,36 @@ pub struct TimerRow { | ||
| 11 | 20 | pub team_name: String, |
| 12 | 21 | pub started_at: DateTime<Utc>, |
| 13 | 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 | 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 | 53 | /// Minutes écoulées depuis le départ, arrondies à la minute. |
| 18 | 54 | pub fn elapsed_minutes(&self, now: DateTime<Utc>) -> i64 { |
| 19 | 55 | round_minutes(now - self.started_at).max(0) |
| @@ -53,7 +89,7 @@ fn round_minutes(d: chrono::Duration) -> i64 { | ||
| 53 | 89 | |
| 54 | 90 | pub async fn get(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Option<TimerRow>> { |
| 55 | 91 | 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 | |
| 57 | 93 | FROM timers t |
| 58 | 94 | JOIN teams e ON e.id = t.team_id |
| 59 | 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 | 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 | 179 | /// Supprime la mesure en attente de cette équipe — au moment de l'annuler |
| 120 | 180 | /// ou une fois transformée en entrée de temps. |
| 121 | 181 | pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { |
| @@ -146,9 +206,33 @@ mod tests { | ||
| 146 | 206 | team_name: String::new(), |
| 147 | 207 | started_at: parse(started), |
| 148 | 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 | 236 | #[test] |
| 153 | 237 | fn measured_rounds_to_nearest_minute_min_one() { |
| 154 | 238 | // 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_at | 92 | + "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at, t.split |
| 57 | FROM timers t | 93 | FROM timers t |
| 58 | JOIN teams e ON e.id = t.team_id | 94 | 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'annuler | 179 | /// 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 +79 -0 | @@ -140,6 +140,28 @@ pub struct Tr { | ||
| 140 | 140 | pub finish_timer: &'static str, |
| 141 | 141 | pub cancel_timer: &'static str, |
| 142 | 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, | |
| 143 | 165 | pub toast_timer_cancelled: &'static str, |
| 144 | 166 | // ─── stats ─── |
| 145 | 167 | pub title_stats: &'static str, |
| @@ -416,6 +438,11 @@ impl Tr { | ||
| 416 | 438 | format!("{n} {}", if n > 1 { self.member_many } else { self.member_one }) |
| 417 | 439 | } |
| 418 | 440 | |
| 441 | + /// « 3 catégories » / "3 categories" / „3 Kategorien“. | |
| 442 | + pub fn categories_count(&self, n: i64) -> String { | |
| 443 | + format!("{n} {}", if n > 1 { self.category_many } else { self.category_one }) | |
| 444 | + } | |
| 445 | + | |
| 419 | 446 | /// « 3 saisies » / "3 entries" / „3 Einträge“. |
| 420 | 447 | pub fn entries_count(&self, n: i64) -> String { |
| 421 | 448 | format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one }) |
| @@ -500,6 +527,19 @@ pub static FR: Tr = Tr { | ||
| 500 | 527 | finish_timer: "Finir le minuteur", |
| 501 | 528 | cancel_timer: "Annuler le minuteur", |
| 502 | 529 | toast_measure_discarded: "Temps mesuré annulé", |
| 530 | + split_idle: "Répartir le temps restant", | |
| 531 | + split_pat: "Répartir les {} restantes", | |
| 532 | + add_continue: "Ajouter et continuer", | |
| 533 | + remaining_pick_pat: "{} restantes à placer", | |
| 534 | + cancel_remaining: "Jeter le temps restant", | |
| 535 | + split_mark_pat: "+{}", | |
| 536 | + split_mark_aria_pat: "{} déjà réparties depuis ce minuteur", | |
| 537 | + added_remaining_pat: "{} ajouté ✓ — reste {} à répartir", | |
| 538 | + split_done_pat: "{} ajouté ✓ — minuteur réparti : {} sur {}", | |
| 539 | + category_one: "catégorie", | |
| 540 | + category_many: "catégories", | |
| 541 | + remaining_pat: "{} restantes", | |
| 542 | + split_pending_note: "à répartir", | |
| 503 | 543 | toast_timer_cancelled: "Minuteur annulé", |
| 504 | 544 | title_stats: "Statistiques", |
| 505 | 545 | my_entries_arrow: "Mes saisies →", |
| @@ -804,6 +844,19 @@ pub static EN: Tr = Tr { | ||
| 804 | 844 | finish_timer: "Finish the timer", |
| 805 | 845 | cancel_timer: "Cancel the timer", |
| 806 | 846 | toast_measure_discarded: "Measured time discarded", |
| 847 | + split_idle: "Split the remaining time", | |
| 848 | + split_pat: "Split the remaining {}", | |
| 849 | + add_continue: "Add and continue", | |
| 850 | + remaining_pick_pat: "{} left to place", | |
| 851 | + cancel_remaining: "Discard the remaining time", | |
| 852 | + split_mark_pat: "+{}", | |
| 853 | + split_mark_aria_pat: "{} already split from this timer", | |
| 854 | + added_remaining_pat: "{} added ✓ — {} left to split", | |
| 855 | + split_done_pat: "{} added ✓ — timer split: {} across {}", | |
| 856 | + category_one: "category", | |
| 857 | + category_many: "categories", | |
| 858 | + remaining_pat: "{} left", | |
| 859 | + split_pending_note: "to split", | |
| 807 | 860 | toast_timer_cancelled: "Timer cancelled", |
| 808 | 861 | title_stats: "Statistics", |
| 809 | 862 | my_entries_arrow: "My entries →", |
| @@ -1108,6 +1161,19 @@ pub static DE: Tr = Tr { | ||
| 1108 | 1161 | finish_timer: "Timer beenden", |
| 1109 | 1162 | cancel_timer: "Timer abbrechen", |
| 1110 | 1163 | toast_measure_discarded: "Gemessene Zeit verworfen", |
| 1164 | + split_idle: "Restzeit aufteilen", | |
| 1165 | + split_pat: "Restliche {} aufteilen", | |
| 1166 | + add_continue: "Erfassen und weiter", | |
| 1167 | + remaining_pick_pat: "{} noch zu verteilen", | |
| 1168 | + cancel_remaining: "Restzeit verwerfen", | |
| 1169 | + split_mark_pat: "+{}", | |
| 1170 | + split_mark_aria_pat: "{} bereits aus diesem Timer verteilt", | |
| 1171 | + added_remaining_pat: "{} erfasst ✓ — noch {} zu verteilen", | |
| 1172 | + split_done_pat: "{} erfasst ✓ — Timer aufgeteilt: {} auf {}", | |
| 1173 | + category_one: "Kategorie", | |
| 1174 | + category_many: "Kategorien", | |
| 1175 | + remaining_pat: "{} übrig", | |
| 1176 | + split_pending_note: "zu verteilen", | |
| 1111 | 1177 | toast_timer_cancelled: "Timer abgebrochen", |
| 1112 | 1178 | title_stats: "Statistik", |
| 1113 | 1179 | my_entries_arrow: "Meine Einträge →", |
| @@ -1412,6 +1478,19 @@ pub static PT_BR: Tr = Tr { | ||
| 1412 | 1478 | finish_timer: "Encerrar o cronômetro", |
| 1413 | 1479 | cancel_timer: "Cancelar o cronômetro", |
| 1414 | 1480 | toast_measure_discarded: "Tempo medido descartado", |
| 1481 | + split_idle: "Repartir o tempo restante", | |
| 1482 | + split_pat: "Repartir os {} restantes", | |
| 1483 | + add_continue: "Adicionar e continuar", | |
| 1484 | + remaining_pick_pat: "{} restantes a distribuir", | |
| 1485 | + cancel_remaining: "Descartar o tempo restante", | |
| 1486 | + split_mark_pat: "+{}", | |
| 1487 | + split_mark_aria_pat: "{} já repartidos deste cronômetro", | |
| 1488 | + added_remaining_pat: "{} adicionado ✓ — restam {} a repartir", | |
| 1489 | + split_done_pat: "{} adicionado ✓ — cronômetro repartido: {} em {}", | |
| 1490 | + category_one: "categoria", | |
| 1491 | + category_many: "categorias", | |
| 1492 | + remaining_pat: "{} restantes", | |
| 1493 | + split_pending_note: "a repartir", | |
| 1415 | 1494 | toast_timer_cancelled: "Cronômetro cancelado", |
| 1416 | 1495 | title_stats: "Estatísticas", |
| 1417 | 1496 | my_entries_arrow: "Meus registros →", |
| @@ -140,6 +140,28 @@ 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, | ||
| 143 | pub toast_timer_cancelled: &'static str, | 165 | pub toast_timer_cancelled: &'static str, |
| 144 | // ─── stats ─── | 166 | // ─── stats ─── |
| 145 | pub title_stats: &'static str, | 167 | pub title_stats: &'static str, |
| @@ -416,6 +438,11 @@ impl Tr { | |||
| 416 | format!("{n} {}", if n > 1 { self.member_many } else { self.member_one }) | 438 | format!("{n} {}", if n > 1 { self.member_many } else { self.member_one }) |
| 417 | } | 439 | } |
| 418 | 440 | ||
| 441 | + /// « 3 catégories » / "3 categories" / „3 Kategorien“. | ||
| 442 | + pub fn categories_count(&self, n: i64) -> String { | ||
| 443 | + format!("{n} {}", if n > 1 { self.category_many } else { self.category_one }) | ||
| 444 | + } | ||
| 445 | + | ||
| 419 | /// « 3 saisies » / "3 entries" / „3 Einträge“. | 446 | /// « 3 saisies » / "3 entries" / „3 Einträge“. |
| 420 | pub fn entries_count(&self, n: i64) -> String { | 447 | pub fn entries_count(&self, n: i64) -> String { |
| 421 | format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one }) | 448 | format!("{n} {}", if n > 1 { self.entry_many } else { self.entry_one }) |
| @@ -500,6 +527,19 @@ pub static FR: Tr = Tr { | |||
| 500 | finish_timer: "Finir le minuteur", | 527 | finish_timer: "Finir le minuteur", |
| 501 | cancel_timer: "Annuler le minuteur", | 528 | cancel_timer: "Annuler le minuteur", |
| 502 | toast_measure_discarded: "Temps mesuré annulé", | 529 | toast_measure_discarded: "Temps mesuré annulé", |
| 530 | + split_idle: "Répartir le temps restant", | ||
| 531 | + split_pat: "Répartir les {} restantes", | ||
| 532 | + add_continue: "Ajouter et continuer", | ||
| 533 | + remaining_pick_pat: "{} restantes à placer", | ||
| 534 | + cancel_remaining: "Jeter le temps restant", | ||
| 535 | + split_mark_pat: "+{}", | ||
| 536 | + split_mark_aria_pat: "{} déjà réparties depuis ce minuteur", | ||
| 537 | + added_remaining_pat: "{} ajouté ✓ — reste {} à répartir", | ||
| 538 | + split_done_pat: "{} ajouté ✓ — minuteur réparti : {} sur {}", | ||
| 539 | + category_one: "catégorie", | ||
| 540 | + category_many: "catégories", | ||
| 541 | + remaining_pat: "{} restantes", | ||
| 542 | + split_pending_note: "à répartir", | ||
| 503 | toast_timer_cancelled: "Minuteur annulé", | 543 | toast_timer_cancelled: "Minuteur annulé", |
| 504 | title_stats: "Statistiques", | 544 | title_stats: "Statistiques", |
| 505 | my_entries_arrow: "Mes saisies →", | 545 | my_entries_arrow: "Mes saisies →", |
| @@ -804,6 +844,19 @@ pub static EN: Tr = Tr { | |||
| 804 | finish_timer: "Finish the timer", | 844 | finish_timer: "Finish the timer", |
| 805 | cancel_timer: "Cancel the timer", | 845 | cancel_timer: "Cancel the timer", |
| 806 | toast_measure_discarded: "Measured time discarded", | 846 | toast_measure_discarded: "Measured time discarded", |
| 847 | + split_idle: "Split the remaining time", | ||
| 848 | + split_pat: "Split the remaining {}", | ||
| 849 | + add_continue: "Add and continue", | ||
| 850 | + remaining_pick_pat: "{} left to place", | ||
| 851 | + cancel_remaining: "Discard the remaining time", | ||
| 852 | + split_mark_pat: "+{}", | ||
| 853 | + split_mark_aria_pat: "{} already split from this timer", | ||
| 854 | + added_remaining_pat: "{} added ✓ — {} left to split", | ||
| 855 | + split_done_pat: "{} added ✓ — timer split: {} across {}", | ||
| 856 | + category_one: "category", | ||
| 857 | + category_many: "categories", | ||
| 858 | + remaining_pat: "{} left", | ||
| 859 | + split_pending_note: "to split", | ||
| 807 | toast_timer_cancelled: "Timer cancelled", | 860 | toast_timer_cancelled: "Timer cancelled", |
| 808 | title_stats: "Statistics", | 861 | title_stats: "Statistics", |
| 809 | my_entries_arrow: "My entries →", | 862 | my_entries_arrow: "My entries →", |
| @@ -1108,6 +1161,19 @@ pub static DE: Tr = Tr { | |||
| 1108 | finish_timer: "Timer beenden", | 1161 | finish_timer: "Timer beenden", |
| 1109 | cancel_timer: "Timer abbrechen", | 1162 | cancel_timer: "Timer abbrechen", |
| 1110 | toast_measure_discarded: "Gemessene Zeit verworfen", | 1163 | toast_measure_discarded: "Gemessene Zeit verworfen", |
| 1164 | + split_idle: "Restzeit aufteilen", | ||
| 1165 | + split_pat: "Restliche {} aufteilen", | ||
| 1166 | + add_continue: "Erfassen und weiter", | ||
| 1167 | + remaining_pick_pat: "{} noch zu verteilen", | ||
| 1168 | + cancel_remaining: "Restzeit verwerfen", | ||
| 1169 | + split_mark_pat: "+{}", | ||
| 1170 | + split_mark_aria_pat: "{} bereits aus diesem Timer verteilt", | ||
| 1171 | + added_remaining_pat: "{} erfasst ✓ — noch {} zu verteilen", | ||
| 1172 | + split_done_pat: "{} erfasst ✓ — Timer aufgeteilt: {} auf {}", | ||
| 1173 | + category_one: "Kategorie", | ||
| 1174 | + category_many: "Kategorien", | ||
| 1175 | + remaining_pat: "{} übrig", | ||
| 1176 | + split_pending_note: "zu verteilen", | ||
| 1111 | toast_timer_cancelled: "Timer abgebrochen", | 1177 | toast_timer_cancelled: "Timer abgebrochen", |
| 1112 | title_stats: "Statistik", | 1178 | title_stats: "Statistik", |
| 1113 | my_entries_arrow: "Meine Einträge →", | 1179 | my_entries_arrow: "Meine Einträge →", |
| @@ -1412,6 +1478,19 @@ pub static PT_BR: Tr = Tr { | |||
| 1412 | finish_timer: "Encerrar o cronômetro", | 1478 | finish_timer: "Encerrar o cronômetro", |
| 1413 | cancel_timer: "Cancelar o cronômetro", | 1479 | cancel_timer: "Cancelar o cronômetro", |
| 1414 | toast_measure_discarded: "Tempo medido descartado", | 1480 | toast_measure_discarded: "Tempo medido descartado", |
| 1481 | + split_idle: "Repartir o tempo restante", | ||
| 1482 | + split_pat: "Repartir os {} restantes", | ||
| 1483 | + add_continue: "Adicionar e continuar", | ||
| 1484 | + remaining_pick_pat: "{} restantes a distribuir", | ||
| 1485 | + cancel_remaining: "Descartar o tempo restante", | ||
| 1486 | + split_mark_pat: "+{}", | ||
| 1487 | + split_mark_aria_pat: "{} já repartidos deste cronômetro", | ||
| 1488 | + added_remaining_pat: "{} adicionado ✓ — restam {} a repartir", | ||
| 1489 | + split_done_pat: "{} adicionado ✓ — cronômetro repartido: {} em {}", | ||
| 1490 | + category_one: "categoria", | ||
| 1491 | + category_many: "categorias", | ||
| 1492 | + remaining_pat: "{} restantes", | ||
| 1493 | + split_pending_note: "a repartir", | ||
| 1415 | toast_timer_cancelled: "Cronômetro cancelado", | 1494 | toast_timer_cancelled: "Cronômetro cancelado", |
| 1416 | title_stats: "Estatísticas", | 1495 | title_stats: "Estatísticas", |
| 1417 | my_entries_arrow: "Meus registros →", | 1496 | my_entries_arrow: "Meus registros →", |
modified
src/routes/entries.rs +105 -10 | @@ -13,7 +13,7 @@ use crate::db::entries::{self, EntryRow}; | ||
| 13 | 13 | use crate::db::categories::{self, CategoryRow}; |
| 14 | 14 | use crate::db::timers; |
| 15 | 15 | use crate::error::AppError; |
| 16 | -use crate::i18n::p1; | |
| 16 | +use crate::i18n::{p1, p2}; | |
| 17 | 17 | use crate::period::{DateVocab, Week, day_label, paris_today}; |
| 18 | 18 | use crate::render::{HtmlTemplate, Shell}; |
| 19 | 19 | use crate::routes::{Preset, duration_presets, shell}; |
| @@ -30,13 +30,20 @@ pub struct PickCategory { | ||
| 30 | 30 | pub name: String, |
| 31 | 31 | /// « 1 h 30 cette semaine » (mon temps, semaine en cours) — vide sinon. |
| 32 | 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 | 41 | pub struct MeasuredView { |
| 37 | 42 | pub title: String, |
| 38 | 43 | /// Mesure > 4 h : probable timer oublié. |
| 39 | 44 | pub warning: bool, |
| 45 | + /// « Annuler ce temps » / « Jeter le temps restant ». | |
| 46 | + pub cancel_label: &'static str, | |
| 40 | 47 | } |
| 41 | 48 | |
| 42 | 49 | /// Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle. |
| @@ -99,6 +106,10 @@ pub struct AddStep2Page { | ||
| 99 | 106 | pub day_prev: String, |
| 100 | 107 | /// None = aujourd'hui, flèche désactivée. |
| 101 | 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 | 115 | #[derive(Deserialize)] |
| @@ -110,6 +121,11 @@ pub struct NewQuery { | ||
| 110 | 121 | date: Option<NaiveDate>, |
| 111 | 122 | /// minutes de la saisie qui vient d'aboutir (toast). |
| 112 | 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 | 129 | /// mesure de timer jetée (toast). |
| 114 | 130 | annule: Option<u8>, |
| 115 | 131 | /// minuteur annulé sans mesure (toast). |
| @@ -132,7 +148,15 @@ pub async fn entry_new( | ||
| 132 | 148 | } else if q.abandonne.is_some() { |
| 133 | 149 | Some(tr.toast_timer_cancelled.to_string()) |
| 134 | 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 | 161 | let all_tasks = categories::list_by_creation(&state.pool, ctx.team_id).await?; |
| 138 | 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 | 211 | .categorie |
| 188 | 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 | 215 | let minutes = q |
| 192 | 216 | .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 | 218 | .unwrap_or(30) |
| 195 | 219 | .max(1); |
| 196 | 220 | let date = q |
| @@ -219,6 +243,7 @@ pub async fn entry_new( | ||
| 219 | 243 | day_prev: step2_href(picked.id, minutes, date - Duration::days(1)), |
| 220 | 244 | day_next: (date < paris_today()) |
| 221 | 245 | .then(|| step2_href(picked.id, minutes, date + Duration::days(1))), |
| 246 | + remaining, | |
| 222 | 247 | }) |
| 223 | 248 | .into_response()); |
| 224 | 249 | } |
| @@ -230,6 +255,13 @@ pub async fn entry_new( | ||
| 230 | 255 | .await? |
| 231 | 256 | .into_iter() |
| 232 | 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 | 265 | let mut active: Vec<&CategoryRow> = all_tasks.iter().filter(|t| !t.archived).collect(); |
| 234 | 266 | active.sort_by(|a, b| a.name.cmp(&b.name)); |
| 235 | 267 | let categories = active |
| @@ -242,13 +274,34 @@ pub async fn entry_new( | ||
| 242 | 274 | .get(&t.id) |
| 243 | 275 | .map(|m| p1(tr.week_hours_pat, fmt_minutes(*m))) |
| 244 | 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 | 286 | .collect(); |
| 247 | 287 | 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 | + ); | |
| 252 | 305 | let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; |
| 253 | 306 | Ok(HtmlTemplate(AddStep1Page { |
| 254 | 307 | shell: sh, |
| @@ -265,6 +318,10 @@ pub struct EntryForm { | ||
| 265 | 318 | categorie: Uuid, |
| 266 | 319 | date: NaiveDate, |
| 267 | 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 | 327 | fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> { |
| @@ -297,8 +354,46 @@ pub async fn entry_create( | ||
| 297 | 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 | 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 | 397 | Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) |
| 303 | 398 | } |
| 304 | 399 | |
| @@ -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 | .categorie | 211 | .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 = q | 215 | let minutes = q |
| 192 | .minutes | 216 | .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 = q | 220 | 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 = active | 267 | 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( | ||
| 208 | 208 | // l'information que le dock est seul à porter (le clic y bascule au |
| 209 | 209 | // passage). Le libellé « 47 min mesurées » dit déjà de quoi il s'agit |
| 210 | 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 | 212 | let note = match (foreign, pending) { |
| 212 | 213 | (true, true) => crate::i18n::p1(tr.in_team_pat, &t.team_name), |
| 213 | 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 | 216 | (false, true) => tr.measure_pending_note.to_string(), |
| 215 | 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 | 220 | Some(m) => ( |
| 219 | 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 | 226 | tr.record_cta, |
| 222 | 227 | tr.record_measured_aria, |
| 223 | 228 | ), |
| @@ -208,16 +208,21 @@ pub async fn shell( | |||
| 208 | // l'information que le dock est seul à porter (le clic y bascule au | 208 | // 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'agit | 209 | // 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 @@ | ||
| 9 | 9 | {% if m.warning %}<p class="warn">{{ shell.tr.forgot_timer }}</p>{% endif %} |
| 10 | 10 | <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0"> |
| 11 | 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 | 13 | </form> |
| 14 | 14 | </div> |
| 15 | 15 | {% endif %} |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | <span class="dot dot-14" style="background:{{ t.color }}"></span> |
| 24 | 24 | <span class="tname">{{ t.name }}</span> |
| 25 | 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 | 27 | </a> |
| 27 | 28 | {% endfor %} |
| 28 | 29 | </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 @@ | ||
| 31 | 31 | data-months="{{ vocab.months }}" data-today="{{ vocab.today }}" |
| 32 | 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 | 49 | </form> |
| 36 | 50 | {% 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 +32 -3 | @@ -174,17 +174,45 @@ | ||
| 174 | 174 | // sheet injectée par l'écran Tâches) : steppers, presets et libellé sans |
| 175 | 175 | // recharger. Délégation : fonctionne aussi sur du contenu injecté. |
| 176 | 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 | 202 | function refreshMinutes(root) { |
| 178 | 203 | var min = root.querySelector('#minutes'); |
| 179 | 204 | 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); | |
| 183 | 206 | root.querySelectorAll('.preset[data-minutes]').forEach(function (p) { |
| 184 | 207 | if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); } |
| 185 | 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 | 216 | // Tout en UTC : les steppers restent justes en travers d'un changement d'heure. |
| 189 | 217 | function parseDate(v) { |
| 190 | 218 | var p = String(v || '').split('-'); |
| @@ -240,6 +268,7 @@ | ||
| 240 | 268 | if (!root) return; |
| 241 | 269 | if (e.target.id === 'minutes') refreshMinutes(root); |
| 242 | 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 | })(); |
| @@ -174,17 +174,45 @@ | |||
| 174 | // sheet injectée par l'écran Tâches) : steppers, presets et libellé sans | 174 | // 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 | })(); |