feat(timer): alignement design v3 — minuteur plein écran sur Ajouter, lancement local, bandeaux pleine largeurUnverified
73f1cee parent: 15fbb33 modified
assets/style.css +38 -21 | @@ -150,38 +150,55 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | ||
| 150 | 150 | /* — timer — */ |
| 151 | 151 | .timer-slot { |
| 152 | 152 | position: fixed; left: 50%; transform: translateX(-50%); margin: 0; |
| 153 | + width: 100%; max-width: 430px; padding: 0 var(--space-4); | |
| 153 | 154 | bottom: calc(88px + env(safe-area-inset-bottom, 0px)); |
| 154 | 155 | display: flex; justify-content: center; pointer-events: none; z-index: 4; |
| 155 | 156 | } |
| 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; | |
| 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 | 167 | .timer-start { |
| 168 | - width: 52px; height: 52px; justify-content: center; padding: 0; | |
| 169 | 168 | 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; | |
| 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 | 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 | 176 | background: var(--color-neutral-200); |
| 181 | 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 | 203 | /* Bandeau « X min mesurées » (étape 1). */ |
| 187 | 204 | .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 { | ||
| 29 | 29 | pub fn start_date(&self) -> NaiveDate { |
| 30 | 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 | 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 | 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 | 109 | /// Supprime la mesure en attente de cette équipe — au moment de l'annuler |
| 81 | 110 | /// ou une fois transformée en entrée de temps. |
| 82 | 111 | pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { |
| @@ -128,6 +157,19 @@ mod tests { | ||
| 128 | 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 | 173 | #[test] |
| 132 | 174 | fn start_date_is_paris_day_of_start() { |
| 133 | 175 | // 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'annuler | 109 | /// 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 { | ||
| 46 | 46 | /// Où revenir après un démarrage de timer (page courante, déduite du tab). |
| 47 | 47 | pub start_return: &'static str, |
| 48 | 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 | } |
| @@ -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 { | ||
| 46 | 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 | 62 | /// ─── Étape 2 : durée + date ─── |
| 50 | 63 | |
| 51 | 64 | pub struct Preset { |
| @@ -85,6 +98,8 @@ pub struct NewQuery { | ||
| 85 | 98 | ok: Option<i64>, |
| 86 | 99 | /// mesure de timer jetée (toast). |
| 87 | 100 | annule: Option<u8>, |
| 101 | + /// minuteur annulé sans mesure (toast). | |
| 102 | + abandonne: Option<u8>, | |
| 88 | 103 | } |
| 89 | 104 | |
| 90 | 105 | fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String { |
| @@ -98,16 +113,39 @@ pub async fn entry_new( | ||
| 98 | 113 | ) -> Result<Response, AppError> { |
| 99 | 114 | let toast = if q.annule.is_some() { |
| 100 | 115 | Some("Temps mesuré annulé".to_string()) |
| 116 | + } else if q.abandonne.is_some() { | |
| 117 | + Some("Minuteur annulé".to_string()) | |
| 101 | 118 | } else { |
| 102 | 119 | q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))) |
| 103 | 120 | }; |
| 104 | 121 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; |
| 105 | 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 | 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) | |
| 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()); | |
| 111 | 149 | |
| 112 | 150 | // Étape 2 si une tâche (active) est choisie. |
| 113 | 151 | if let Some(picked) = q |
| @@ -140,9 +178,11 @@ pub async fn entry_new( | ||
| 140 | 178 | selected: minutes == m, |
| 141 | 179 | }) |
| 142 | 180 | .collect(); |
| 181 | + let mut sh = shell(&state, &ctx, "Ajouter du temps", "add", toast).await?; | |
| 182 | + sh.show_start = can_start; | |
| 143 | 183 | return Ok(HtmlTemplate(AddStep2Page { |
| 144 | 184 | csrf: ctx.user.csrf_token(&state), |
| 145 | - shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?, | |
| 185 | + shell: sh, | |
| 146 | 186 | tache: picked.id, |
| 147 | 187 | picked_color: colors[&picked.id], |
| 148 | 188 | picked_name: picked.name.clone(), |
| @@ -186,8 +226,10 @@ pub async fn entry_new( | ||
| 186 | 226 | title: format!("{} mesurées — choisis la tâche", fmt_minutes(m)), |
| 187 | 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 | 231 | Ok(HtmlTemplate(AddStep1Page { |
| 190 | - shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?, | |
| 232 | + shell: sh, | |
| 191 | 233 | tasks, |
| 192 | 234 | measured, |
| 193 | 235 | }) |
| @@ -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) = q | 151 | 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 { | ||
| 48 | 48 | ) |
| 49 | 49 | .route("/timer/demarrer", post(timer::timer_start)) |
| 50 | 50 | .route("/timer/arreter", post(timer::timer_stop)) |
| 51 | + .route("/timer/abandonner", post(timer::timer_abandon)) | |
| 51 | 52 | .route("/timer/annuler", post(timer::timer_cancel)) |
| 52 | 53 | .route("/equipes", post(teams::team_create)) |
| 53 | 54 | .route("/equipes/basculer", post(teams::team_switch)) |
| @@ -163,5 +164,6 @@ pub async fn shell( | ||
| 163 | 164 | _ => "/temps/nouveau", |
| 164 | 165 | }, |
| 165 | 166 | timer, |
| 167 | + show_start: false, | |
| 166 | 168 | }) |
| 167 | 169 | } |
| @@ -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( | ||
| 54 | 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 | 75 | /// POST /timer/annuler — jette la mesure en attente sans rien enregistrer. |
| 58 | 76 | pub async fn timer_cancel( |
| 59 | 77 | 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 @@ | ||
| 17 | 17 | {% if let Some(t) = shell.timer %} |
| 18 | 18 | {% if t.foreign %} |
| 19 | 19 | <div class="timer-slot"> |
| 20 | - <span class="timer-pill timer-foreign"> | |
| 20 | + <span class="timer-foreign"> | |
| 21 | 21 | {% if t.running %} |
| 22 | 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 | 23 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> |
| @@ -31,7 +31,7 @@ | ||
| 31 | 31 | {% else if t.running %} |
| 32 | 32 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 33 | 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 | 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 | 36 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> |
| 37 | 37 | <span class="t-note">{{ t.note }}</span> |
| @@ -39,18 +39,19 @@ | ||
| 39 | 39 | </form> |
| 40 | 40 | {% else %} |
| 41 | 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 | 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 | 44 | <span class="t-elapsed">{{ t.label }}</span> |
| 45 | 45 | </a> |
| 46 | 46 | </div> |
| 47 | 47 | {% endif %} |
| 48 | -{% else %} | |
| 48 | +{% else if shell.show_start %} | |
| 49 | 49 | <form class="timer-slot" method="post" action="/timer/demarrer"> |
| 50 | 50 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 51 | 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"> | |
| 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> | |
| 54 | 55 | </button> |
| 55 | 56 | </form> |
| 56 | 57 | {% 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 %} | ||