feat(ajout): sélecteur de durée en deux capsules heures / minutes (design)Unverified
Synchronisation avec le projet Claude Design « Time-time » : l'étape 2 de l'ajout remplace la capsule unique en minutes par deux capsules « h » et « min », chacune avec ses steppers (± 1 h, ± 5 min) et son champ libre, un indice « soit 90 minutes — tape dans les champs pour aller à la minute » et quatre presets en grille (5 / 15 / 30 min / 1 h). Zéro-JS conservé : les steppers restent des liens ?minutes= sur le total (plancher 1 min comme la maquette), le formulaire poste heures + minutes et le serveur en fait le total avant tout le reste du handler. Le JS ne fait qu'éviter le rechargement : total recalculé à chaque frappe, ventilation h/min au change seulement (taper 90 min donne 1 h 30 au blur). Nouvelle macro hm_picker ; duration_picker reste au flux « effectuée », que le design garde inchangé. Sept chaînes i18n dans les quatre langues.
c2b3977 parent: 5745456 modified
assets/style.css +18 -0 | @@ -505,6 +505,18 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | ||
| 505 | 505 | min-height: 44px; text-align: center; padding: 0; |
| 506 | 506 | background: transparent; border-color: transparent; |
| 507 | 507 | } |
| 508 | +/* Étape 2 : deux capsules h / min côte à côte (design). L'input se cale sur | |
| 509 | + 3,5 ch (jusqu'à 999) pour laisser l'unité en ligne de base. */ | |
| 510 | +.hm-row { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); } | |
| 511 | +.stepper.hm { flex: 1 1 0; min-width: 0; } | |
| 512 | +.hm-field { flex: 1; min-width: 0; display: flex; align-items: baseline; justify-content: center; gap: 3px; } | |
| 513 | +.stepper .hm-input { | |
| 514 | + width: 100%; min-width: 0; max-width: 3.5ch; font-size: 2rem; font-family: var(--font-heading); | |
| 515 | + font-variant-numeric: tabular-nums; min-height: 44px; text-align: center; padding: 0; | |
| 516 | + background: transparent; border-color: transparent; | |
| 517 | +} | |
| 518 | +.hm-unit { font-size: 0.9375rem; opacity: .7; } | |
| 519 | +.hm-hint { font-size: 0.8125rem; opacity: .7; margin: 0 0 var(--space-4); font-variant-numeric: tabular-nums; } | |
| 508 | 520 | .duration-row .unit { font-size: 0.9375rem; opacity: .7; } |
| 509 | 521 | .duration-row .pretty { font-size: 0.8125rem; opacity: .7; } |
| 510 | 522 | .presets { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-6); } |
| @@ -514,6 +526,12 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | ||
| 514 | 526 | border: 1px solid var(--color-divider); color: var(--color-text); |
| 515 | 527 | } |
| 516 | 528 | .preset[aria-current="true"] { background: var(--color-accent-deep); color: var(--color-bg); border-color: transparent; } |
| 529 | +/* Étape 2 : presets en grille de 4 (design), après .presets pour primer. */ | |
| 530 | +.presets.presets-grid { display: grid; grid-template-columns: repeat(4, 1fr); } | |
| 531 | +.presets-grid .preset { | |
| 532 | + padding: 9px 8px; min-height: 40px; box-sizing: border-box; text-align: center; white-space: nowrap; | |
| 533 | + font-variant-numeric: tabular-nums; display: flex; align-items: center; justify-content: center; | |
| 534 | +} | |
| 517 | 535 | .date-row { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-3); } |
| 518 | 536 | .date-row .input { flex: 1; min-height: 44px; text-align: center; } |
| 519 | 537 | .date-label { font-size: 0.8125rem; opacity: .7; margin: 0 0 var(--space-6); } |
| @@ -505,6 +505,18 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | |||
| 505 | min-height: 44px; text-align: center; padding: 0; | 505 | min-height: 44px; text-align: center; padding: 0; |
| 506 | background: transparent; border-color: transparent; | 506 | background: transparent; border-color: transparent; |
| 507 | } | 507 | } |
| 508 | +/* Étape 2 : deux capsules h / min côte à côte (design). L'input se cale sur | ||
| 509 | + 3,5 ch (jusqu'à 999) pour laisser l'unité en ligne de base. */ | ||
| 510 | +.hm-row { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); } | ||
| 511 | +.stepper.hm { flex: 1 1 0; min-width: 0; } | ||
| 512 | +.hm-field { flex: 1; min-width: 0; display: flex; align-items: baseline; justify-content: center; gap: 3px; } | ||
| 513 | +.stepper .hm-input { | ||
| 514 | + width: 100%; min-width: 0; max-width: 3.5ch; font-size: 2rem; font-family: var(--font-heading); | ||
| 515 | + font-variant-numeric: tabular-nums; min-height: 44px; text-align: center; padding: 0; | ||
| 516 | + background: transparent; border-color: transparent; | ||
| 517 | +} | ||
| 518 | +.hm-unit { font-size: 0.9375rem; opacity: .7; } | ||
| 519 | +.hm-hint { font-size: 0.8125rem; opacity: .7; margin: 0 0 var(--space-4); font-variant-numeric: tabular-nums; } | ||
| 508 | .duration-row .unit { font-size: 0.9375rem; opacity: .7; } | 520 | .duration-row .unit { font-size: 0.9375rem; opacity: .7; } |
| 509 | .duration-row .pretty { font-size: 0.8125rem; opacity: .7; } | 521 | .duration-row .pretty { font-size: 0.8125rem; opacity: .7; } |
| 510 | .presets { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-6); } | 522 | .presets { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-6); } |
| @@ -514,6 +526,12 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | |||
| 514 | border: 1px solid var(--color-divider); color: var(--color-text); | 526 | border: 1px solid var(--color-divider); color: var(--color-text); |
| 515 | } | 527 | } |
| 516 | .preset[aria-current="true"] { background: var(--color-accent-deep); color: var(--color-bg); border-color: transparent; } | 528 | .preset[aria-current="true"] { background: var(--color-accent-deep); color: var(--color-bg); border-color: transparent; } |
| 529 | +/* Étape 2 : presets en grille de 4 (design), après .presets pour primer. */ | ||
| 530 | +.presets.presets-grid { display: grid; grid-template-columns: repeat(4, 1fr); } | ||
| 531 | +.presets-grid .preset { | ||
| 532 | + padding: 9px 8px; min-height: 40px; box-sizing: border-box; text-align: center; white-space: nowrap; | ||
| 533 | + font-variant-numeric: tabular-nums; display: flex; align-items: center; justify-content: center; | ||
| 534 | +} | ||
| 517 | .date-row { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-3); } | 535 | .date-row { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-3); } |
| 518 | .date-row .input { flex: 1; min-height: 44px; text-align: center; } | 536 | .date-row .input { flex: 1; min-height: 44px; text-align: center; } |
| 519 | .date-label { font-size: 0.8125rem; opacity: .7; margin: 0 0 var(--space-6); } | 537 | .date-label { font-size: 0.8125rem; opacity: .7; margin: 0 0 var(--space-6); } |
modified
src/i18n.rs +37 -0 | @@ -124,6 +124,15 @@ pub struct Tr { | ||
| 124 | 124 | pub date_label: &'static str, |
| 125 | 125 | pub five_less: &'static str, |
| 126 | 126 | pub five_more: &'static str, |
| 127 | + /// Sélecteur heures + minutes de l'étape 2 (design : deux capsules). | |
| 128 | + pub hour_less: &'static str, | |
| 129 | + pub hour_more: &'static str, | |
| 130 | + pub hours_aria: &'static str, | |
| 131 | + pub minutes_aria: &'static str, | |
| 132 | + pub hours_short: &'static str, | |
| 133 | + pub minutes_short: &'static str, | |
| 134 | + /// Indice sous les capsules : total en minutes + « tape dans les champs ». | |
| 135 | + pub duration_hint_pat: &'static str, | |
| 127 | 136 | pub day_prev: &'static str, |
| 128 | 137 | pub day_next: &'static str, |
| 129 | 138 | pub forgot_timer: &'static str, |
| @@ -514,6 +523,13 @@ pub static FR: Tr = Tr { | ||
| 514 | 523 | date_label: "Date", |
| 515 | 524 | five_less: "Cinq minutes de moins", |
| 516 | 525 | five_more: "Cinq minutes de plus", |
| 526 | + hour_less: "Une heure de moins", | |
| 527 | + hour_more: "Une heure de plus", | |
| 528 | + hours_aria: "Heures", | |
| 529 | + minutes_aria: "Minutes", | |
| 530 | + hours_short: "h", | |
| 531 | + minutes_short: "min", | |
| 532 | + duration_hint_pat: "soit {} minutes — tape dans les champs pour aller à la minute", | |
| 517 | 533 | day_prev: "Jour précédent", |
| 518 | 534 | day_next: "Jour suivant", |
| 519 | 535 | forgot_timer: "Timer oublié ? Tu pourras corriger la durée à l'étape suivante.", |
| @@ -832,6 +848,13 @@ pub static EN: Tr = Tr { | ||
| 832 | 848 | date_label: "Date", |
| 833 | 849 | five_less: "Five minutes less", |
| 834 | 850 | five_more: "Five minutes more", |
| 851 | + hour_less: "One hour less", | |
| 852 | + hour_more: "One hour more", | |
| 853 | + hours_aria: "Hours", | |
| 854 | + minutes_aria: "Minutes", | |
| 855 | + hours_short: "h", | |
| 856 | + minutes_short: "min", | |
| 857 | + duration_hint_pat: "that's {} minutes — type in the fields to go down to the minute", | |
| 835 | 858 | day_prev: "Previous day", |
| 836 | 859 | day_next: "Next day", |
| 837 | 860 | forgot_timer: "Forgot the timer? You can fix the duration in the next step.", |
| @@ -1150,6 +1173,13 @@ pub static DE: Tr = Tr { | ||
| 1150 | 1173 | date_label: "Datum", |
| 1151 | 1174 | five_less: "Fünf Minuten weniger", |
| 1152 | 1175 | five_more: "Fünf Minuten mehr", |
| 1176 | + hour_less: "Eine Stunde weniger", | |
| 1177 | + hour_more: "Eine Stunde mehr", | |
| 1178 | + hours_aria: "Stunden", | |
| 1179 | + minutes_aria: "Minuten", | |
| 1180 | + hours_short: "h", | |
| 1181 | + minutes_short: "min", | |
| 1182 | + duration_hint_pat: "also {} Minuten — tipp in die Felder, um minutengenau zu werden", | |
| 1153 | 1183 | day_prev: "Vortag", |
| 1154 | 1184 | day_next: "Nächster Tag", |
| 1155 | 1185 | forgot_timer: "Timer vergessen? Du kannst die Dauer im nächsten Schritt anpassen.", |
| @@ -1468,6 +1498,13 @@ pub static PT_BR: Tr = Tr { | ||
| 1468 | 1498 | date_label: "Data", |
| 1469 | 1499 | five_less: "Cinco minutos a menos", |
| 1470 | 1500 | five_more: "Cinco minutos a mais", |
| 1501 | + hour_less: "Uma hora a menos", | |
| 1502 | + hour_more: "Uma hora a mais", | |
| 1503 | + hours_aria: "Horas", | |
| 1504 | + minutes_aria: "Minutos", | |
| 1505 | + hours_short: "h", | |
| 1506 | + minutes_short: "min", | |
| 1507 | + duration_hint_pat: "ou seja, {} minutos — digite nos campos para ajustar ao minuto", | |
| 1471 | 1508 | day_prev: "Dia anterior", |
| 1472 | 1509 | day_next: "Próximo dia", |
| 1473 | 1510 | forgot_timer: "Esqueceu o cronômetro? Dá para corrigir a duração na etapa seguinte.", |
| @@ -124,6 +124,15 @@ pub struct Tr { | |||
| 124 | pub date_label: &'static str, | 124 | pub date_label: &'static str, |
| 125 | pub five_less: &'static str, | 125 | pub five_less: &'static str, |
| 126 | pub five_more: &'static str, | 126 | pub five_more: &'static str, |
| 127 | + /// Sélecteur heures + minutes de l'étape 2 (design : deux capsules). | ||
| 128 | + pub hour_less: &'static str, | ||
| 129 | + pub hour_more: &'static str, | ||
| 130 | + pub hours_aria: &'static str, | ||
| 131 | + pub minutes_aria: &'static str, | ||
| 132 | + pub hours_short: &'static str, | ||
| 133 | + pub minutes_short: &'static str, | ||
| 134 | + /// Indice sous les capsules : total en minutes + « tape dans les champs ». | ||
| 135 | + pub duration_hint_pat: &'static str, | ||
| 127 | pub day_prev: &'static str, | 136 | pub day_prev: &'static str, |
| 128 | pub day_next: &'static str, | 137 | pub day_next: &'static str, |
| 129 | pub forgot_timer: &'static str, | 138 | pub forgot_timer: &'static str, |
| @@ -514,6 +523,13 @@ pub static FR: Tr = Tr { | |||
| 514 | date_label: "Date", | 523 | date_label: "Date", |
| 515 | five_less: "Cinq minutes de moins", | 524 | five_less: "Cinq minutes de moins", |
| 516 | five_more: "Cinq minutes de plus", | 525 | five_more: "Cinq minutes de plus", |
| 526 | + hour_less: "Une heure de moins", | ||
| 527 | + hour_more: "Une heure de plus", | ||
| 528 | + hours_aria: "Heures", | ||
| 529 | + minutes_aria: "Minutes", | ||
| 530 | + hours_short: "h", | ||
| 531 | + minutes_short: "min", | ||
| 532 | + duration_hint_pat: "soit {} minutes — tape dans les champs pour aller à la minute", | ||
| 517 | day_prev: "Jour précédent", | 533 | day_prev: "Jour précédent", |
| 518 | day_next: "Jour suivant", | 534 | day_next: "Jour suivant", |
| 519 | forgot_timer: "Timer oublié ? Tu pourras corriger la durée à l'étape suivante.", | 535 | forgot_timer: "Timer oublié ? Tu pourras corriger la durée à l'étape suivante.", |
| @@ -832,6 +848,13 @@ pub static EN: Tr = Tr { | |||
| 832 | date_label: "Date", | 848 | date_label: "Date", |
| 833 | five_less: "Five minutes less", | 849 | five_less: "Five minutes less", |
| 834 | five_more: "Five minutes more", | 850 | five_more: "Five minutes more", |
| 851 | + hour_less: "One hour less", | ||
| 852 | + hour_more: "One hour more", | ||
| 853 | + hours_aria: "Hours", | ||
| 854 | + minutes_aria: "Minutes", | ||
| 855 | + hours_short: "h", | ||
| 856 | + minutes_short: "min", | ||
| 857 | + duration_hint_pat: "that's {} minutes — type in the fields to go down to the minute", | ||
| 835 | day_prev: "Previous day", | 858 | day_prev: "Previous day", |
| 836 | day_next: "Next day", | 859 | day_next: "Next day", |
| 837 | forgot_timer: "Forgot the timer? You can fix the duration in the next step.", | 860 | forgot_timer: "Forgot the timer? You can fix the duration in the next step.", |
| @@ -1150,6 +1173,13 @@ pub static DE: Tr = Tr { | |||
| 1150 | date_label: "Datum", | 1173 | date_label: "Datum", |
| 1151 | five_less: "Fünf Minuten weniger", | 1174 | five_less: "Fünf Minuten weniger", |
| 1152 | five_more: "Fünf Minuten mehr", | 1175 | five_more: "Fünf Minuten mehr", |
| 1176 | + hour_less: "Eine Stunde weniger", | ||
| 1177 | + hour_more: "Eine Stunde mehr", | ||
| 1178 | + hours_aria: "Stunden", | ||
| 1179 | + minutes_aria: "Minuten", | ||
| 1180 | + hours_short: "h", | ||
| 1181 | + minutes_short: "min", | ||
| 1182 | + duration_hint_pat: "also {} Minuten — tipp in die Felder, um minutengenau zu werden", | ||
| 1153 | day_prev: "Vortag", | 1183 | day_prev: "Vortag", |
| 1154 | day_next: "Nächster Tag", | 1184 | day_next: "Nächster Tag", |
| 1155 | forgot_timer: "Timer vergessen? Du kannst die Dauer im nächsten Schritt anpassen.", | 1185 | forgot_timer: "Timer vergessen? Du kannst die Dauer im nächsten Schritt anpassen.", |
| @@ -1468,6 +1498,13 @@ pub static PT_BR: Tr = Tr { | |||
| 1468 | date_label: "Data", | 1498 | date_label: "Data", |
| 1469 | five_less: "Cinco minutos a menos", | 1499 | five_less: "Cinco minutos a menos", |
| 1470 | five_more: "Cinco minutos a mais", | 1500 | five_more: "Cinco minutos a mais", |
| 1501 | + hour_less: "Uma hora a menos", | ||
| 1502 | + hour_more: "Uma hora a mais", | ||
| 1503 | + hours_aria: "Horas", | ||
| 1504 | + minutes_aria: "Minutos", | ||
| 1505 | + hours_short: "h", | ||
| 1506 | + minutes_short: "min", | ||
| 1507 | + duration_hint_pat: "ou seja, {} minutos — digite nos campos para ajustar ao minuto", | ||
| 1471 | day_prev: "Dia anterior", | 1508 | day_prev: "Dia anterior", |
| 1472 | day_next: "Próximo dia", | 1509 | day_next: "Próximo dia", |
| 1473 | forgot_timer: "Esqueceu o cronômetro? Dá para corrigir a duração na etapa seguinte.", | 1510 | forgot_timer: "Esqueceu o cronômetro? Dá para corrigir a duração na etapa seguinte.", |
modified
src/routes/entries.rs +37 -11 | @@ -16,7 +16,7 @@ use crate::error::AppError; | ||
| 16 | 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 | -use crate::routes::{Preset, duration_presets, shell}; | |
| 19 | +use crate::routes::{Preset, shell, step2_presets}; | |
| 20 | 20 | use crate::state::AppState; |
| 21 | 21 | use crate::team::ActiveTeam; |
| 22 | 22 | |
| @@ -91,9 +91,15 @@ pub struct AddStep2Page { | ||
| 91 | 91 | pub categorie: Uuid, |
| 92 | 92 | pub picked_color: &'static str, |
| 93 | 93 | pub picked_name: String, |
| 94 | - pub minutes: i32, | |
| 95 | - pub minutes_pretty: String, | |
| 96 | - /// Steppers ± 5 minutes (liens, zéro JS). | |
| 94 | + /// Total en minutes ventilé sur les deux capsules « h » / « min » (les | |
| 95 | + /// liens et les presets portent le total). | |
| 96 | + pub hours: i32, | |
| 97 | + pub mins: i32, | |
| 98 | + /// « soit 90 minutes — tape dans les champs… » sous les capsules. | |
| 99 | + pub hint: String, | |
| 100 | + /// Steppers ± 1 h et ± 5 minutes (liens, zéro JS). | |
| 101 | + pub hour_minus_href: String, | |
| 102 | + pub hour_plus_href: String, | |
| 97 | 103 | pub minus_href: String, |
| 98 | 104 | pub plus_href: String, |
| 99 | 105 | pub presets: Vec<Preset>, |
| @@ -227,7 +233,7 @@ pub async fn entry_new( | ||
| 227 | 233 | .or(pending.as_ref().map(|t| t.start_date())) |
| 228 | 234 | .unwrap_or_else(paris_today) |
| 229 | 235 | .min(paris_today()); |
| 230 | - let presets = duration_presets(minutes, |m| step2_href(picked.id, m, date)); | |
| 236 | + let presets = step2_presets(minutes, |m| step2_href(picked.id, m, date)); | |
| 231 | 237 | let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; |
| 232 | 238 | return Ok(HtmlTemplate(AddStep2Page { |
| 233 | 239 | csrf: ctx.user.csrf_token(&state), |
| @@ -235,10 +241,13 @@ pub async fn entry_new( | ||
| 235 | 241 | categorie: picked.id, |
| 236 | 242 | picked_color: colors[&picked.id], |
| 237 | 243 | picked_name: picked.name.clone(), |
| 238 | - minutes, | |
| 239 | - minutes_pretty: fmt_minutes(minutes as i64), | |
| 240 | - // Comme la maquette : jamais sous 5 min via le stepper, pas de plafond. | |
| 241 | - minus_href: step2_href(picked.id, (minutes - 5).max(5), date), | |
| 244 | + hours: minutes / 60, | |
| 245 | + mins: minutes % 60, | |
| 246 | + hint: p1(tr.duration_hint_pat, minutes), | |
| 247 | + // Comme la maquette : plancher 1 min via les steppers, pas de plafond. | |
| 248 | + hour_minus_href: step2_href(picked.id, (minutes - 60).max(1), date), | |
| 249 | + hour_plus_href: step2_href(picked.id, minutes.saturating_add(60), date), | |
| 250 | + minus_href: step2_href(picked.id, (minutes - 5).max(1), date), | |
| 242 | 251 | plus_href: step2_href(picked.id, minutes.saturating_add(5), date), |
| 243 | 252 | presets, |
| 244 | 253 | date, |
| @@ -320,6 +329,9 @@ pub async fn entry_new( | ||
| 320 | 329 | |
| 321 | 330 | #[derive(Deserialize)] |
| 322 | 331 | pub struct EntryForm { |
| 332 | + /// Capsule « h » de l'étape 2 : s'ajoute à `minutes` (×60). Absente sur | |
| 333 | + /// les autres formulaires qui postent une durée en minutes seules. | |
| 334 | + heures: Option<i32>, | |
| 323 | 335 | minutes: i32, |
| 324 | 336 | categorie: Uuid, |
| 325 | 337 | date: NaiveDate, |
| @@ -340,8 +352,15 @@ fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> { | ||
| 340 | 352 | pub async fn entry_create( |
| 341 | 353 | State(state): State<AppState>, |
| 342 | 354 | ctx: ActiveTeam, |
| 343 | - Form(form): Form<EntryForm>, | |
| 355 | + Form(mut form): Form<EntryForm>, | |
| 344 | 356 | ) -> Result<Redirect, AppError> { |
| 357 | + // Total = heures × 60 + minutes ; tout le reste du handler ne voit que | |
| 358 | + // `minutes`. | |
| 359 | + form.minutes = form | |
| 360 | + .heures | |
| 361 | + .unwrap_or(0) | |
| 362 | + .saturating_mul(60) | |
| 363 | + .saturating_add(form.minutes); | |
| 345 | 364 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 346 | 365 | return Err(AppError::Csrf); |
| 347 | 366 | } |
| @@ -522,8 +541,15 @@ pub async fn entry_update( | ||
| 522 | 541 | State(state): State<AppState>, |
| 523 | 542 | ctx: ActiveTeam, |
| 524 | 543 | Path(id): Path<Uuid>, |
| 525 | - Form(form): Form<EntryForm>, | |
| 544 | + Form(mut form): Form<EntryForm>, | |
| 526 | 545 | ) -> Result<Redirect, AppError> { |
| 546 | + // Total = heures × 60 + minutes ; tout le reste du handler ne voit que | |
| 547 | + // `minutes`. | |
| 548 | + form.minutes = form | |
| 549 | + .heures | |
| 550 | + .unwrap_or(0) | |
| 551 | + .saturating_mul(60) | |
| 552 | + .saturating_add(form.minutes); | |
| 527 | 553 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 528 | 554 | return Err(AppError::Csrf); |
| 529 | 555 | } |
| @@ -16,7 +16,7 @@ use crate::error::AppError; | |||
| 16 | use crate::i18n::{p1, p2}; | 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, shell, step2_presets}; |
| 20 | use crate::state::AppState; | 20 | use crate::state::AppState; |
| 21 | use crate::team::ActiveTeam; | 21 | use crate::team::ActiveTeam; |
| 22 | 22 | ||
| @@ -91,9 +91,15 @@ pub struct AddStep2Page { | |||
| 91 | pub categorie: Uuid, | 91 | pub categorie: Uuid, |
| 92 | pub picked_color: &'static str, | 92 | pub picked_color: &'static str, |
| 93 | pub picked_name: String, | 93 | pub picked_name: String, |
| 94 | - pub minutes: i32, | 94 | + /// Total en minutes ventilé sur les deux capsules « h » / « min » (les |
| 95 | - pub minutes_pretty: String, | 95 | + /// liens et les presets portent le total). |
| 96 | - /// Steppers ± 5 minutes (liens, zéro JS). | 96 | + pub hours: i32, |
| 97 | + pub mins: i32, | ||
| 98 | + /// « soit 90 minutes — tape dans les champs… » sous les capsules. | ||
| 99 | + pub hint: String, | ||
| 100 | + /// Steppers ± 1 h et ± 5 minutes (liens, zéro JS). | ||
| 101 | + pub hour_minus_href: String, | ||
| 102 | + pub hour_plus_href: String, | ||
| 97 | pub minus_href: String, | 103 | pub minus_href: String, |
| 98 | pub plus_href: String, | 104 | pub plus_href: String, |
| 99 | pub presets: Vec<Preset>, | 105 | pub presets: Vec<Preset>, |
| @@ -227,7 +233,7 @@ pub async fn entry_new( | |||
| 227 | .or(pending.as_ref().map(|t| t.start_date())) | 233 | .or(pending.as_ref().map(|t| t.start_date())) |
| 228 | .unwrap_or_else(paris_today) | 234 | .unwrap_or_else(paris_today) |
| 229 | .min(paris_today()); | 235 | .min(paris_today()); |
| 230 | - let presets = duration_presets(minutes, |m| step2_href(picked.id, m, date)); | 236 | + let presets = step2_presets(minutes, |m| step2_href(picked.id, m, date)); |
| 231 | let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; | 237 | let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?; |
| 232 | return Ok(HtmlTemplate(AddStep2Page { | 238 | return Ok(HtmlTemplate(AddStep2Page { |
| 233 | csrf: ctx.user.csrf_token(&state), | 239 | csrf: ctx.user.csrf_token(&state), |
| @@ -235,10 +241,13 @@ pub async fn entry_new( | |||
| 235 | categorie: picked.id, | 241 | categorie: picked.id, |
| 236 | picked_color: colors[&picked.id], | 242 | picked_color: colors[&picked.id], |
| 237 | picked_name: picked.name.clone(), | 243 | picked_name: picked.name.clone(), |
| 238 | - minutes, | 244 | + hours: minutes / 60, |
| 239 | - minutes_pretty: fmt_minutes(minutes as i64), | 245 | + mins: minutes % 60, |
| 240 | - // Comme la maquette : jamais sous 5 min via le stepper, pas de plafond. | 246 | + hint: p1(tr.duration_hint_pat, minutes), |
| 241 | - minus_href: step2_href(picked.id, (minutes - 5).max(5), date), | 247 | + // Comme la maquette : plancher 1 min via les steppers, pas de plafond. |
| 248 | + hour_minus_href: step2_href(picked.id, (minutes - 60).max(1), date), | ||
| 249 | + hour_plus_href: step2_href(picked.id, minutes.saturating_add(60), date), | ||
| 250 | + minus_href: step2_href(picked.id, (minutes - 5).max(1), date), | ||
| 242 | plus_href: step2_href(picked.id, minutes.saturating_add(5), date), | 251 | plus_href: step2_href(picked.id, minutes.saturating_add(5), date), |
| 243 | presets, | 252 | presets, |
| 244 | date, | 253 | date, |
| @@ -320,6 +329,9 @@ pub async fn entry_new( | |||
| 320 | 329 | ||
| 321 | #[derive(Deserialize)] | 330 | #[derive(Deserialize)] |
| 322 | pub struct EntryForm { | 331 | pub struct EntryForm { |
| 332 | + /// Capsule « h » de l'étape 2 : s'ajoute à `minutes` (×60). Absente sur | ||
| 333 | + /// les autres formulaires qui postent une durée en minutes seules. | ||
| 334 | + heures: Option<i32>, | ||
| 323 | minutes: i32, | 335 | minutes: i32, |
| 324 | categorie: Uuid, | 336 | categorie: Uuid, |
| 325 | date: NaiveDate, | 337 | date: NaiveDate, |
| @@ -340,8 +352,15 @@ fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> { | |||
| 340 | pub async fn entry_create( | 352 | pub async fn entry_create( |
| 341 | State(state): State<AppState>, | 353 | State(state): State<AppState>, |
| 342 | ctx: ActiveTeam, | 354 | ctx: ActiveTeam, |
| 343 | - Form(form): Form<EntryForm>, | 355 | + Form(mut form): Form<EntryForm>, |
| 344 | ) -> Result<Redirect, AppError> { | 356 | ) -> Result<Redirect, AppError> { |
| 357 | + // Total = heures × 60 + minutes ; tout le reste du handler ne voit que | ||
| 358 | + // `minutes`. | ||
| 359 | + form.minutes = form | ||
| 360 | + .heures | ||
| 361 | + .unwrap_or(0) | ||
| 362 | + .saturating_mul(60) | ||
| 363 | + .saturating_add(form.minutes); | ||
| 345 | if !ctx.user.check_csrf(&state, &form.csrf) { | 364 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 346 | return Err(AppError::Csrf); | 365 | return Err(AppError::Csrf); |
| 347 | } | 366 | } |
| @@ -522,8 +541,15 @@ pub async fn entry_update( | |||
| 522 | State(state): State<AppState>, | 541 | State(state): State<AppState>, |
| 523 | ctx: ActiveTeam, | 542 | ctx: ActiveTeam, |
| 524 | Path(id): Path<Uuid>, | 543 | Path(id): Path<Uuid>, |
| 525 | - Form(form): Form<EntryForm>, | 544 | + Form(mut form): Form<EntryForm>, |
| 526 | ) -> Result<Redirect, AppError> { | 545 | ) -> Result<Redirect, AppError> { |
| 546 | + // Total = heures × 60 + minutes ; tout le reste du handler ne voit que | ||
| 547 | + // `minutes`. | ||
| 548 | + form.minutes = form | ||
| 549 | + .heures | ||
| 550 | + .unwrap_or(0) | ||
| 551 | + .saturating_mul(60) | ||
| 552 | + .saturating_add(form.minutes); | ||
| 527 | if !ctx.user.check_csrf(&state, &form.csrf) { | 553 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 528 | return Err(AppError::Csrf); | 554 | return Err(AppError::Csrf); |
| 529 | } | 555 | } |
modified
src/routes/mod.rs +26 -9 | @@ -170,15 +170,32 @@ pub struct Preset { | ||
| 170 | 170 | |
| 171 | 171 | /// Les presets 5/15/30/45 min/1 h, avec l'href zéro-JS propre à chaque écran. |
| 172 | 172 | pub fn duration_presets(minutes: i32, href: impl Fn(i32) -> String) -> Vec<Preset> { |
| 173 | - [ | |
| 174 | - (5, "5 min"), | |
| 175 | - (15, "15 min"), | |
| 176 | - (30, "30 min"), | |
| 177 | - (45, "45 min"), | |
| 178 | - (60, "1 h"), | |
| 179 | - ] | |
| 180 | - .into_iter() | |
| 181 | - .map(|(m, label)| Preset { | |
| 173 | + presets_of( | |
| 174 | + &[ | |
| 175 | + (5, "5 min"), | |
| 176 | + (15, "15 min"), | |
| 177 | + (30, "30 min"), | |
| 178 | + (45, "45 min"), | |
| 179 | + (60, "1 h"), | |
| 180 | + ], | |
| 181 | + minutes, | |
| 182 | + href, | |
| 183 | + ) | |
| 184 | +} | |
| 185 | + | |
| 186 | +/// Presets de l'étape 2 (design : grille de 4, le sélecteur h/min couvre le | |
| 187 | +/// reste). | |
| 188 | +pub fn step2_presets(minutes: i32, href: impl Fn(i32) -> String) -> Vec<Preset> { | |
| 189 | + presets_of(&[(5, "5 min"), (15, "15 min"), (30, "30 min"), (60, "1 h")], minutes, href) | |
| 190 | +} | |
| 191 | + | |
| 192 | +fn presets_of( | |
| 193 | + list: &[(i32, &'static str)], | |
| 194 | + minutes: i32, | |
| 195 | + href: impl Fn(i32) -> String, | |
| 196 | +) -> Vec<Preset> { | |
| 197 | + list.iter() | |
| 198 | + .map(|&(m, label)| Preset { | |
| 182 | 199 | label, |
| 183 | 200 | href: href(m), |
| 184 | 201 | minutes: m, |
| @@ -170,15 +170,32 @@ pub struct Preset { | |||
| 170 | 170 | ||
| 171 | /// Les presets 5/15/30/45 min/1 h, avec l'href zéro-JS propre à chaque écran. | 171 | /// Les presets 5/15/30/45 min/1 h, avec l'href zéro-JS propre à chaque écran. |
| 172 | pub fn duration_presets(minutes: i32, href: impl Fn(i32) -> String) -> Vec<Preset> { | 172 | pub fn duration_presets(minutes: i32, href: impl Fn(i32) -> String) -> Vec<Preset> { |
| 173 | - [ | 173 | + presets_of( |
| 174 | - (5, "5 min"), | 174 | + &[ |
| 175 | - (15, "15 min"), | 175 | + (5, "5 min"), |
| 176 | - (30, "30 min"), | 176 | + (15, "15 min"), |
| 177 | - (45, "45 min"), | 177 | + (30, "30 min"), |
| 178 | - (60, "1 h"), | 178 | + (45, "45 min"), |
| 179 | - ] | 179 | + (60, "1 h"), |
| 180 | - .into_iter() | 180 | + ], |
| 181 | - .map(|(m, label)| Preset { | 181 | + minutes, |
| 182 | + href, | ||
| 183 | + ) | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +/// Presets de l'étape 2 (design : grille de 4, le sélecteur h/min couvre le | ||
| 187 | +/// reste). | ||
| 188 | +pub fn step2_presets(minutes: i32, href: impl Fn(i32) -> String) -> Vec<Preset> { | ||
| 189 | + presets_of(&[(5, "5 min"), (15, "15 min"), (30, "30 min"), (60, "1 h")], minutes, href) | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +fn presets_of( | ||
| 193 | + list: &[(i32, &'static str)], | ||
| 194 | + minutes: i32, | ||
| 195 | + href: impl Fn(i32) -> String, | ||
| 196 | +) -> Vec<Preset> { | ||
| 197 | + list.iter() | ||
| 198 | + .map(|&(m, label)| Preset { | ||
| 182 | label, | 199 | label, |
| 183 | href: href(m), | 200 | href: href(m), |
| 184 | minutes: m, | 201 | minutes: m, |
modified
templates/add_step2.html +2 -2 | @@ -21,8 +21,8 @@ | ||
| 21 | 21 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 22 | 22 | <input type="hidden" name="categorie" value="{{ categorie }}"> |
| 23 | 23 | |
| 24 | - <label class="mini-label" for="minutes">{{ shell.tr.duration_label }}</label> | |
| 25 | - {% call c::duration_picker(shell, minus_href, plus_href, minutes, minutes_pretty, "", presets) %}{% endcall %} | |
| 24 | + <p class="mini-label">{{ shell.tr.duration_label }}</p> | |
| 25 | + {% call c::hm_picker(shell, hour_minus_href, hour_plus_href, minus_href, plus_href, hours, mins, hint, presets) %}{% endcall %} | |
| 26 | 26 | |
| 27 | 27 | <label class="mini-label" for="date">{{ shell.tr.date_label }}</label> |
| 28 | 28 | {% call c::date_row(shell, "date", "date", date, today.to_string(), "", day_prev, day_next, true) %}{% endcall %} |
| @@ -21,8 +21,8 @@ | |||
| 21 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 21 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 22 | <input type="hidden" name="categorie" value="{{ categorie }}"> | 22 | <input type="hidden" name="categorie" value="{{ categorie }}"> |
| 23 | 23 | ||
| 24 | - <label class="mini-label" for="minutes">{{ shell.tr.duration_label }}</label> | 24 | + <p class="mini-label">{{ shell.tr.duration_label }}</p> |
| 25 | - {% call c::duration_picker(shell, minus_href, plus_href, minutes, minutes_pretty, "", presets) %}{% endcall %} | 25 | + {% call c::hm_picker(shell, hour_minus_href, hour_plus_href, minus_href, plus_href, hours, mins, hint, presets) %}{% endcall %} |
| 26 | 26 | ||
| 27 | <label class="mini-label" for="date">{{ shell.tr.date_label }}</label> | 27 | <label class="mini-label" for="date">{{ shell.tr.date_label }}</label> |
| 28 | {% call c::date_row(shell, "date", "date", date, today.to_string(), "", day_prev, day_next, true) %}{% endcall %} | 28 | {% call c::date_row(shell, "date", "date", date, today.to_string(), "", day_prev, day_next, true) %}{% endcall %} |
modified
templates/app.html +24 -8 | @@ -204,10 +204,24 @@ | ||
| 204 | 204 | if (!btn || !cb) return; |
| 205 | 205 | btn.textContent = cb.checked && !cb.disabled ? btn.dataset.labelContinue : btn.dataset.labelAdd; |
| 206 | 206 | } |
| 207 | + // Total en minutes du formulaire : capsule « h » (étape 2) × 60 + minutes. | |
| 208 | + function totalMinutes(root) { | |
| 209 | + var h = root.querySelector('#heures'), min = root.querySelector('#minutes'); | |
| 210 | + return (h ? (Math.round(+h.value) || 0) * 60 : 0) + (Math.round(+min.value) || 0); | |
| 211 | + } | |
| 212 | + // Pose un total : ventilé sur h / min quand la capsule « h » existe. | |
| 213 | + function setTotal(root, v) { | |
| 214 | + var h = root.querySelector('#heures'), min = root.querySelector('#minutes'); | |
| 215 | + if (h) { h.value = Math.floor(v / 60); min.value = v % 60; } | |
| 216 | + else { min.value = v; } | |
| 217 | + refreshMinutes(root); | |
| 218 | + } | |
| 207 | 219 | function refreshMinutes(root) { |
| 208 | - var min = root.querySelector('#minutes'); | |
| 209 | - var v = Math.round(+min.value) || 0; | |
| 210 | - root.querySelector('#minutes-pretty').textContent = fmtMinutes(v); | |
| 220 | + var v = totalMinutes(root); | |
| 221 | + var pretty = root.querySelector('#minutes-pretty'); | |
| 222 | + if (pretty) pretty.textContent = fmtMinutes(v); | |
| 223 | + var hint = root.querySelector('[data-hint-pat]'); | |
| 224 | + if (hint) hint.textContent = hint.dataset.hintPat.replace('{}', v); | |
| 211 | 225 | root.querySelectorAll('.preset[data-minutes]').forEach(function (p) { |
| 212 | 226 | if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); } |
| 213 | 227 | else { p.removeAttribute('aria-current'); } |
| @@ -262,16 +276,18 @@ | ||
| 262 | 276 | refreshDate(root, inp); |
| 263 | 277 | return; |
| 264 | 278 | } |
| 265 | - var min = root.querySelector('#minutes'); | |
| 266 | - if (b.dataset.step) { min.value = Math.max(5, (Math.round(+min.value) || 0) + (+b.dataset.step)); } | |
| 267 | - else { min.value = +b.dataset.minutes; } | |
| 268 | - refreshMinutes(root); | |
| 279 | + if (b.dataset.step) { setTotal(root, Math.max(1, totalMinutes(root) + (+b.dataset.step))); } | |
| 280 | + else { setTotal(root, +b.dataset.minutes); } | |
| 269 | 281 | }); |
| 270 | 282 | ['input', 'change'].forEach(function (ev) { |
| 271 | 283 | document.addEventListener(ev, function (e) { |
| 272 | 284 | var root = e.target.closest && e.target.closest('form[data-duration]'); |
| 273 | 285 | if (!root) return; |
| 274 | - if (e.target.id === 'minutes') refreshMinutes(root); | |
| 286 | + // Saisie libre : recalcul à chaque frappe, ventilation h / min | |
| 287 | + // (90 min → 1 h 30) seulement une fois le champ quitté. | |
| 288 | + if (e.target.id === 'minutes' || e.target.id === 'heures') { | |
| 289 | + if (ev === 'change') setTotal(root, Math.max(1, totalMinutes(root))); else refreshMinutes(root); | |
| 290 | + } | |
| 275 | 291 | else if (e.target.type === 'date') refreshDate(root, e.target); |
| 276 | 292 | else if (e.target.closest('[data-split]')) { |
| 277 | 293 | if (ev === 'change') e.target.closest('[data-split]').dataset.want = e.target.checked ? '1' : '0'; |
| @@ -204,10 +204,24 @@ | |||
| 204 | if (!btn || !cb) return; | 204 | if (!btn || !cb) return; |
| 205 | btn.textContent = cb.checked && !cb.disabled ? btn.dataset.labelContinue : btn.dataset.labelAdd; | 205 | btn.textContent = cb.checked && !cb.disabled ? btn.dataset.labelContinue : btn.dataset.labelAdd; |
| 206 | } | 206 | } |
| 207 | + // Total en minutes du formulaire : capsule « h » (étape 2) × 60 + minutes. | ||
| 208 | + function totalMinutes(root) { | ||
| 209 | + var h = root.querySelector('#heures'), min = root.querySelector('#minutes'); | ||
| 210 | + return (h ? (Math.round(+h.value) || 0) * 60 : 0) + (Math.round(+min.value) || 0); | ||
| 211 | + } | ||
| 212 | + // Pose un total : ventilé sur h / min quand la capsule « h » existe. | ||
| 213 | + function setTotal(root, v) { | ||
| 214 | + var h = root.querySelector('#heures'), min = root.querySelector('#minutes'); | ||
| 215 | + if (h) { h.value = Math.floor(v / 60); min.value = v % 60; } | ||
| 216 | + else { min.value = v; } | ||
| 217 | + refreshMinutes(root); | ||
| 218 | + } | ||
| 207 | function refreshMinutes(root) { | 219 | function refreshMinutes(root) { |
| 208 | - var min = root.querySelector('#minutes'); | 220 | + var v = totalMinutes(root); |
| 209 | - var v = Math.round(+min.value) || 0; | 221 | + var pretty = root.querySelector('#minutes-pretty'); |
| 210 | - root.querySelector('#minutes-pretty').textContent = fmtMinutes(v); | 222 | + if (pretty) pretty.textContent = fmtMinutes(v); |
| 223 | + var hint = root.querySelector('[data-hint-pat]'); | ||
| 224 | + if (hint) hint.textContent = hint.dataset.hintPat.replace('{}', v); | ||
| 211 | root.querySelectorAll('.preset[data-minutes]').forEach(function (p) { | 225 | root.querySelectorAll('.preset[data-minutes]').forEach(function (p) { |
| 212 | if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); } | 226 | if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); } |
| 213 | else { p.removeAttribute('aria-current'); } | 227 | else { p.removeAttribute('aria-current'); } |
| @@ -262,16 +276,18 @@ | |||
| 262 | refreshDate(root, inp); | 276 | refreshDate(root, inp); |
| 263 | return; | 277 | return; |
| 264 | } | 278 | } |
| 265 | - var min = root.querySelector('#minutes'); | 279 | + if (b.dataset.step) { setTotal(root, Math.max(1, totalMinutes(root) + (+b.dataset.step))); } |
| 266 | - if (b.dataset.step) { min.value = Math.max(5, (Math.round(+min.value) || 0) + (+b.dataset.step)); } | 280 | + else { setTotal(root, +b.dataset.minutes); } |
| 267 | - else { min.value = +b.dataset.minutes; } | ||
| 268 | - refreshMinutes(root); | ||
| 269 | }); | 281 | }); |
| 270 | ['input', 'change'].forEach(function (ev) { | 282 | ['input', 'change'].forEach(function (ev) { |
| 271 | document.addEventListener(ev, function (e) { | 283 | document.addEventListener(ev, function (e) { |
| 272 | var root = e.target.closest && e.target.closest('form[data-duration]'); | 284 | var root = e.target.closest && e.target.closest('form[data-duration]'); |
| 273 | if (!root) return; | 285 | if (!root) return; |
| 274 | - if (e.target.id === 'minutes') refreshMinutes(root); | 286 | + // Saisie libre : recalcul à chaque frappe, ventilation h / min |
| 287 | + // (90 min → 1 h 30) seulement une fois le champ quitté. | ||
| 288 | + if (e.target.id === 'minutes' || e.target.id === 'heures') { | ||
| 289 | + if (ev === 'change') setTotal(root, Math.max(1, totalMinutes(root))); else refreshMinutes(root); | ||
| 290 | + } | ||
| 275 | else if (e.target.type === 'date') refreshDate(root, e.target); | 291 | else if (e.target.type === 'date') refreshDate(root, e.target); |
| 276 | else if (e.target.closest('[data-split]')) { | 292 | else if (e.target.closest('[data-split]')) { |
| 277 | if (ev === 'change') e.target.closest('[data-split]').dataset.want = e.target.checked ? '1' : '0'; | 293 | if (ev === 'change') e.target.closest('[data-split]').dataset.want = e.target.checked ? '1' : '0'; |
modified
templates/components.html +42 -0 | @@ -34,6 +34,48 @@ | ||
| 34 | 34 | </div> |
| 35 | 35 | {% endmacro %} |
| 36 | 36 | |
| 37 | +{#- Sélecteur heures + minutes de l'étape 2 (design) : deux capsules, chacune | |
| 38 | + avec ses steppers (liens ±60 / ±5 sur le total, zéro JS) et son champ ; | |
| 39 | + le serveur poste heures + minutes et en fait le total. Sous les capsules, | |
| 40 | + l'indice « soit N minutes… » (data-hint-pat : le JS le refait sans | |
| 41 | + recharger). Presets en grille de 4. -#} | |
| 42 | +{% macro hm_picker(shell, hour_minus_href, hour_plus_href, minus_href, plus_href, hours, mins, hint, presets) %} | |
| 43 | +<div class="hm-row"> | |
| 44 | + <div class="stepper hm"> | |
| 45 | + <a class="stepper-btn" href="{{ hour_minus_href }}" data-step="-60" aria-label="{{ shell.tr.hour_less }}"> | |
| 46 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> | |
| 47 | + </a> | |
| 48 | + <div class="hm-field"> | |
| 49 | + <input class="input hm-input" id="heures" name="heures" type="number" inputmode="numeric" | |
| 50 | + min="0" required value="{{ hours }}" aria-label="{{ shell.tr.hours_aria }}"> | |
| 51 | + <span class="hm-unit">{{ shell.tr.hours_short }}</span> | |
| 52 | + </div> | |
| 53 | + <a class="stepper-btn" href="{{ hour_plus_href }}" data-step="60" aria-label="{{ shell.tr.hour_more }}"> | |
| 54 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> | |
| 55 | + </a> | |
| 56 | + </div> | |
| 57 | + <div class="stepper hm"> | |
| 58 | + <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="{{ shell.tr.five_less }}"> | |
| 59 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> | |
| 60 | + </a> | |
| 61 | + <div class="hm-field"> | |
| 62 | + <input class="input hm-input" id="minutes" name="minutes" type="number" inputmode="numeric" | |
| 63 | + min="0" required value="{{ mins }}" aria-label="{{ shell.tr.minutes_aria }}"> | |
| 64 | + <span class="hm-unit">{{ shell.tr.minutes_short }}</span> | |
| 65 | + </div> | |
| 66 | + <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="{{ shell.tr.five_more }}"> | |
| 67 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> | |
| 68 | + </a> | |
| 69 | + </div> | |
| 70 | +</div> | |
| 71 | +<p class="hm-hint" data-hint-pat="{{ shell.tr.duration_hint_pat }}">{{ hint }}</p> | |
| 72 | +<div class="presets presets-grid"> | |
| 73 | + {% for p in presets %} | |
| 74 | + <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | |
| 75 | + {% endfor %} | |
| 76 | +</div> | |
| 77 | +{% endmacro %} | |
| 78 | + | |
| 37 | 79 | {#- Rangée date : ← input →. next_href est une Option (None = aujourd'hui, |
| 38 | 80 | flèche désactivée). js=true pose les data-attrs du stepping délégué |
| 39 | 81 | d'app.html (form[data-duration]) — max doit alors être renseigné, le JS |
| @@ -34,6 +34,48 @@ | |||
| 34 | </div> | 34 | </div> |
| 35 | {% endmacro %} | 35 | {% endmacro %} |
| 36 | 36 | ||
| 37 | +{#- Sélecteur heures + minutes de l'étape 2 (design) : deux capsules, chacune | ||
| 38 | + avec ses steppers (liens ±60 / ±5 sur le total, zéro JS) et son champ ; | ||
| 39 | + le serveur poste heures + minutes et en fait le total. Sous les capsules, | ||
| 40 | + l'indice « soit N minutes… » (data-hint-pat : le JS le refait sans | ||
| 41 | + recharger). Presets en grille de 4. -#} | ||
| 42 | +{% macro hm_picker(shell, hour_minus_href, hour_plus_href, minus_href, plus_href, hours, mins, hint, presets) %} | ||
| 43 | +<div class="hm-row"> | ||
| 44 | + <div class="stepper hm"> | ||
| 45 | + <a class="stepper-btn" href="{{ hour_minus_href }}" data-step="-60" aria-label="{{ shell.tr.hour_less }}"> | ||
| 46 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> | ||
| 47 | + </a> | ||
| 48 | + <div class="hm-field"> | ||
| 49 | + <input class="input hm-input" id="heures" name="heures" type="number" inputmode="numeric" | ||
| 50 | + min="0" required value="{{ hours }}" aria-label="{{ shell.tr.hours_aria }}"> | ||
| 51 | + <span class="hm-unit">{{ shell.tr.hours_short }}</span> | ||
| 52 | + </div> | ||
| 53 | + <a class="stepper-btn" href="{{ hour_plus_href }}" data-step="60" aria-label="{{ shell.tr.hour_more }}"> | ||
| 54 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> | ||
| 55 | + </a> | ||
| 56 | + </div> | ||
| 57 | + <div class="stepper hm"> | ||
| 58 | + <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="{{ shell.tr.five_less }}"> | ||
| 59 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg> | ||
| 60 | + </a> | ||
| 61 | + <div class="hm-field"> | ||
| 62 | + <input class="input hm-input" id="minutes" name="minutes" type="number" inputmode="numeric" | ||
| 63 | + min="0" required value="{{ mins }}" aria-label="{{ shell.tr.minutes_aria }}"> | ||
| 64 | + <span class="hm-unit">{{ shell.tr.minutes_short }}</span> | ||
| 65 | + </div> | ||
| 66 | + <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="{{ shell.tr.five_more }}"> | ||
| 67 | + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg> | ||
| 68 | + </a> | ||
| 69 | + </div> | ||
| 70 | +</div> | ||
| 71 | +<p class="hm-hint" data-hint-pat="{{ shell.tr.duration_hint_pat }}">{{ hint }}</p> | ||
| 72 | +<div class="presets presets-grid"> | ||
| 73 | + {% for p in presets %} | ||
| 74 | + <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | ||
| 75 | + {% endfor %} | ||
| 76 | +</div> | ||
| 77 | +{% endmacro %} | ||
| 78 | + | ||
| 37 | {#- Rangée date : ← input →. next_href est une Option (None = aujourd'hui, | 79 | {#- Rangée date : ← input →. next_href est une Option (None = aujourd'hui, |
| 38 | flèche désactivée). js=true pose les data-attrs du stepping délégué | 80 | flèche désactivée). js=true pose les data-attrs du stepping délégué |
| 39 | d'app.html (form[data-duration]) — max doit alors être renseigné, le JS | 81 | d'app.html (form[data-duration]) — max doit alors être renseigné, le JS |