judu/ex-egopublic Fork
⑂ e76fa64
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: 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.
Julien Durillon committed 2026-08-06T22:56:58+02:00 Browse files
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 {
4242 pub struct Preset {
4343 pub label: &'static str,
4444 pub href: String,
45+ pub minutes: i32,
4546 pub selected: bool,
4647 }
4748
@@ -93,13 +94,14 @@ pub async fn entry_new(
9394 .tache
9495 .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived))
9596 {
96- let minutes = q.minutes.unwrap_or(30).clamp(1, 1440);
97+ let minutes = q.minutes.unwrap_or(30).max(1);
9798 let date = q.date.unwrap_or_else(paris_today).min(paris_today());
9899 let presets = [(15, "15 min"), (30, "30 min"), (45, "45 min"), (60, "1 h")]
99100 .into_iter()
100101 .map(|(m, label)| Preset {
101102 label,
102103 href: step2_href(picked.id, m, date),
104+ minutes: m,
103105 selected: minutes == m,
104106 })
105107 .collect();
@@ -111,9 +113,9 @@ pub async fn entry_new(
111113 picked_name: picked.name.clone(),
112114 minutes,
113115 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.
115117 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),
117119 presets,
118120 date,
119121 date_label: day_label(date),
@@ -160,9 +162,9 @@ pub struct EntryForm {
160162 }
161163
162164 fn validate(form: &EntryForm) -> Result<(), AppError> {
163- if !(1..=1440).contains(&form.minutes) {
165+ if form.minutes < 1 {
164166 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(),
166168 ));
167169 }
168170 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 .tache94 .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 @@
1717 <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>
1818 </a>
1919 <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 }}">
2121 <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus">
2222 <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>
2323 </a>
@@ -39,12 +39,30 @@
3939 if (!h) return r + ' min';
4040 return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
4141 }
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+ }
4354 document.querySelectorAll('.stepper-btn[data-step]').forEach(function (btn) {
4455 btn.addEventListener('click', function (e) {
4556 e.preventDefault();
4657 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;
4866 refresh();
4967 });
5068 });
@@ -53,7 +71,7 @@
5371 </script>
5472 <div class="presets">
5573 {% 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>
5775 {% endfor %}
5876 </div>
5977
@@ -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 @@
1515 <div class="field" style="flex:1">
1616 <label for="minutes">Durée (min)</label>
1717 <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">
1919 </div>
2020 <div class="field" style="flex:1.4">
2121 <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>