judu/ex-egopublic Fork
⑂ 73f1cee
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(timer): alignement design v3 — minuteur plein écran sur Ajouter, lancement local, bandeaux pleine largeurUnverified

Julien Durillon committed 2026-08-08T08:49:41+02:00 Browse files
73f1cee parent: 15fbb33
modified assets/style.css +38 -21
@@ -150,38 +150,55 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
150150 /* — timer — */
151151 .timer-slot {
152152 position: fixed; left: 50%; transform: translateX(-50%); margin: 0;
153+ width: 100%; max-width: 430px; padding: 0 var(--space-4);
153154 bottom: calc(88px + env(safe-area-inset-bottom, 0px));
154155 display: flex; justify-content: center; pointer-events: none; z-index: 4;
155156 }
156-.timer-pill {
157- pointer-events: auto; display: flex; align-items: center; gap: 9px;
158- border: none; font: inherit; font-size: 15px; text-decoration: none;
159- padding: 12px 20px; border-radius: 999px; cursor: pointer;
160- box-shadow: var(--shadow-md);
161-}
162-.timer-pill .t-elapsed {
163- font-family: var(--font-heading); font-weight: var(--font-heading-weight);
164- letter-spacing: -0.01em;
157+/* Bandeau pleine largeur (lancer / en cours / mesure en attente). */
158+.timer-banner {
159+ pointer-events: auto; width: 100%; display: flex; align-items: center;
160+ gap: 12px; padding: 14px 20px; border: none; border-radius: var(--radius-lg);
161+ box-shadow: var(--shadow-lg); cursor: pointer; text-decoration: none;
162+ font: inherit; font-size: 15px; font-weight: 700;
165163 }
166-.timer-pill .t-note { font-size: 12px; opacity: .85; }
164+.timer-banner .t-note { font-size: 13px; font-weight: 400; opacity: .85; }
165+.timer-running { background: var(--color-accent); color: var(--color-bg); }
166+.timer-waiting { background: var(--color-accent-200); color: var(--color-accent-900); }
167167 .timer-start {
168- width: 52px; height: 52px; justify-content: center; padding: 0;
169168 background: var(--color-surface); color: var(--color-accent-700);
170- border: 1px solid var(--color-divider);
171-}
172-.timer-running { background: var(--color-accent); color: var(--color-bg); }
173-.timer-running .t-elapsed { font-size: 17px; }
174-.timer-waiting {
175- background: var(--color-accent-200); color: var(--color-accent-900);
176- border: 1px solid var(--color-accent-300);
169+ transition: background .15s ease, color .15s ease;
177170 }
178-.timer-waiting .t-elapsed { font-size: 16px; }
171+.timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); }
172+/* Timer ou mesure d'une autre équipe : pill compacte inerte. */
179173 .timer-foreign {
174+ pointer-events: auto; display: flex; align-items: center; gap: 9px;
175+ padding: 10px 18px; border-radius: 999px; box-shadow: var(--shadow-md);
180176 background: var(--color-neutral-200);
181177 color: color-mix(in srgb, var(--color-text) 62%, transparent);
182- cursor: default; padding: 10px 18px;
178+ font-size: 15px; cursor: default;
179+}
180+.timer-foreign .t-elapsed {
181+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
182+ letter-spacing: -0.01em;
183+}
184+.timer-foreign .t-note { font-size: 12px; opacity: .85; max-width: 13em; text-align: left; line-height: 1.25; }
185+
186+/* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */
187+.chrono-card {
188+ background: var(--color-accent-200); border-radius: var(--radius-lg);
189+ padding: var(--space-6) var(--space-4); text-align: center;
190+}
191+.chrono-kicker {
192+ margin: 0 0 var(--space-2); font-size: 13px; font-weight: 700;
193+ letter-spacing: .06em; text-transform: uppercase;
194+ color: var(--color-accent-900); opacity: .7;
195+}
196+.chrono-time {
197+ margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight);
198+ font-size: 44px; line-height: 1.1; color: var(--color-accent-900);
183199 }
184-.timer-foreign .t-note { max-width: 13em; text-align: left; line-height: 1.25; }
200+.chrono-start { margin: var(--space-2) 0 0; font-size: 14px; color: var(--color-accent-900); opacity: .7; }
201+.chrono-hint { margin: var(--space-4) 0 var(--space-6); font-size: 14px; opacity: .6; text-align: center; text-wrap: pretty; }
185202
186203 /* Bandeau « X min mesurées » (étape 1). */
187204 .measure-card {
@@ -150,38 +150,55 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
150 /* — timer — */150 /* — timer — */
151 .timer-slot {151 .timer-slot {
152 position: fixed; left: 50%; transform: translateX(-50%); margin: 0;152 position: fixed; left: 50%; transform: translateX(-50%); margin: 0;
153+ width: 100%; max-width: 430px; padding: 0 var(--space-4);
153 bottom: calc(88px + env(safe-area-inset-bottom, 0px));154 bottom: calc(88px + env(safe-area-inset-bottom, 0px));
154 display: flex; justify-content: center; pointer-events: none; z-index: 4;155 display: flex; justify-content: center; pointer-events: none; z-index: 4;
155 }156 }
156-.timer-pill {157+/* Bandeau pleine largeur (lancer / en cours / mesure en attente). */
157- pointer-events: auto; display: flex; align-items: center; gap: 9px;158+.timer-banner {
158- border: none; font: inherit; font-size: 15px; text-decoration: none;159+ pointer-events: auto; width: 100%; display: flex; align-items: center;
159- padding: 12px 20px; border-radius: 999px; cursor: pointer;160+ gap: 12px; padding: 14px 20px; border: none; border-radius: var(--radius-lg);
160- box-shadow: var(--shadow-md);161+ box-shadow: var(--shadow-lg); cursor: pointer; text-decoration: none;
161-}162+ font: inherit; font-size: 15px; font-weight: 700;
162-.timer-pill .t-elapsed {
163- font-family: var(--font-heading); font-weight: var(--font-heading-weight);
164- letter-spacing: -0.01em;
165 }163 }
166-.timer-pill .t-note { font-size: 12px; opacity: .85; }164+.timer-banner .t-note { font-size: 13px; font-weight: 400; opacity: .85; }
165+.timer-running { background: var(--color-accent); color: var(--color-bg); }
166+.timer-waiting { background: var(--color-accent-200); color: var(--color-accent-900); }
167 .timer-start {167 .timer-start {
168- width: 52px; height: 52px; justify-content: center; padding: 0;
169 background: var(--color-surface); color: var(--color-accent-700);168 background: var(--color-surface); color: var(--color-accent-700);
170- border: 1px solid var(--color-divider);169+ transition: background .15s ease, color .15s ease;
171-}
172-.timer-running { background: var(--color-accent); color: var(--color-bg); }
173-.timer-running .t-elapsed { font-size: 17px; }
174-.timer-waiting {
175- background: var(--color-accent-200); color: var(--color-accent-900);
176- border: 1px solid var(--color-accent-300);
177 }170 }
178-.timer-waiting .t-elapsed { font-size: 16px; }171+.timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); }
172+/* Timer ou mesure d'une autre équipe : pill compacte inerte. */
179 .timer-foreign {173 .timer-foreign {
174+ pointer-events: auto; display: flex; align-items: center; gap: 9px;
175+ padding: 10px 18px; border-radius: 999px; box-shadow: var(--shadow-md);
180 background: var(--color-neutral-200);176 background: var(--color-neutral-200);
181 color: color-mix(in srgb, var(--color-text) 62%, transparent);177 color: color-mix(in srgb, var(--color-text) 62%, transparent);
182- cursor: default; padding: 10px 18px;178+ font-size: 15px; cursor: default;
179+}
180+.timer-foreign .t-elapsed {
181+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
182+ letter-spacing: -0.01em;
183+}
184+.timer-foreign .t-note { font-size: 12px; opacity: .85; max-width: 13em; text-align: left; line-height: 1.25; }
185+
186+/* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */
187+.chrono-card {
188+ background: var(--color-accent-200); border-radius: var(--radius-lg);
189+ padding: var(--space-6) var(--space-4); text-align: center;
190+}
191+.chrono-kicker {
192+ margin: 0 0 var(--space-2); font-size: 13px; font-weight: 700;
193+ letter-spacing: .06em; text-transform: uppercase;
194+ color: var(--color-accent-900); opacity: .7;
195+}
196+.chrono-time {
197+ margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight);
198+ font-size: 44px; line-height: 1.1; color: var(--color-accent-900);
183 }199 }
184-.timer-foreign .t-note { max-width: 13em; text-align: left; line-height: 1.25; }200+.chrono-start { margin: var(--space-2) 0 0; font-size: 14px; color: var(--color-accent-900); opacity: .7; }
201+.chrono-hint { margin: var(--space-4) 0 var(--space-6); font-size: 14px; opacity: .6; text-align: center; text-wrap: pretty; }
185 202
186 /* Bandeau « X min mesurées » (étape 1). */203 /* Bandeau « X min mesurées » (étape 1). */
187 .measure-card {204 .measure-card {
modified src/db/timers.rs +42 -0
@@ -29,6 +29,22 @@ impl TimerRow {
2929 pub fn start_date(&self) -> NaiveDate {
3030 self.started_at.with_timezone(&Paris).date_naive()
3131 }
32+
33+ /// « 47 min 23 s » / « 1 h 07 min 23 s » — temps vivant de l'écran minuteur.
34+ pub fn live_label(&self, now: DateTime<Utc>) -> String {
35+ let secs = (now - self.started_at).num_seconds().max(0);
36+ let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
37+ if h > 0 {
38+ format!("{h} h {m:02} min {s:02} s")
39+ } else {
40+ format!("{m} min {s:02} s")
41+ }
42+ }
43+
44+ /// « 14:32 » — heure de démarrage en Europe/Paris.
45+ pub fn start_clock(&self) -> String {
46+ self.started_at.with_timezone(&Paris).format("%H:%M").to_string()
47+ }
3248 }
3349
3450 fn round_minutes(d: chrono::Duration) -> i64 {
@@ -77,6 +93,19 @@ pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u
7793 Ok(res.rows_affected())
7894 }
7995
96+/// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure.
97+pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
98+ let res = sqlx::query(
99+ "DELETE FROM timers
100+ WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL",
101+ )
102+ .bind(user_id)
103+ .bind(team_id)
104+ .execute(pool)
105+ .await?;
106+ Ok(res.rows_affected())
107+}
108+
80109 /// Supprime la mesure en attente de cette équipe — au moment de l'annuler
81110 /// ou une fois transformée en entrée de temps.
82111 pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
@@ -128,6 +157,19 @@ mod tests {
128157 assert_eq!(row("2026-08-07 10:00:00", None).measured_minutes(), None);
129158 }
130159
160+ #[test]
161+ fn live_label_formats() {
162+ let r = row("2026-08-07 10:00:00", None);
163+ let at = |s: &str| {
164+ NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
165+ .unwrap()
166+ .and_utc()
167+ };
168+ assert_eq!(r.live_label(at("2026-08-07 10:47:23")), "47 min 23 s");
169+ assert_eq!(r.live_label(at("2026-08-07 11:07:05")), "1 h 07 min 05 s");
170+ assert_eq!(r.live_label(at("2026-08-07 10:00:00")), "0 min 00 s");
171+ }
172+
131173 #[test]
132174 fn start_date_is_paris_day_of_start() {
133175 // 23h30 UTC un 6 août = 01h30 le 7 août à Paris (été, UTC+2).
@@ -29,6 +29,22 @@ impl TimerRow {
29 pub fn start_date(&self) -> NaiveDate {29 pub fn start_date(&self) -> NaiveDate {
30 self.started_at.with_timezone(&Paris).date_naive()30 self.started_at.with_timezone(&Paris).date_naive()
31 }31 }
32+
33+ /// « 47 min 23 s » / « 1 h 07 min 23 s » — temps vivant de l'écran minuteur.
34+ pub fn live_label(&self, now: DateTime<Utc>) -> String {
35+ let secs = (now - self.started_at).num_seconds().max(0);
36+ let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
37+ if h > 0 {
38+ format!("{h} h {m:02} min {s:02} s")
39+ } else {
40+ format!("{m} min {s:02} s")
41+ }
42+ }
43+
44+ /// « 14:32 » — heure de démarrage en Europe/Paris.
45+ pub fn start_clock(&self) -> String {
46+ self.started_at.with_timezone(&Paris).format("%H:%M").to_string()
47+ }
32 }48 }
33 49
34 fn round_minutes(d: chrono::Duration) -> i64 {50 fn round_minutes(d: chrono::Duration) -> i64 {
@@ -77,6 +93,19 @@ pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u
77 Ok(res.rows_affected())93 Ok(res.rows_affected())
78 }94 }
79 95
96+/// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure.
97+pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
98+ let res = sqlx::query(
99+ "DELETE FROM timers
100+ WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL",
101+ )
102+ .bind(user_id)
103+ .bind(team_id)
104+ .execute(pool)
105+ .await?;
106+ Ok(res.rows_affected())
107+}
108+
80 /// Supprime la mesure en attente de cette équipe — au moment de l'annuler109 /// Supprime la mesure en attente de cette équipe — au moment de l'annuler
81 /// ou une fois transformée en entrée de temps.110 /// ou une fois transformée en entrée de temps.
82 pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {111 pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
@@ -128,6 +157,19 @@ mod tests {
128 assert_eq!(row("2026-08-07 10:00:00", None).measured_minutes(), None);157 assert_eq!(row("2026-08-07 10:00:00", None).measured_minutes(), None);
129 }158 }
130 159
160+ #[test]
161+ fn live_label_formats() {
162+ let r = row("2026-08-07 10:00:00", None);
163+ let at = |s: &str| {
164+ NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
165+ .unwrap()
166+ .and_utc()
167+ };
168+ assert_eq!(r.live_label(at("2026-08-07 10:47:23")), "47 min 23 s");
169+ assert_eq!(r.live_label(at("2026-08-07 11:07:05")), "1 h 07 min 05 s");
170+ assert_eq!(r.live_label(at("2026-08-07 10:00:00")), "0 min 00 s");
171+ }
172+
131 #[test]173 #[test]
132 fn start_date_is_paris_day_of_start() {174 fn start_date_is_paris_day_of_start() {
133 // 23h30 UTC un 6 août = 01h30 le 7 août à Paris (été, UTC+2).175 // 23h30 UTC un 6 août = 01h30 le 7 août à Paris (été, UTC+2).
modified src/render.rs +3 -0
@@ -46,4 +46,7 @@ pub struct Shell {
4646 /// Où revenir après un démarrage de timer (page courante, déduite du tab).
4747 pub start_return: &'static str,
4848 pub timer: Option<TimerView>,
49+ /// Bandeau « Lancer le minuteur » — seulement sur l'écran Ajouter,
50+ /// et seulement si aucun timer ni mesure n'existe (posé par entry_new).
51+ pub show_start: bool,
4952 }
@@ -46,4 +46,7 @@ pub struct Shell {
46 /// Où revenir après un démarrage de timer (page courante, déduite du tab).46 /// Où revenir après un démarrage de timer (page courante, déduite du tab).
47 pub start_return: &'static str,47 pub start_return: &'static str,
48 pub timer: Option<TimerView>,48 pub timer: Option<TimerView>,
49+ /// Bandeau « Lancer le minuteur » — seulement sur l'écran Ajouter,
50+ /// et seulement si aucun timer ni mesure n'existe (posé par entry_new).
51+ pub show_start: bool,
49 }52 }
modified src/routes/entries.rs +47 -5
@@ -46,6 +46,19 @@ pub struct AddStep1Page {
4646 pub measured: Option<MeasuredView>,
4747 }
4848
49+/// Écran minuteur — remplace tout l'onglet Ajouter pendant qu'un timer
50+/// de l'équipe active tourne.
51+#[derive(Template)]
52+#[template(path = "timer.html")]
53+pub struct TimerPage {
54+ pub shell: Shell,
55+ /// « 47 min 23 s » au rendu — le JS prend le relais chaque seconde.
56+ pub live_label: String,
57+ /// « 14:32 » (Europe/Paris).
58+ pub start_clock: String,
59+ pub started_ms: i64,
60+}
61+
4962 /// ─── Étape 2 : durée + date ───
5063
5164 pub struct Preset {
@@ -85,6 +98,8 @@ pub struct NewQuery {
8598 ok: Option<i64>,
8699 /// mesure de timer jetée (toast).
87100 annule: Option<u8>,
101+ /// minuteur annulé sans mesure (toast).
102+ abandonne: Option<u8>,
88103 }
89104
90105 fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String {
@@ -98,16 +113,39 @@ pub async fn entry_new(
98113 ) -> Result<Response, AppError> {
99114 let toast = if q.annule.is_some() {
100115 Some("Temps mesuré annulé".to_string())
116+ } else if q.abandonne.is_some() {
117+ Some("Minuteur annulé".to_string())
101118 } else {
102119 q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)))
103120 };
104121 let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
105122 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
106123
124+ let timer_row = timers::get(&state.pool, ctx.user.id).await?;
125+ // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et
126+ // seulement quand rien ne tourne ni n'attend (dans aucune équipe).
127+ let can_start = timer_row.is_none();
128+
129+ // Timer en cours dans l'équipe active : l'onglet Ajouter devient
130+ // l'écran minuteur, le formulaire manuel est inaccessible.
131+ if let Some(t) = timer_row
132+ .as_ref()
133+ .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_none())
134+ {
135+ let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?;
136+ // L'écran EST le minuteur : pas de bandeau flottant en doublon.
137+ sh.timer = None;
138+ return Ok(HtmlTemplate(TimerPage {
139+ shell: sh,
140+ live_label: t.live_label(chrono::Utc::now()),
141+ start_clock: t.start_clock(),
142+ started_ms: t.started_at.timestamp_millis(),
143+ })
144+ .into_response());
145+ }
146+
107147 // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2.
108- let pending = timers::get(&state.pool, ctx.user.id)
109- .await?
110- .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());
148+ let pending = timer_row.filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());
111149
112150 // Étape 2 si une tâche (active) est choisie.
113151 if let Some(picked) = q
@@ -140,9 +178,11 @@ pub async fn entry_new(
140178 selected: minutes == m,
141179 })
142180 .collect();
181+ let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?;
182+ sh.show_start = can_start;
143183 return Ok(HtmlTemplate(AddStep2Page {
144184 csrf: ctx.user.csrf_token(&state),
145- shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
185+ shell: sh,
146186 tache: picked.id,
147187 picked_color: colors[&picked.id],
148188 picked_name: picked.name.clone(),
@@ -186,8 +226,10 @@ pub async fn entry_new(
186226 title: format!("{} mesurées — choisis la tâche", fmt_minutes(m)),
187227 warning: m > 240,
188228 });
229+ let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?;
230+ sh.show_start = can_start;
189231 Ok(HtmlTemplate(AddStep1Page {
190- shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
232+ shell: sh,
191233 tasks,
192234 measured,
193235 })
@@ -46,6 +46,19 @@ pub struct AddStep1Page {
46 pub measured: Option<MeasuredView>,46 pub measured: Option<MeasuredView>,
47 }47 }
48 48
49+/// Écran minuteur — remplace tout l'onglet Ajouter pendant qu'un timer
50+/// de l'équipe active tourne.
51+#[derive(Template)]
52+#[template(path = "timer.html")]
53+pub struct TimerPage {
54+ pub shell: Shell,
55+ /// « 47 min 23 s » au rendu — le JS prend le relais chaque seconde.
56+ pub live_label: String,
57+ /// « 14:32 » (Europe/Paris).
58+ pub start_clock: String,
59+ pub started_ms: i64,
60+}
61+
49 /// ─── Étape 2 : durée + date ───62 /// ─── Étape 2 : durée + date ───
50 63
51 pub struct Preset {64 pub struct Preset {
@@ -85,6 +98,8 @@ pub struct NewQuery {
85 ok: Option<i64>,98 ok: Option<i64>,
86 /// mesure de timer jetée (toast).99 /// mesure de timer jetée (toast).
87 annule: Option<u8>,100 annule: Option<u8>,
101+ /// minuteur annulé sans mesure (toast).
102+ abandonne: Option<u8>,
88 }103 }
89 104
90 fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String {105 fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String {
@@ -98,16 +113,39 @@ pub async fn entry_new(
98 ) -> Result<Response, AppError> {113 ) -> Result<Response, AppError> {
99 let toast = if q.annule.is_some() {114 let toast = if q.annule.is_some() {
100 Some("Temps mesuré annulé".to_string())115 Some("Temps mesuré annulé".to_string())
116+ } else if q.abandonne.is_some() {
117+ Some("Minuteur annulé".to_string())
101 } else {118 } else {
102 q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)))119 q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m)))
103 };120 };
104 let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;121 let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
105 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());122 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
106 123
124+ let timer_row = timers::get(&state.pool, ctx.user.id).await?;
125+ // Le bandeau « Lancer le minuteur » n'existe que sur cet écran, et
126+ // seulement quand rien ne tourne ni n'attend (dans aucune équipe).
127+ let can_start = timer_row.is_none();
128+
129+ // Timer en cours dans l'équipe active : l'onglet Ajouter devient
130+ // l'écran minuteur, le formulaire manuel est inaccessible.
131+ if let Some(t) = timer_row
132+ .as_ref()
133+ .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_none())
134+ {
135+ let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?;
136+ // L'écran EST le minuteur : pas de bandeau flottant en doublon.
137+ sh.timer = None;
138+ return Ok(HtmlTemplate(TimerPage {
139+ shell: sh,
140+ live_label: t.live_label(chrono::Utc::now()),
141+ start_clock: t.start_clock(),
142+ started_ms: t.started_at.timestamp_millis(),
143+ })
144+ .into_response());
145+ }
146+
107 // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2.147 // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2.
108- let pending = timers::get(&state.pool, ctx.user.id)148+ let pending = timer_row.filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());
109- .await?
110- .filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());
111 149
112 // Étape 2 si une tâche (active) est choisie.150 // Étape 2 si une tâche (active) est choisie.
113 if let Some(picked) = q151 if let Some(picked) = q
@@ -140,9 +178,11 @@ pub async fn entry_new(
140 selected: minutes == m,178 selected: minutes == m,
141 })179 })
142 .collect();180 .collect();
181+ let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?;
182+ sh.show_start = can_start;
143 return Ok(HtmlTemplate(AddStep2Page {183 return Ok(HtmlTemplate(AddStep2Page {
144 csrf: ctx.user.csrf_token(&state),184 csrf: ctx.user.csrf_token(&state),
145- shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,185+ shell: sh,
146 tache: picked.id,186 tache: picked.id,
147 picked_color: colors[&picked.id],187 picked_color: colors[&picked.id],
148 picked_name: picked.name.clone(),188 picked_name: picked.name.clone(),
@@ -186,8 +226,10 @@ pub async fn entry_new(
186 title: format!("{} mesurées — choisis la tâche", fmt_minutes(m)),226 title: format!("{} mesurées — choisis la tâche", fmt_minutes(m)),
187 warning: m > 240,227 warning: m > 240,
188 });228 });
229+ let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?;
230+ sh.show_start = can_start;
189 Ok(HtmlTemplate(AddStep1Page {231 Ok(HtmlTemplate(AddStep1Page {
190- shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,232+ shell: sh,
191 tasks,233 tasks,
192 measured,234 measured,
193 })235 })
modified src/routes/mod.rs +2 -0
@@ -48,6 +48,7 @@ pub fn router(state: AppState) -> Router {
4848 )
4949 .route("/timer/demarrer", post(timer::timer_start))
5050 .route("/timer/arreter", post(timer::timer_stop))
51+ .route("/timer/abandonner", post(timer::timer_abandon))
5152 .route("/timer/annuler", post(timer::timer_cancel))
5253 .route("/equipes", post(teams::team_create))
5354 .route("/equipes/basculer", post(teams::team_switch))
@@ -163,5 +164,6 @@ pub async fn shell(
163164 _ => "/temps/nouveau",
164165 },
165166 timer,
167+ show_start: false,
166168 })
167169 }
@@ -48,6 +48,7 @@ pub fn router(state: AppState) -> Router {
48 )48 )
49 .route("/timer/demarrer", post(timer::timer_start))49 .route("/timer/demarrer", post(timer::timer_start))
50 .route("/timer/arreter", post(timer::timer_stop))50 .route("/timer/arreter", post(timer::timer_stop))
51+ .route("/timer/abandonner", post(timer::timer_abandon))
51 .route("/timer/annuler", post(timer::timer_cancel))52 .route("/timer/annuler", post(timer::timer_cancel))
52 .route("/equipes", post(teams::team_create))53 .route("/equipes", post(teams::team_create))
53 .route("/equipes/basculer", post(teams::team_switch))54 .route("/equipes/basculer", post(teams::team_switch))
@@ -163,5 +164,6 @@ pub async fn shell(
163 _ => "/temps/nouveau",164 _ => "/temps/nouveau",
164 },165 },
165 timer,166 timer,
167+ show_start: false,
166 })168 })
167 }169 }
modified src/routes/timer.rs +18 -0
@@ -54,6 +54,24 @@ pub async fn timer_stop(
5454 Ok(Redirect::to("/temps/nouveau"))
5555 }
5656
57+/// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en
58+/// cours sans créer de mesure.
59+pub async fn timer_abandon(
60+ State(state): State<AppState>,
61+ ctx: ActiveTeam,
62+ Form(form): Form<CsrfOnly>,
63+) -> Result<Redirect, AppError> {
64+ if !ctx.user.check_csrf(&state, &form.csrf) {
65+ return Err(AppError::Csrf);
66+ }
67+ let n = timers::cancel_running(&state.pool, ctx.user.id, ctx.team_id).await?;
68+ Ok(Redirect::to(if n > 0 {
69+ "/temps/nouveau?abandonne=1"
70+ } else {
71+ "/temps/nouveau"
72+ }))
73+}
74+
5775 /// POST /timer/annuler — jette la mesure en attente sans rien enregistrer.
5876 pub async fn timer_cancel(
5977 State(state): State<AppState>,
@@ -54,6 +54,24 @@ pub async fn timer_stop(
54 Ok(Redirect::to("/temps/nouveau"))54 Ok(Redirect::to("/temps/nouveau"))
55 }55 }
56 56
57+/// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en
58+/// cours sans créer de mesure.
59+pub async fn timer_abandon(
60+ State(state): State<AppState>,
61+ ctx: ActiveTeam,
62+ Form(form): Form<CsrfOnly>,
63+) -> Result<Redirect, AppError> {
64+ if !ctx.user.check_csrf(&state, &form.csrf) {
65+ return Err(AppError::Csrf);
66+ }
67+ let n = timers::cancel_running(&state.pool, ctx.user.id, ctx.team_id).await?;
68+ Ok(Redirect::to(if n > 0 {
69+ "/temps/nouveau?abandonne=1"
70+ } else {
71+ "/temps/nouveau"
72+ }))
73+}
74+
57 /// POST /timer/annuler — jette la mesure en attente sans rien enregistrer.75 /// POST /timer/annuler — jette la mesure en attente sans rien enregistrer.
58 pub async fn timer_cancel(76 pub async fn timer_cancel(
59 State(state): State<AppState>,77 State(state): State<AppState>,
modified templates/app.html +7 -6
@@ -17,7 +17,7 @@
1717 {% if let Some(t) = shell.timer %}
1818 {% if t.foreign %}
1919 <div class="timer-slot">
20- <span class="timer-pill timer-foreign">
20+ <span class="timer-foreign">
2121 {% if t.running %}
2222 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg>
2323 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
@@ -31,7 +31,7 @@
3131 {% else if t.running %}
3232 <form class="timer-slot" method="post" action="/timer/arreter">
3333 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
34- <button type="submit" class="timer-pill timer-running" aria-label="Arrêter le timer">
34+ <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer">
3535 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg>
3636 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
3737 <span class="t-note">{{ t.note }}</span>
@@ -39,18 +39,19 @@
3939 </form>
4040 {% else %}
4141 <div class="timer-slot">
42- <a class="timer-pill timer-waiting" href="/temps/nouveau" aria-label="Enregistrer le temps mesuré">
42+ <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="Enregistrer le temps mesuré">
4343 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg>
4444 <span class="t-elapsed">{{ t.label }}</span>
4545 </a>
4646 </div>
4747 {% endif %}
48-{% else %}
48+{% else if shell.show_start %}
4949 <form class="timer-slot" method="post" action="/timer/demarrer">
5050 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
5151 <input type="hidden" name="retour" value="{{ shell.start_return }}">
52- <button type="submit" class="timer-pill timer-start" aria-label="Démarrer le timer">
53- <svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 5.4v13.2L19 12z"></path></svg>
52+ <button type="submit" class="timer-banner timer-start" aria-label="Démarrer le timer">
53+ <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M10.2 8.6l5.6 3.4-5.6 3.4z"></path></svg>
54+ <span>Lancer le minuteur</span>
5455 </button>
5556 </form>
5657 {% endif %}
@@ -17,7 +17,7 @@
17 {% if let Some(t) = shell.timer %}17 {% if let Some(t) = shell.timer %}
18 {% if t.foreign %}18 {% if t.foreign %}
19 <div class="timer-slot">19 <div class="timer-slot">
20- <span class="timer-pill timer-foreign">20+ <span class="timer-foreign">
21 {% if t.running %}21 {% if t.running %}
22 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg>22 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg>
23 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>23 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
@@ -31,7 +31,7 @@
31 {% else if t.running %}31 {% else if t.running %}
32 <form class="timer-slot" method="post" action="/timer/arreter">32 <form class="timer-slot" method="post" action="/timer/arreter">
33 <input type="hidden" name="csrf" value="{{ shell.csrf }}">33 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
34- <button type="submit" class="timer-pill timer-running" aria-label="Arrêter le timer">34+ <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer">
35 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg>35 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg>
36 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>36 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
37 <span class="t-note">{{ t.note }}</span>37 <span class="t-note">{{ t.note }}</span>
@@ -39,18 +39,19 @@
39 </form>39 </form>
40 {% else %}40 {% else %}
41 <div class="timer-slot">41 <div class="timer-slot">
42- <a class="timer-pill timer-waiting" href="/temps/nouveau" aria-label="Enregistrer le temps mesuré">42+ <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="Enregistrer le temps mesuré">
43 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg>43 <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg>
44 <span class="t-elapsed">{{ t.label }}</span>44 <span class="t-elapsed">{{ t.label }}</span>
45 </a>45 </a>
46 </div>46 </div>
47 {% endif %}47 {% endif %}
48-{% else %}48+{% else if shell.show_start %}
49 <form class="timer-slot" method="post" action="/timer/demarrer">49 <form class="timer-slot" method="post" action="/timer/demarrer">
50 <input type="hidden" name="csrf" value="{{ shell.csrf }}">50 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
51 <input type="hidden" name="retour" value="{{ shell.start_return }}">51 <input type="hidden" name="retour" value="{{ shell.start_return }}">
52- <button type="submit" class="timer-pill timer-start" aria-label="Démarrer le timer">52+ <button type="submit" class="timer-banner timer-start" aria-label="Démarrer le timer">
53- <svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor"><path d="M8.5 5.4v13.2L19 12z"></path></svg>53+ <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M10.2 8.6l5.6 3.4-5.6 3.4z"></path></svg>
54+ <span>Lancer le minuteur</span>
54 </button>55 </button>
55 </form>56 </form>
56 {% endif %}57 {% endif %}
added templates/timer.html +32 -0
new file mode 100644
@@ -0,0 +1,32 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<div class="chrono-card">
4+ <p class="chrono-kicker">Minuteur en cours</p>
5+ <p class="chrono-time" data-started="{{ started_ms }}">{{ live_label }}</p>
6+ <p class="chrono-start">Démarré à {{ start_clock }}</p>
7+</div>
8+<p class="chrono-hint">Termine le minuteur pour choisir la tâche et enregistrer ce temps.</p>
9+<div style="display:flex;flex-direction:column;gap:var(--space-3)">
10+ <form method="post" action="/timer/arreter" style="margin:0;display:flex">
11+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
12+ <button type="submit" class="btn btn-primary btn-block" style="flex:1;min-height:52px">Finir le minuteur</button>
13+ </form>
14+ <form method="post" action="/timer/abandonner" style="margin:0;display:flex">
15+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
16+ <button type="submit" class="btn btn-secondary btn-block" style="flex:1;min-height:48px">Annuler le minuteur</button>
17+ </form>
18+</div>
19+<script>
20+// Temps vivant à la seconde — le rendu serveur reste juste sans JS.
21+(function () {
22+ var el = document.querySelector('.chrono-time[data-started]');
23+ if (!el) return;
24+ function pad(n) { return n < 10 ? '0' + n : '' + n; }
25+ setInterval(function () {
26+ var s = Math.max(0, Math.floor((Date.now() - Number(el.dataset.started)) / 1000));
27+ var h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, sec = s % 60;
28+ el.textContent = (h ? h + ' h ' + pad(m) : m) + ' min ' + pad(sec) + ' s';
29+ }, 1000);
30+})();
31+</script>
32+{% endblock %}
new file mode 100644
@@ -0,0 +1,32 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<div class="chrono-card">
4+ <p class="chrono-kicker">Minuteur en cours</p>
5+ <p class="chrono-time" data-started="{{ started_ms }}">{{ live_label }}</p>
6+ <p class="chrono-start">Démarré à {{ start_clock }}</p>
7+</div>
8+<p class="chrono-hint">Termine le minuteur pour choisir la tâche et enregistrer ce temps.</p>
9+<div style="display:flex;flex-direction:column;gap:var(--space-3)">
10+ <form method="post" action="/timer/arreter" style="margin:0;display:flex">
11+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
12+ <button type="submit" class="btn btn-primary btn-block" style="flex:1;min-height:52px">Finir le minuteur</button>
13+ </form>
14+ <form method="post" action="/timer/abandonner" style="margin:0;display:flex">
15+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
16+ <button type="submit" class="btn btn-secondary btn-block" style="flex:1;min-height:48px">Annuler le minuteur</button>
17+ </form>
18+</div>
19+<script>
20+// Temps vivant à la seconde — le rendu serveur reste juste sans JS.
21+(function () {
22+ var el = document.querySelector('.chrono-time[data-started]');
23+ if (!el) return;
24+ function pad(n) { return n < 10 ? '0' + n : '' + n; }
25+ setInterval(function () {
26+ var s = Math.max(0, Math.floor((Date.now() - Number(el.dataset.started)) / 1000));
27+ var h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, sec = s % 60;
28+ el.textContent = (h ? h + ' h ' + pad(m) : m) + ' min ' + pad(sec) + ' s';
29+ }, 1000);
30+})();
31+</script>
32+{% endblock %}