judu/ex-egopublic Fork
⑂ c2b3977
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(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.
Julien Durillon committed 2026-09-13T23:20:33+02:00 Browse files
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
505505 min-height: 44px; text-align: center; padding: 0;
506506 background: transparent; border-color: transparent;
507507 }
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; }
508520 .duration-row .unit { font-size: 0.9375rem; opacity: .7; }
509521 .duration-row .pretty { font-size: 0.8125rem; opacity: .7; }
510522 .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
514526 border: 1px solid var(--color-divider); color: var(--color-text);
515527 }
516528 .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+}
517535 .date-row { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-3); }
518536 .date-row .input { flex: 1; min-height: 44px; text-align: center; }
519537 .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 {
124124 pub date_label: &'static str,
125125 pub five_less: &'static str,
126126 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,
127136 pub day_prev: &'static str,
128137 pub day_next: &'static str,
129138 pub forgot_timer: &'static str,
@@ -514,6 +523,13 @@ pub static FR: Tr = Tr {
514523 date_label: "Date",
515524 five_less: "Cinq minutes de moins",
516525 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",
517533 day_prev: "Jour précédent",
518534 day_next: "Jour suivant",
519535 forgot_timer: "Timer oublié ? Tu pourras corriger la durée à l'étape suivante.",
@@ -832,6 +848,13 @@ pub static EN: Tr = Tr {
832848 date_label: "Date",
833849 five_less: "Five minutes less",
834850 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",
835858 day_prev: "Previous day",
836859 day_next: "Next day",
837860 forgot_timer: "Forgot the timer? You can fix the duration in the next step.",
@@ -1150,6 +1173,13 @@ pub static DE: Tr = Tr {
11501173 date_label: "Datum",
11511174 five_less: "Fünf Minuten weniger",
11521175 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",
11531183 day_prev: "Vortag",
11541184 day_next: "Nächster Tag",
11551185 forgot_timer: "Timer vergessen? Du kannst die Dauer im nächsten Schritt anpassen.",
@@ -1468,6 +1498,13 @@ pub static PT_BR: Tr = Tr {
14681498 date_label: "Data",
14691499 five_less: "Cinco minutos a menos",
14701500 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",
14711508 day_prev: "Dia anterior",
14721509 day_next: "Próximo dia",
14731510 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;
1616 use crate::i18n::{p1, p2};
1717 use crate::period::{DateVocab, Week, day_label, paris_today};
1818 use crate::render::{HtmlTemplate, Shell};
19-use crate::routes::{Preset, duration_presets, shell};
19+use crate::routes::{Preset, shell, step2_presets};
2020 use crate::state::AppState;
2121 use crate::team::ActiveTeam;
2222
@@ -91,9 +91,15 @@ pub struct AddStep2Page {
9191 pub categorie: Uuid,
9292 pub picked_color: &'static str,
9393 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,
97103 pub minus_href: String,
98104 pub plus_href: String,
99105 pub presets: Vec<Preset>,
@@ -227,7 +233,7 @@ pub async fn entry_new(
227233 .or(pending.as_ref().map(|t| t.start_date()))
228234 .unwrap_or_else(paris_today)
229235 .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));
231237 let sh = shell(&state, &ctx, tr.title_add, "add", toast).await?;
232238 return Ok(HtmlTemplate(AddStep2Page {
233239 csrf: ctx.user.csrf_token(&state),
@@ -235,10 +241,13 @@ pub async fn entry_new(
235241 categorie: picked.id,
236242 picked_color: colors[&picked.id],
237243 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),
242251 plus_href: step2_href(picked.id, minutes.saturating_add(5), date),
243252 presets,
244253 date,
@@ -320,6 +329,9 @@ pub async fn entry_new(
320329
321330 #[derive(Deserialize)]
322331 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>,
323335 minutes: i32,
324336 categorie: Uuid,
325337 date: NaiveDate,
@@ -340,8 +352,15 @@ fn validate(form: &EntryForm, tr: &crate::i18n::Tr) -> Result<(), AppError> {
340352 pub async fn entry_create(
341353 State(state): State<AppState>,
342354 ctx: ActiveTeam,
343- Form(form): Form<EntryForm>,
355+ Form(mut form): Form<EntryForm>,
344356 ) -> 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);
345364 if !ctx.user.check_csrf(&state, &form.csrf) {
346365 return Err(AppError::Csrf);
347366 }
@@ -522,8 +541,15 @@ pub async fn entry_update(
522541 State(state): State<AppState>,
523542 ctx: ActiveTeam,
524543 Path(id): Path<Uuid>,
525- Form(form): Form<EntryForm>,
544+ Form(mut form): Form<EntryForm>,
526545 ) -> 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);
527553 if !ctx.user.check_csrf(&state, &form.csrf) {
528554 return Err(AppError::Csrf);
529555 }
@@ -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 {
170170
171171 /// Les presets 5/15/30/45 min/1 h, avec l'href zéro-JS propre à chaque écran.
172172 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 {
182199 label,
183200 href: href(m),
184201 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 @@
2121 <input type="hidden" name="csrf" value="{{ csrf }}">
2222 <input type="hidden" name="categorie" value="{{ categorie }}">
2323
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 %}
2626
2727 <label class="mini-label" for="date">{{ shell.tr.date_label }}</label>
2828 {% 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 @@
204204 if (!btn || !cb) return;
205205 btn.textContent = cb.checked && !cb.disabled ? btn.dataset.labelContinue : btn.dataset.labelAdd;
206206 }
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+ }
207219 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);
211225 root.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
212226 if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); }
213227 else { p.removeAttribute('aria-current'); }
@@ -262,16 +276,18 @@
262276 refreshDate(root, inp);
263277 return;
264278 }
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); }
269281 });
270282 ['input', 'change'].forEach(function (ev) {
271283 document.addEventListener(ev, function (e) {
272284 var root = e.target.closest && e.target.closest('form[data-duration]');
273285 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+ }
275291 else if (e.target.type === 'date') refreshDate(root, e.target);
276292 else if (e.target.closest('[data-split]')) {
277293 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 @@
3434 </div>
3535 {% endmacro %}
3636
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+
3779 {#- Rangée date : ← input →. next_href est une Option (None = aujourd'hui,
3880 flèche désactivée). js=true pose les data-attrs du stepping délégué
3981 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 JS81 d'app.html (form[data-duration]) — max doit alors être renseigné, le JS