feat: durée sans plafond + presets sans rechargementUnverified
Migration 0002 : CHECK minutes > 0 seulement (plus de plafond 1440), validation serveur et bornes client alignées. Les presets 15/30/45/1 h remplissent le champ en JS (état sélectionné suivi en direct), avec fallback lien sans JS, comme le stepper ±5.
e76fa64 parent: 8aa0894 added
migrations/0002_no_max_minutes.sql +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +-- Plus de plafond sur la durée d'une saisie : minutes > 0 seulement. | |
| 2 | +ALTER TABLE time_entries DROP CONSTRAINT time_entries_minutes_check; | |
| 3 | +ALTER TABLE time_entries ADD CONSTRAINT time_entries_minutes_check CHECK (minutes > 0); | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +-- Plus de plafond sur la durée d'une saisie : minutes > 0 seulement. | ||
| 2 | +ALTER TABLE time_entries DROP CONSTRAINT time_entries_minutes_check; | ||
| 3 | +ALTER TABLE time_entries ADD CONSTRAINT time_entries_minutes_check CHECK (minutes > 0); | ||
modified
src/routes/entries.rs +7 -5 | @@ -42,6 +42,7 @@ pub struct AddStep1Page { | ||
| 42 | 42 | pub struct Preset { |
| 43 | 43 | pub label: &'static str, |
| 44 | 44 | pub href: String, |
| 45 | + pub minutes: i32, | |
| 45 | 46 | pub selected: bool, |
| 46 | 47 | } |
| 47 | 48 | |
| @@ -93,13 +94,14 @@ pub async fn entry_new( | ||
| 93 | 94 | .tache |
| 94 | 95 | .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) |
| 95 | 96 | { |
| 96 | - let minutes = q.minutes.unwrap_or(30).clamp(1, 1440); | |
| 97 | + let minutes = q.minutes.unwrap_or(30).max(1); | |
| 97 | 98 | let date = q.date.unwrap_or_else(paris_today).min(paris_today()); |
| 98 | 99 | let presets = [(15, "15 min"), (30, "30 min"), (45, "45 min"), (60, "1 h")] |
| 99 | 100 | .into_iter() |
| 100 | 101 | .map(|(m, label)| Preset { |
| 101 | 102 | label, |
| 102 | 103 | href: step2_href(picked.id, m, date), |
| 104 | + minutes: m, | |
| 103 | 105 | selected: minutes == m, |
| 104 | 106 | }) |
| 105 | 107 | .collect(); |
| @@ -111,9 +113,9 @@ pub async fn entry_new( | ||
| 111 | 113 | picked_name: picked.name.clone(), |
| 112 | 114 | minutes, |
| 113 | 115 | minutes_pretty: fmt_minutes(minutes as i64), |
| 114 | - // Comme la maquette : jamais sous 5 min via le stepper. | |
| 116 | + // Comme la maquette : jamais sous 5 min via le stepper, pas de plafond. | |
| 115 | 117 | minus_href: step2_href(picked.id, (minutes - 5).max(5), date), |
| 116 | - plus_href: step2_href(picked.id, (minutes + 5).min(1440), date), | |
| 118 | + plus_href: step2_href(picked.id, minutes.saturating_add(5), date), | |
| 117 | 119 | presets, |
| 118 | 120 | date, |
| 119 | 121 | date_label: day_label(date), |
| @@ -160,9 +162,9 @@ pub struct EntryForm { | ||
| 160 | 162 | } |
| 161 | 163 | |
| 162 | 164 | fn validate(form: &EntryForm) -> Result<(), AppError> { |
| 163 | - if !(1..=1440).contains(&form.minutes) { | |
| 165 | + if form.minutes < 1 { | |
| 164 | 166 | return Err(AppError::BadRequest( |
| 165 | - "La durée doit être comprise entre 1 et 1440 minutes.".into(), | |
| 167 | + "La durée doit être d'au moins 1 minute.".into(), | |
| 166 | 168 | )); |
| 167 | 169 | } |
| 168 | 170 | Ok(()) |
| @@ -42,6 +42,7 @@ pub struct AddStep1Page { | |||
| 42 | pub struct Preset { | 42 | pub struct Preset { |
| 43 | pub label: &'static str, | 43 | pub label: &'static str, |
| 44 | pub href: String, | 44 | pub href: String, |
| 45 | + pub minutes: i32, | ||
| 45 | pub selected: bool, | 46 | pub selected: bool, |
| 46 | } | 47 | } |
| 47 | 48 | ||
| @@ -93,13 +94,14 @@ pub async fn entry_new( | |||
| 93 | .tache | 94 | .tache |
| 94 | .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) | 95 | .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) |
| 95 | { | 96 | { |
| 96 | - let minutes = q.minutes.unwrap_or(30).clamp(1, 1440); | 97 | + let minutes = q.minutes.unwrap_or(30).max(1); |
| 97 | let date = q.date.unwrap_or_else(paris_today).min(paris_today()); | 98 | let date = q.date.unwrap_or_else(paris_today).min(paris_today()); |
| 98 | let presets = [(15, "15 min"), (30, "30 min"), (45, "45 min"), (60, "1 h")] | 99 | let presets = [(15, "15 min"), (30, "30 min"), (45, "45 min"), (60, "1 h")] |
| 99 | .into_iter() | 100 | .into_iter() |
| 100 | .map(|(m, label)| Preset { | 101 | .map(|(m, label)| Preset { |
| 101 | label, | 102 | label, |
| 102 | href: step2_href(picked.id, m, date), | 103 | href: step2_href(picked.id, m, date), |
| 104 | + minutes: m, | ||
| 103 | selected: minutes == m, | 105 | selected: minutes == m, |
| 104 | }) | 106 | }) |
| 105 | .collect(); | 107 | .collect(); |
| @@ -111,9 +113,9 @@ pub async fn entry_new( | |||
| 111 | picked_name: picked.name.clone(), | 113 | picked_name: picked.name.clone(), |
| 112 | minutes, | 114 | minutes, |
| 113 | minutes_pretty: fmt_minutes(minutes as i64), | 115 | minutes_pretty: fmt_minutes(minutes as i64), |
| 114 | - // Comme la maquette : jamais sous 5 min via le stepper. | 116 | + // Comme la maquette : jamais sous 5 min via le stepper, pas de plafond. |
| 115 | minus_href: step2_href(picked.id, (minutes - 5).max(5), date), | 117 | minus_href: step2_href(picked.id, (minutes - 5).max(5), date), |
| 116 | - plus_href: step2_href(picked.id, (minutes + 5).min(1440), date), | 118 | + plus_href: step2_href(picked.id, minutes.saturating_add(5), date), |
| 117 | presets, | 119 | presets, |
| 118 | date, | 120 | date, |
| 119 | date_label: day_label(date), | 121 | date_label: day_label(date), |
| @@ -160,9 +162,9 @@ pub struct EntryForm { | |||
| 160 | } | 162 | } |
| 161 | 163 | ||
| 162 | fn validate(form: &EntryForm) -> Result<(), AppError> { | 164 | fn validate(form: &EntryForm) -> Result<(), AppError> { |
| 163 | - if !(1..=1440).contains(&form.minutes) { | 165 | + if form.minutes < 1 { |
| 164 | return Err(AppError::BadRequest( | 166 | return Err(AppError::BadRequest( |
| 165 | - "La durée doit être comprise entre 1 et 1440 minutes.".into(), | 167 | + "La durée doit être d'au moins 1 minute.".into(), |
| 166 | )); | 168 | )); |
| 167 | } | 169 | } |
| 168 | Ok(()) | 170 | Ok(()) |
modified
templates/add_step2.html +22 -4 | @@ -17,7 +17,7 @@ | ||
| 17 | 17 | <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> |
| 18 | 18 | </a> |
| 19 | 19 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 20 | - min="1" max="1440" required value="{{ minutes }}"> | |
| 20 | + min="1" required value="{{ minutes }}"> | |
| 21 | 21 | <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> |
| 22 | 22 | <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> |
| 23 | 23 | </a> |
| @@ -39,12 +39,30 @@ | ||
| 39 | 39 | if (!h) return r + ' min'; |
| 40 | 40 | return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h'; |
| 41 | 41 | } |
| 42 | - function refresh() { pretty.textContent = dur(Math.round(+input.value) || 0); } | |
| 42 | + function refresh() { | |
| 43 | + var v = Math.round(+input.value) || 0; | |
| 44 | + pretty.textContent = dur(v); | |
| 45 | + // État sélectionné des presets suit la valeur du champ. | |
| 46 | + document.querySelectorAll('.preset[data-minutes]').forEach(function (p) { | |
| 47 | + if (+p.dataset.minutes === v) { | |
| 48 | + p.setAttribute('aria-current', 'true'); | |
| 49 | + } else { | |
| 50 | + p.removeAttribute('aria-current'); | |
| 51 | + } | |
| 52 | + }); | |
| 53 | + } | |
| 43 | 54 | document.querySelectorAll('.stepper-btn[data-step]').forEach(function (btn) { |
| 44 | 55 | btn.addEventListener('click', function (e) { |
| 45 | 56 | e.preventDefault(); |
| 46 | 57 | var v = Math.round(+input.value) || 0; |
| 47 | - input.value = Math.min(1440, Math.max(5, v + (+btn.dataset.step))); | |
| 58 | + input.value = Math.max(5, v + (+btn.dataset.step)); | |
| 59 | + refresh(); | |
| 60 | + }); | |
| 61 | + }); | |
| 62 | + document.querySelectorAll('.preset[data-minutes]').forEach(function (p) { | |
| 63 | + p.addEventListener('click', function (e) { | |
| 64 | + e.preventDefault(); | |
| 65 | + input.value = +p.dataset.minutes; | |
| 48 | 66 | refresh(); |
| 49 | 67 | }); |
| 50 | 68 | }); |
| @@ -53,7 +71,7 @@ | ||
| 53 | 71 | </script> |
| 54 | 72 | <div class="presets"> |
| 55 | 73 | {% for p in presets %} |
| 56 | - <a class="preset" href="{{ p.href }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | |
| 74 | + <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | |
| 57 | 75 | {% endfor %} |
| 58 | 76 | </div> |
| 59 | 77 | |
| @@ -17,7 +17,7 @@ | |||
| 17 | <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> | 17 | <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> |
| 18 | </a> | 18 | </a> |
| 19 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" | 19 | <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 20 | - min="1" max="1440" required value="{{ minutes }}"> | 20 | + min="1" required value="{{ minutes }}"> |
| 21 | <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> | 21 | <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus"> |
| 22 | <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> | 22 | <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> |
| 23 | </a> | 23 | </a> |
| @@ -39,12 +39,30 @@ | |||
| 39 | if (!h) return r + ' min'; | 39 | if (!h) return r + ' min'; |
| 40 | return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h'; | 40 | return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h'; |
| 41 | } | 41 | } |
| 42 | - function refresh() { pretty.textContent = dur(Math.round(+input.value) || 0); } | 42 | + function refresh() { |
| 43 | + var v = Math.round(+input.value) || 0; | ||
| 44 | + pretty.textContent = dur(v); | ||
| 45 | + // État sélectionné des presets suit la valeur du champ. | ||
| 46 | + document.querySelectorAll('.preset[data-minutes]').forEach(function (p) { | ||
| 47 | + if (+p.dataset.minutes === v) { | ||
| 48 | + p.setAttribute('aria-current', 'true'); | ||
| 49 | + } else { | ||
| 50 | + p.removeAttribute('aria-current'); | ||
| 51 | + } | ||
| 52 | + }); | ||
| 53 | + } | ||
| 43 | document.querySelectorAll('.stepper-btn[data-step]').forEach(function (btn) { | 54 | document.querySelectorAll('.stepper-btn[data-step]').forEach(function (btn) { |
| 44 | btn.addEventListener('click', function (e) { | 55 | btn.addEventListener('click', function (e) { |
| 45 | e.preventDefault(); | 56 | e.preventDefault(); |
| 46 | var v = Math.round(+input.value) || 0; | 57 | var v = Math.round(+input.value) || 0; |
| 47 | - input.value = Math.min(1440, Math.max(5, v + (+btn.dataset.step))); | 58 | + input.value = Math.max(5, v + (+btn.dataset.step)); |
| 59 | + refresh(); | ||
| 60 | + }); | ||
| 61 | + }); | ||
| 62 | + document.querySelectorAll('.preset[data-minutes]').forEach(function (p) { | ||
| 63 | + p.addEventListener('click', function (e) { | ||
| 64 | + e.preventDefault(); | ||
| 65 | + input.value = +p.dataset.minutes; | ||
| 48 | refresh(); | 66 | refresh(); |
| 49 | }); | 67 | }); |
| 50 | }); | 68 | }); |
| @@ -53,7 +71,7 @@ | |||
| 53 | </script> | 71 | </script> |
| 54 | <div class="presets"> | 72 | <div class="presets"> |
| 55 | {% for p in presets %} | 73 | {% for p in presets %} |
| 56 | - <a class="preset" href="{{ p.href }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | 74 | + <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> |
| 57 | {% endfor %} | 75 | {% endfor %} |
| 58 | </div> | 76 | </div> |
| 59 | 77 | ||
modified
templates/edit.html +1 -1 | @@ -15,7 +15,7 @@ | ||
| 15 | 15 | <div class="field" style="flex:1"> |
| 16 | 16 | <label for="minutes">Durée (min)</label> |
| 17 | 17 | <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 18 | - min="1" max="1440" required value="{{ minutes }}" style="min-height:44px"> | |
| 18 | + min="1" required value="{{ minutes }}" style="min-height:44px"> | |
| 19 | 19 | </div> |
| 20 | 20 | <div class="field" style="flex:1.4"> |
| 21 | 21 | <label for="date">Date</label> |
| @@ -15,7 +15,7 @@ | |||
| 15 | <div class="field" style="flex:1"> | 15 | <div class="field" style="flex:1"> |
| 16 | <label for="minutes">Durée (min)</label> | 16 | <label for="minutes">Durée (min)</label> |
| 17 | <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" | 17 | <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" |
| 18 | - min="1" max="1440" required value="{{ minutes }}" style="min-height:44px"> | 18 | + min="1" required value="{{ minutes }}" style="min-height:44px"> |
| 19 | </div> | 19 | </div> |
| 20 | <div class="field" style="flex:1.4"> | 20 | <div class="field" style="flex:1.4"> |
| 21 | <label for="date">Date</label> | 21 | <label for="date">Date</label> |