feat(timer): mesure du temps par timer — pill flottante, flux post-stop, verrous d'équipeUnverified
6183065 parent: 804247e modified
assets/style.css +49 -1 | @@ -144,7 +144,52 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | ||
| 144 | 144 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } |
| 145 | 145 | .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } |
| 146 | 146 | |
| 147 | -.app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; } | |
| 147 | +/* 168px : la pill timer flotte au-dessus de la tab bar, le contenu passe dessous. */ | |
| 148 | +.app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 168px; } | |
| 149 | + | |
| 150 | +/* — timer — */ | |
| 151 | +.timer-slot { | |
| 152 | + position: fixed; left: 50%; transform: translateX(-50%); margin: 0; | |
| 153 | + bottom: calc(88px + env(safe-area-inset-bottom, 0px)); | |
| 154 | + display: flex; justify-content: center; pointer-events: none; z-index: 4; | |
| 155 | +} | |
| 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; | |
| 165 | +} | |
| 166 | +.timer-pill .t-note { font-size: 12px; opacity: .85; } | |
| 167 | +.timer-start { | |
| 168 | + width: 52px; height: 52px; justify-content: center; padding: 0; | |
| 169 | + 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); | |
| 177 | +} | |
| 178 | +.timer-waiting .t-elapsed { font-size: 16px; } | |
| 179 | +.timer-foreign { | |
| 180 | + background: var(--color-neutral-200); | |
| 181 | + color: color-mix(in srgb, var(--color-text) 62%, transparent); | |
| 182 | + cursor: default; padding: 10px 18px; | |
| 183 | +} | |
| 184 | +.timer-foreign .t-note { max-width: 13em; text-align: left; line-height: 1.25; } | |
| 185 | + | |
| 186 | +/* Bandeau « X min mesurées » (étape 1). */ | |
| 187 | +.measure-card { | |
| 188 | + background: var(--color-accent-200); border-radius: var(--radius-lg); | |
| 189 | + padding: var(--space-4); margin-bottom: var(--space-4); | |
| 190 | +} | |
| 191 | +.measure-card p { margin: 0; font-size: 16px; color: var(--color-accent-900); text-wrap: pretty; } | |
| 192 | +.measure-card .warn { margin-top: var(--space-2); font-size: 13px; opacity: .75; } | |
| 148 | 193 | |
| 149 | 194 | /* — tab bar — */ |
| 150 | 195 | .tabbar { |
| @@ -182,6 +227,9 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | ||
| 182 | 227 | color: var(--color-text); font: inherit; |
| 183 | 228 | } |
| 184 | 229 | .team-choice:hover { border-color: var(--color-accent-2); } |
| 230 | +.team-choice:disabled { opacity: .55; cursor: default; } | |
| 231 | +.team-choice:disabled:hover { border-color: transparent; } | |
| 232 | +.team-choice.current:disabled:hover { border-color: var(--color-accent-2); } | |
| 185 | 233 | .team-choice .team-dot { |
| 186 | 234 | width: 12px; height: 12px; border-radius: 999px; flex: none; |
| 187 | 235 | background: var(--color-neutral-300); |
| @@ -144,7 +144,52 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | |||
| 144 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } | 144 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; } |
| 145 | .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } | 145 | .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } |
| 146 | 146 | ||
| 147 | -.app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; } | 147 | +/* 168px : la pill timer flotte au-dessus de la tab bar, le contenu passe dessous. */ |
| 148 | +.app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 168px; } | ||
| 149 | + | ||
| 150 | +/* — timer — */ | ||
| 151 | +.timer-slot { | ||
| 152 | + position: fixed; left: 50%; transform: translateX(-50%); margin: 0; | ||
| 153 | + bottom: calc(88px + env(safe-area-inset-bottom, 0px)); | ||
| 154 | + display: flex; justify-content: center; pointer-events: none; z-index: 4; | ||
| 155 | +} | ||
| 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; | ||
| 165 | +} | ||
| 166 | +.timer-pill .t-note { font-size: 12px; opacity: .85; } | ||
| 167 | +.timer-start { | ||
| 168 | + width: 52px; height: 52px; justify-content: center; padding: 0; | ||
| 169 | + 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); | ||
| 177 | +} | ||
| 178 | +.timer-waiting .t-elapsed { font-size: 16px; } | ||
| 179 | +.timer-foreign { | ||
| 180 | + background: var(--color-neutral-200); | ||
| 181 | + color: color-mix(in srgb, var(--color-text) 62%, transparent); | ||
| 182 | + cursor: default; padding: 10px 18px; | ||
| 183 | +} | ||
| 184 | +.timer-foreign .t-note { max-width: 13em; text-align: left; line-height: 1.25; } | ||
| 185 | + | ||
| 186 | +/* Bandeau « X min mesurées » (étape 1). */ | ||
| 187 | +.measure-card { | ||
| 188 | + background: var(--color-accent-200); border-radius: var(--radius-lg); | ||
| 189 | + padding: var(--space-4); margin-bottom: var(--space-4); | ||
| 190 | +} | ||
| 191 | +.measure-card p { margin: 0; font-size: 16px; color: var(--color-accent-900); text-wrap: pretty; } | ||
| 192 | +.measure-card .warn { margin-top: var(--space-2); font-size: 13px; opacity: .75; } | ||
| 148 | 193 | ||
| 149 | /* — tab bar — */ | 194 | /* — tab bar — */ |
| 150 | .tabbar { | 195 | .tabbar { |
| @@ -182,6 +227,9 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | |||
| 182 | color: var(--color-text); font: inherit; | 227 | color: var(--color-text); font: inherit; |
| 183 | } | 228 | } |
| 184 | .team-choice:hover { border-color: var(--color-accent-2); } | 229 | .team-choice:hover { border-color: var(--color-accent-2); } |
| 230 | +.team-choice:disabled { opacity: .55; cursor: default; } | ||
| 231 | +.team-choice:disabled:hover { border-color: transparent; } | ||
| 232 | +.team-choice.current:disabled:hover { border-color: var(--color-accent-2); } | ||
| 185 | .team-choice .team-dot { | 233 | .team-choice .team-dot { |
| 186 | width: 12px; height: 12px; border-radius: 999px; flex: none; | 234 | width: 12px; height: 12px; border-radius: 999px; flex: none; |
| 187 | background: var(--color-neutral-300); | 235 | background: var(--color-neutral-300); |
added
migrations/0004_timers.sql +8 -0 | new file mode 100644 | ||
| @@ -0,0 +1,8 @@ | ||
| 1 | +-- Timer : au plus une mesure par utilisateur, liée à l'équipe de départ. | |
| 2 | +-- stopped_at NULL = en cours ; renseigné = mesure en attente d'enregistrement. | |
| 3 | +CREATE TABLE timers ( | |
| 4 | + user_id UUID PRIMARY KEY REFERENCES users(id), | |
| 5 | + team_id UUID NOT NULL REFERENCES teams(id), | |
| 6 | + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 7 | + stopped_at TIMESTAMPTZ | |
| 8 | +); | |
| new file mode 100644 | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | +-- Timer : au plus une mesure par utilisateur, liée à l'équipe de départ. | ||
| 2 | +-- stopped_at NULL = en cours ; renseigné = mesure en attente d'enregistrement. | ||
| 3 | +CREATE TABLE timers ( | ||
| 4 | + user_id UUID PRIMARY KEY REFERENCES users(id), | ||
| 5 | + team_id UUID NOT NULL REFERENCES teams(id), | ||
| 6 | + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| 7 | + stopped_at TIMESTAMPTZ | ||
| 8 | +); | ||
modified
src/db/mod.rs +1 -0 | @@ -2,5 +2,6 @@ pub mod entries; | ||
| 2 | 2 | pub mod invitations; |
| 3 | 3 | pub mod stats; |
| 4 | 4 | pub mod tasks; |
| 5 | +pub mod timers; | |
| 5 | 6 | pub mod teams; |
| 6 | 7 | pub mod users; |
| @@ -2,5 +2,6 @@ pub mod entries; | |||
| 2 | pub mod invitations; | 2 | pub mod invitations; |
| 3 | pub mod stats; | 3 | pub mod stats; |
| 4 | pub mod tasks; | 4 | pub mod tasks; |
| 5 | +pub mod timers; | ||
| 5 | pub mod teams; | 6 | pub mod teams; |
| 6 | pub mod users; | 7 | pub mod users; |
added
src/db/timers.rs +137 -0 | new file mode 100644 | ||
| @@ -0,0 +1,137 @@ | ||
| 1 | +use chrono::{DateTime, NaiveDate, Utc}; | |
| 2 | +use chrono_tz::Europe::Paris; | |
| 3 | +use sqlx::PgPool; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// Le timer d'un utilisateur (au plus un) : en cours tant que `stopped_at` | |
| 7 | +/// est NULL, mesure en attente d'enregistrement ensuite. | |
| 8 | +#[derive(sqlx::FromRow)] | |
| 9 | +pub struct TimerRow { | |
| 10 | + pub team_id: Uuid, | |
| 11 | + pub team_name: String, | |
| 12 | + pub started_at: DateTime<Utc>, | |
| 13 | + pub stopped_at: Option<DateTime<Utc>>, | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl TimerRow { | |
| 17 | + /// Minutes écoulées depuis le départ, arrondies à la minute. | |
| 18 | + pub fn elapsed_minutes(&self, now: DateTime<Utc>) -> i64 { | |
| 19 | + round_minutes(now - self.started_at).max(0) | |
| 20 | + } | |
| 21 | + | |
| 22 | + /// Minutes de la mesure figée (min 1) — None si le timer court encore. | |
| 23 | + pub fn measured_minutes(&self) -> Option<i64> { | |
| 24 | + self.stopped_at | |
| 25 | + .map(|s| round_minutes(s - self.started_at).max(1)) | |
| 26 | + } | |
| 27 | + | |
| 28 | + /// Jour du démarrage en Europe/Paris — date préremplie de l'entrée. | |
| 29 | + pub fn start_date(&self) -> NaiveDate { | |
| 30 | + self.started_at.with_timezone(&Paris).date_naive() | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +fn round_minutes(d: chrono::Duration) -> i64 { | |
| 35 | + (d.num_seconds() + 30).div_euclid(60) | |
| 36 | +} | |
| 37 | + | |
| 38 | +pub async fn get(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Option<TimerRow>> { | |
| 39 | + sqlx::query_as( | |
| 40 | + "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at | |
| 41 | + FROM timers t | |
| 42 | + JOIN teams e ON e.id = t.team_id | |
| 43 | + WHERE t.user_id = $1", | |
| 44 | + ) | |
| 45 | + .bind(user_id) | |
| 46 | + .fetch_optional(pool) | |
| 47 | + .await | |
| 48 | +} | |
| 49 | + | |
| 50 | +/// Démarre un timer dans l'équipe donnée — 0 ligne si un timer existe déjà | |
| 51 | +/// ou si l'utilisateur n'en est pas membre (appartenance vérifiée en SQL). | |
| 52 | +pub async fn start(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | |
| 53 | + let res = sqlx::query( | |
| 54 | + "INSERT INTO timers (user_id, team_id) | |
| 55 | + SELECT $1, $2 FROM team_members m | |
| 56 | + WHERE m.team_id = $2 AND m.user_id = $1 | |
| 57 | + ON CONFLICT (user_id) DO NOTHING", | |
| 58 | + ) | |
| 59 | + .bind(user_id) | |
| 60 | + .bind(team_id) | |
| 61 | + .execute(pool) | |
| 62 | + .await?; | |
| 63 | + Ok(res.rows_affected()) | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Fige la mesure — seulement depuis l'équipe du timer, 0 ligne sinon | |
| 67 | +/// (timer déjà stoppé ailleurs, ou appareil sur une autre équipe). | |
| 68 | +pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | |
| 69 | + let res = sqlx::query( | |
| 70 | + "UPDATE timers SET stopped_at = now() | |
| 71 | + WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL", | |
| 72 | + ) | |
| 73 | + .bind(user_id) | |
| 74 | + .bind(team_id) | |
| 75 | + .execute(pool) | |
| 76 | + .await?; | |
| 77 | + Ok(res.rows_affected()) | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// Supprime la mesure en attente de cette équipe — au moment de l'annuler | |
| 81 | +/// 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> { | |
| 83 | + let res = sqlx::query( | |
| 84 | + "DELETE FROM timers | |
| 85 | + WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NOT NULL", | |
| 86 | + ) | |
| 87 | + .bind(user_id) | |
| 88 | + .bind(team_id) | |
| 89 | + .execute(pool) | |
| 90 | + .await?; | |
| 91 | + Ok(res.rows_affected()) | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// Vrai si un timer existe (en cours ou en attente) — verrous sur tout ce | |
| 95 | +/// qui changerait l'équipe active. | |
| 96 | +pub async fn exists(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> { | |
| 97 | + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM timers WHERE user_id = $1)") | |
| 98 | + .bind(user_id) | |
| 99 | + .fetch_one(pool) | |
| 100 | + .await | |
| 101 | +} | |
| 102 | + | |
| 103 | +#[cfg(test)] | |
| 104 | +mod tests { | |
| 105 | + use super::*; | |
| 106 | + use chrono::NaiveDateTime; | |
| 107 | + | |
| 108 | + fn row(started: &str, stopped: Option<&str>) -> TimerRow { | |
| 109 | + let parse = |s: &str| { | |
| 110 | + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") | |
| 111 | + .unwrap() | |
| 112 | + .and_utc() | |
| 113 | + }; | |
| 114 | + TimerRow { | |
| 115 | + team_id: Uuid::nil(), | |
| 116 | + team_name: String::new(), | |
| 117 | + started_at: parse(started), | |
| 118 | + stopped_at: stopped.map(parse), | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + #[test] | |
| 123 | + fn measured_rounds_to_nearest_minute_min_one() { | |
| 124 | + // 20 s → 1 min (plancher), 89 s → 1 min, 90 s → 2 min. | |
| 125 | + assert_eq!(row("2026-08-07 10:00:00", Some("2026-08-07 10:00:20")).measured_minutes(), Some(1)); | |
| 126 | + assert_eq!(row("2026-08-07 10:00:00", Some("2026-08-07 10:01:29")).measured_minutes(), Some(1)); | |
| 127 | + assert_eq!(row("2026-08-07 10:00:00", Some("2026-08-07 10:01:30")).measured_minutes(), Some(2)); | |
| 128 | + assert_eq!(row("2026-08-07 10:00:00", None).measured_minutes(), None); | |
| 129 | + } | |
| 130 | + | |
| 131 | + #[test] | |
| 132 | + fn start_date_is_paris_day_of_start() { | |
| 133 | + // 23h30 UTC un 6 août = 01h30 le 7 août à Paris (été, UTC+2). | |
| 134 | + let r = row("2026-08-06 23:30:00", None); | |
| 135 | + assert_eq!(r.start_date(), NaiveDate::from_ymd_opt(2026, 8, 7).unwrap()); | |
| 136 | + } | |
| 137 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,137 @@ | |||
| 1 | +use chrono::{DateTime, NaiveDate, Utc}; | ||
| 2 | +use chrono_tz::Europe::Paris; | ||
| 3 | +use sqlx::PgPool; | ||
| 4 | +use uuid::Uuid; | ||
| 5 | + | ||
| 6 | +/// Le timer d'un utilisateur (au plus un) : en cours tant que `stopped_at` | ||
| 7 | +/// est NULL, mesure en attente d'enregistrement ensuite. | ||
| 8 | +#[derive(sqlx::FromRow)] | ||
| 9 | +pub struct TimerRow { | ||
| 10 | + pub team_id: Uuid, | ||
| 11 | + pub team_name: String, | ||
| 12 | + pub started_at: DateTime<Utc>, | ||
| 13 | + pub stopped_at: Option<DateTime<Utc>>, | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +impl TimerRow { | ||
| 17 | + /// Minutes écoulées depuis le départ, arrondies à la minute. | ||
| 18 | + pub fn elapsed_minutes(&self, now: DateTime<Utc>) -> i64 { | ||
| 19 | + round_minutes(now - self.started_at).max(0) | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + /// Minutes de la mesure figée (min 1) — None si le timer court encore. | ||
| 23 | + pub fn measured_minutes(&self) -> Option<i64> { | ||
| 24 | + self.stopped_at | ||
| 25 | + .map(|s| round_minutes(s - self.started_at).max(1)) | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + /// Jour du démarrage en Europe/Paris — date préremplie de l'entrée. | ||
| 29 | + pub fn start_date(&self) -> NaiveDate { | ||
| 30 | + self.started_at.with_timezone(&Paris).date_naive() | ||
| 31 | + } | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +fn round_minutes(d: chrono::Duration) -> i64 { | ||
| 35 | + (d.num_seconds() + 30).div_euclid(60) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +pub async fn get(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Option<TimerRow>> { | ||
| 39 | + sqlx::query_as( | ||
| 40 | + "SELECT t.team_id, e.name AS team_name, t.started_at, t.stopped_at | ||
| 41 | + FROM timers t | ||
| 42 | + JOIN teams e ON e.id = t.team_id | ||
| 43 | + WHERE t.user_id = $1", | ||
| 44 | + ) | ||
| 45 | + .bind(user_id) | ||
| 46 | + .fetch_optional(pool) | ||
| 47 | + .await | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +/// Démarre un timer dans l'équipe donnée — 0 ligne si un timer existe déjà | ||
| 51 | +/// ou si l'utilisateur n'en est pas membre (appartenance vérifiée en SQL). | ||
| 52 | +pub async fn start(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | ||
| 53 | + let res = sqlx::query( | ||
| 54 | + "INSERT INTO timers (user_id, team_id) | ||
| 55 | + SELECT $1, $2 FROM team_members m | ||
| 56 | + WHERE m.team_id = $2 AND m.user_id = $1 | ||
| 57 | + ON CONFLICT (user_id) DO NOTHING", | ||
| 58 | + ) | ||
| 59 | + .bind(user_id) | ||
| 60 | + .bind(team_id) | ||
| 61 | + .execute(pool) | ||
| 62 | + .await?; | ||
| 63 | + Ok(res.rows_affected()) | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +/// Fige la mesure — seulement depuis l'équipe du timer, 0 ligne sinon | ||
| 67 | +/// (timer déjà stoppé ailleurs, ou appareil sur une autre équipe). | ||
| 68 | +pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | ||
| 69 | + let res = sqlx::query( | ||
| 70 | + "UPDATE timers SET stopped_at = now() | ||
| 71 | + WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL", | ||
| 72 | + ) | ||
| 73 | + .bind(user_id) | ||
| 74 | + .bind(team_id) | ||
| 75 | + .execute(pool) | ||
| 76 | + .await?; | ||
| 77 | + Ok(res.rows_affected()) | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +/// Supprime la mesure en attente de cette équipe — au moment de l'annuler | ||
| 81 | +/// 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> { | ||
| 83 | + let res = sqlx::query( | ||
| 84 | + "DELETE FROM timers | ||
| 85 | + WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NOT NULL", | ||
| 86 | + ) | ||
| 87 | + .bind(user_id) | ||
| 88 | + .bind(team_id) | ||
| 89 | + .execute(pool) | ||
| 90 | + .await?; | ||
| 91 | + Ok(res.rows_affected()) | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +/// Vrai si un timer existe (en cours ou en attente) — verrous sur tout ce | ||
| 95 | +/// qui changerait l'équipe active. | ||
| 96 | +pub async fn exists(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> { | ||
| 97 | + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM timers WHERE user_id = $1)") | ||
| 98 | + .bind(user_id) | ||
| 99 | + .fetch_one(pool) | ||
| 100 | + .await | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +#[cfg(test)] | ||
| 104 | +mod tests { | ||
| 105 | + use super::*; | ||
| 106 | + use chrono::NaiveDateTime; | ||
| 107 | + | ||
| 108 | + fn row(started: &str, stopped: Option<&str>) -> TimerRow { | ||
| 109 | + let parse = |s: &str| { | ||
| 110 | + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") | ||
| 111 | + .unwrap() | ||
| 112 | + .and_utc() | ||
| 113 | + }; | ||
| 114 | + TimerRow { | ||
| 115 | + team_id: Uuid::nil(), | ||
| 116 | + team_name: String::new(), | ||
| 117 | + started_at: parse(started), | ||
| 118 | + stopped_at: stopped.map(parse), | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + #[test] | ||
| 123 | + fn measured_rounds_to_nearest_minute_min_one() { | ||
| 124 | + // 20 s → 1 min (plancher), 89 s → 1 min, 90 s → 2 min. | ||
| 125 | + assert_eq!(row("2026-08-07 10:00:00", Some("2026-08-07 10:00:20")).measured_minutes(), Some(1)); | ||
| 126 | + assert_eq!(row("2026-08-07 10:00:00", Some("2026-08-07 10:01:29")).measured_minutes(), Some(1)); | ||
| 127 | + assert_eq!(row("2026-08-07 10:00:00", Some("2026-08-07 10:01:30")).measured_minutes(), Some(2)); | ||
| 128 | + assert_eq!(row("2026-08-07 10:00:00", None).measured_minutes(), None); | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + #[test] | ||
| 132 | + fn start_date_is_paris_day_of_start() { | ||
| 133 | + // 23h30 UTC un 6 août = 01h30 le 7 août à Paris (été, UTC+2). | ||
| 134 | + let r = row("2026-08-06 23:30:00", None); | ||
| 135 | + assert_eq!(r.start_date(), NaiveDate::from_ymd_opt(2026, 8, 7).unwrap()); | ||
| 136 | + } | ||
| 137 | +} | ||
modified
src/render.rs +20 -1 | @@ -16,7 +16,21 @@ impl<T: Template> IntoResponse for HtmlTemplate<T> { | ||
| 16 | 16 | } |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | -/// Données communes du shell applicatif (en-tête + tab bar + toast). | |
| 19 | +/// Pill timer flottante du shell — None dans Shell = repos (bouton ▶). | |
| 20 | +pub struct TimerView { | |
| 21 | + /// Timer en cours (sinon : mesure figée en attente d'enregistrement). | |
| 22 | + pub running: bool, | |
| 23 | + /// Timer d'une autre équipe que l'équipe active : pill inerte. | |
| 24 | + pub foreign: bool, | |
| 25 | + /// « 47 min » (en cours) ou « 47 min mesurées » (en attente). | |
| 26 | + pub label: String, | |
| 27 | + /// « en cours », « timer dans l'équipe X »… — vide = rien à afficher. | |
| 28 | + pub note: String, | |
| 29 | + /// Départ en epoch ms — rafraîchissement JS toutes les 10 s. | |
| 30 | + pub started_ms: i64, | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Données communes du shell applicatif (en-tête + tab bar + toast + timer). | |
| 20 | 34 | pub struct Shell { |
| 21 | 35 | pub title: String, |
| 22 | 36 | pub username: String, |
| @@ -27,4 +41,9 @@ pub struct Shell { | ||
| 27 | 41 | pub toast: Option<String>, |
| 28 | 42 | /// Invitations en attente — badge sur l'onglet Réglages. |
| 29 | 43 | pub invite_count: i64, |
| 44 | + /// Jeton des forms de la pill timer (démarrer / arrêter). | |
| 45 | + pub csrf: String, | |
| 46 | + /// Où revenir après un démarrage de timer (page courante, déduite du tab). | |
| 47 | + pub start_return: &'static str, | |
| 48 | + pub timer: Option<TimerView>, | |
| 30 | 49 | } |
| @@ -16,7 +16,21 @@ impl<T: Template> IntoResponse for HtmlTemplate<T> { | |||
| 16 | } | 16 | } |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 19 | -/// Données communes du shell applicatif (en-tête + tab bar + toast). | 19 | +/// Pill timer flottante du shell — None dans Shell = repos (bouton ▶). |
| 20 | +pub struct TimerView { | ||
| 21 | + /// Timer en cours (sinon : mesure figée en attente d'enregistrement). | ||
| 22 | + pub running: bool, | ||
| 23 | + /// Timer d'une autre équipe que l'équipe active : pill inerte. | ||
| 24 | + pub foreign: bool, | ||
| 25 | + /// « 47 min » (en cours) ou « 47 min mesurées » (en attente). | ||
| 26 | + pub label: String, | ||
| 27 | + /// « en cours », « timer dans l'équipe X »… — vide = rien à afficher. | ||
| 28 | + pub note: String, | ||
| 29 | + /// Départ en epoch ms — rafraîchissement JS toutes les 10 s. | ||
| 30 | + pub started_ms: i64, | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/// Données communes du shell applicatif (en-tête + tab bar + toast + timer). | ||
| 20 | pub struct Shell { | 34 | pub struct Shell { |
| 21 | pub title: String, | 35 | pub title: String, |
| 22 | pub username: String, | 36 | pub username: String, |
| @@ -27,4 +41,9 @@ pub struct Shell { | |||
| 27 | pub toast: Option<String>, | 41 | pub toast: Option<String>, |
| 28 | /// Invitations en attente — badge sur l'onglet Réglages. | 42 | /// Invitations en attente — badge sur l'onglet Réglages. |
| 29 | pub invite_count: i64, | 43 | pub invite_count: i64, |
| 44 | + /// Jeton des forms de la pill timer (démarrer / arrêter). | ||
| 45 | + pub csrf: String, | ||
| 46 | + /// Où revenir après un démarrage de timer (page courante, déduite du tab). | ||
| 47 | + pub start_return: &'static str, | ||
| 48 | + pub timer: Option<TimerView>, | ||
| 30 | } | 49 | } |
modified
src/routes/entries.rs +39 -3 | @@ -11,6 +11,7 @@ use uuid::Uuid; | ||
| 11 | 11 | use crate::charts::{color_map, fmt_minutes}; |
| 12 | 12 | use crate::db::entries::{self, EntryRow}; |
| 13 | 13 | use crate::db::tasks::{self, TaskRow}; |
| 14 | +use crate::db::timers; | |
| 14 | 15 | use crate::error::AppError; |
| 15 | 16 | use crate::period::{Week, day_label, paris_today}; |
| 16 | 17 | use crate::render::{HtmlTemplate, Shell}; |
| @@ -30,11 +31,19 @@ pub struct PickTask { | ||
| 30 | 31 | pub week_label: String, |
| 31 | 32 | } |
| 32 | 33 | |
| 34 | +/// Bandeau « X min mesurées » d'une mesure de timer en attente. | |
| 35 | +pub struct MeasuredView { | |
| 36 | + pub title: String, | |
| 37 | + /// Mesure > 4 h : probable timer oublié. | |
| 38 | + pub warning: bool, | |
| 39 | +} | |
| 40 | + | |
| 33 | 41 | #[derive(Template)] |
| 34 | 42 | #[template(path = "add_step1.html")] |
| 35 | 43 | pub struct AddStep1Page { |
| 36 | 44 | pub shell: Shell, |
| 37 | 45 | pub tasks: Vec<PickTask>, |
| 46 | + pub measured: Option<MeasuredView>, | |
| 38 | 47 | } |
| 39 | 48 | |
| 40 | 49 | /// ─── Étape 2 : durée + date ─── |
| @@ -74,6 +83,8 @@ pub struct NewQuery { | ||
| 74 | 83 | date: Option<NaiveDate>, |
| 75 | 84 | /// minutes de la saisie qui vient d'aboutir (toast). |
| 76 | 85 | ok: Option<i64>, |
| 86 | + /// mesure de timer jetée (toast). | |
| 87 | + annule: Option<u8>, | |
| 77 | 88 | } |
| 78 | 89 | |
| 79 | 90 | fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String { |
| @@ -85,17 +96,35 @@ pub async fn entry_new( | ||
| 85 | 96 | ctx: ActiveTeam, |
| 86 | 97 | Query(q): Query<NewQuery>, |
| 87 | 98 | ) -> Result<Response, AppError> { |
| 88 | - let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))); | |
| 99 | + let toast = if q.annule.is_some() { | |
| 100 | + Some("Temps mesuré annulé".to_string()) | |
| 101 | + } else { | |
| 102 | + q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))) | |
| 103 | + }; | |
| 89 | 104 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; |
| 90 | 105 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| 91 | 106 | |
| 107 | + // 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()); | |
| 111 | + | |
| 92 | 112 | // Étape 2 si une tâche (active) est choisie. |
| 93 | 113 | if let Some(picked) = q |
| 94 | 114 | .tache |
| 95 | 115 | .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) |
| 96 | 116 | { |
| 97 | - let minutes = q.minutes.unwrap_or(30).max(1); | |
| 98 | - let date = q.date.unwrap_or_else(paris_today).min(paris_today()); | |
| 117 | + let measured = pending.as_ref().and_then(|t| t.measured_minutes()); | |
| 118 | + let minutes = q | |
| 119 | + .minutes | |
| 120 | + .or(measured.map(|m| m.min(i32::MAX as i64) as i32)) | |
| 121 | + .unwrap_or(30) | |
| 122 | + .max(1); | |
| 123 | + let date = q | |
| 124 | + .date | |
| 125 | + .or(pending.as_ref().map(|t| t.start_date())) | |
| 126 | + .unwrap_or_else(paris_today) | |
| 127 | + .min(paris_today()); | |
| 99 | 128 | let presets = [ |
| 100 | 129 | (5, "5 min"), |
| 101 | 130 | (15, "15 min"), |
| @@ -153,9 +182,14 @@ pub async fn entry_new( | ||
| 153 | 182 | .unwrap_or_default(), |
| 154 | 183 | }) |
| 155 | 184 | .collect(); |
| 185 | + let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView { | |
| 186 | + title: format!("{} mesurées — choisis la tâche", fmt_minutes(m)), | |
| 187 | + warning: m > 240, | |
| 188 | + }); | |
| 156 | 189 | Ok(HtmlTemplate(AddStep1Page { |
| 157 | 190 | shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?, |
| 158 | 191 | tasks, |
| 192 | + measured, | |
| 159 | 193 | }) |
| 160 | 194 | .into_response()) |
| 161 | 195 | } |
| @@ -200,6 +234,8 @@ pub async fn entry_create( | ||
| 200 | 234 | "Cette tâche n'appartient pas à l'équipe active.".into(), |
| 201 | 235 | )); |
| 202 | 236 | } |
| 237 | + // Une mesure de timer en attente est consommée par l'enregistrement. | |
| 238 | + timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?; | |
| 203 | 239 | Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) |
| 204 | 240 | } |
| 205 | 241 | |
| @@ -11,6 +11,7 @@ use uuid::Uuid; | |||
| 11 | use crate::charts::{color_map, fmt_minutes}; | 11 | use crate::charts::{color_map, fmt_minutes}; |
| 12 | use crate::db::entries::{self, EntryRow}; | 12 | use crate::db::entries::{self, EntryRow}; |
| 13 | use crate::db::tasks::{self, TaskRow}; | 13 | use crate::db::tasks::{self, TaskRow}; |
| 14 | +use crate::db::timers; | ||
| 14 | use crate::error::AppError; | 15 | use crate::error::AppError; |
| 15 | use crate::period::{Week, day_label, paris_today}; | 16 | use crate::period::{Week, day_label, paris_today}; |
| 16 | use crate::render::{HtmlTemplate, Shell}; | 17 | use crate::render::{HtmlTemplate, Shell}; |
| @@ -30,11 +31,19 @@ pub struct PickTask { | |||
| 30 | pub week_label: String, | 31 | pub week_label: String, |
| 31 | } | 32 | } |
| 32 | 33 | ||
| 34 | +/// Bandeau « X min mesurées » d'une mesure de timer en attente. | ||
| 35 | +pub struct MeasuredView { | ||
| 36 | + pub title: String, | ||
| 37 | + /// Mesure > 4 h : probable timer oublié. | ||
| 38 | + pub warning: bool, | ||
| 39 | +} | ||
| 40 | + | ||
| 33 | #[derive(Template)] | 41 | #[derive(Template)] |
| 34 | #[template(path = "add_step1.html")] | 42 | #[template(path = "add_step1.html")] |
| 35 | pub struct AddStep1Page { | 43 | pub struct AddStep1Page { |
| 36 | pub shell: Shell, | 44 | pub shell: Shell, |
| 37 | pub tasks: Vec<PickTask>, | 45 | pub tasks: Vec<PickTask>, |
| 46 | + pub measured: Option<MeasuredView>, | ||
| 38 | } | 47 | } |
| 39 | 48 | ||
| 40 | /// ─── Étape 2 : durée + date ─── | 49 | /// ─── Étape 2 : durée + date ─── |
| @@ -74,6 +83,8 @@ pub struct NewQuery { | |||
| 74 | date: Option<NaiveDate>, | 83 | date: Option<NaiveDate>, |
| 75 | /// minutes de la saisie qui vient d'aboutir (toast). | 84 | /// minutes de la saisie qui vient d'aboutir (toast). |
| 76 | ok: Option<i64>, | 85 | ok: Option<i64>, |
| 86 | + /// mesure de timer jetée (toast). | ||
| 87 | + annule: Option<u8>, | ||
| 77 | } | 88 | } |
| 78 | 89 | ||
| 79 | fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String { | 90 | fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String { |
| @@ -85,17 +96,35 @@ pub async fn entry_new( | |||
| 85 | ctx: ActiveTeam, | 96 | ctx: ActiveTeam, |
| 86 | Query(q): Query<NewQuery>, | 97 | Query(q): Query<NewQuery>, |
| 87 | ) -> Result<Response, AppError> { | 98 | ) -> Result<Response, AppError> { |
| 88 | - let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))); | 99 | + let toast = if q.annule.is_some() { |
| 100 | + Some("Temps mesuré annulé".to_string()) | ||
| 101 | + } else { | ||
| 102 | + q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))) | ||
| 103 | + }; | ||
| 89 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; | 104 | let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?; |
| 90 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | 105 | let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| 91 | 106 | ||
| 107 | + // 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()); | ||
| 111 | + | ||
| 92 | // Étape 2 si une tâche (active) est choisie. | 112 | // Étape 2 si une tâche (active) est choisie. |
| 93 | if let Some(picked) = q | 113 | if let Some(picked) = q |
| 94 | .tache | 114 | .tache |
| 95 | .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) | 115 | .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) |
| 96 | { | 116 | { |
| 97 | - let minutes = q.minutes.unwrap_or(30).max(1); | 117 | + let measured = pending.as_ref().and_then(|t| t.measured_minutes()); |
| 98 | - let date = q.date.unwrap_or_else(paris_today).min(paris_today()); | 118 | + let minutes = q |
| 119 | + .minutes | ||
| 120 | + .or(measured.map(|m| m.min(i32::MAX as i64) as i32)) | ||
| 121 | + .unwrap_or(30) | ||
| 122 | + .max(1); | ||
| 123 | + let date = q | ||
| 124 | + .date | ||
| 125 | + .or(pending.as_ref().map(|t| t.start_date())) | ||
| 126 | + .unwrap_or_else(paris_today) | ||
| 127 | + .min(paris_today()); | ||
| 99 | let presets = [ | 128 | let presets = [ |
| 100 | (5, "5 min"), | 129 | (5, "5 min"), |
| 101 | (15, "15 min"), | 130 | (15, "15 min"), |
| @@ -153,9 +182,14 @@ pub async fn entry_new( | |||
| 153 | .unwrap_or_default(), | 182 | .unwrap_or_default(), |
| 154 | }) | 183 | }) |
| 155 | .collect(); | 184 | .collect(); |
| 185 | + let measured = pending.and_then(|t| t.measured_minutes()).map(|m| MeasuredView { | ||
| 186 | + title: format!("{} mesurées — choisis la tâche", fmt_minutes(m)), | ||
| 187 | + warning: m > 240, | ||
| 188 | + }); | ||
| 156 | Ok(HtmlTemplate(AddStep1Page { | 189 | Ok(HtmlTemplate(AddStep1Page { |
| 157 | shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?, | 190 | shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?, |
| 158 | tasks, | 191 | tasks, |
| 192 | + measured, | ||
| 159 | }) | 193 | }) |
| 160 | .into_response()) | 194 | .into_response()) |
| 161 | } | 195 | } |
| @@ -200,6 +234,8 @@ pub async fn entry_create( | |||
| 200 | "Cette tâche n'appartient pas à l'équipe active.".into(), | 234 | "Cette tâche n'appartient pas à l'équipe active.".into(), |
| 201 | )); | 235 | )); |
| 202 | } | 236 | } |
| 237 | + // Une mesure de timer en attente est consommée par l'enregistrement. | ||
| 238 | + timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?; | ||
| 203 | Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) | 239 | Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) |
| 204 | } | 240 | } |
| 205 | 241 | ||
modified
src/routes/mod.rs +42 -2 | @@ -4,6 +4,7 @@ mod settings; | ||
| 4 | 4 | mod statics; |
| 5 | 5 | mod stats; |
| 6 | 6 | mod teams; |
| 7 | +mod timer; | |
| 7 | 8 | |
| 8 | 9 | use axum::Router; |
| 9 | 10 | use axum::extract::Request; |
| @@ -13,9 +14,10 @@ use axum::response::{Redirect, Response}; | ||
| 13 | 14 | use axum::routing::{get, post}; |
| 14 | 15 | |
| 15 | 16 | use crate::auth::extractor::AuthUser; |
| 16 | -use crate::db::{invitations, users}; | |
| 17 | +use crate::charts::fmt_minutes; | |
| 18 | +use crate::db::{invitations, timers, users}; | |
| 17 | 19 | use crate::error::AppError; |
| 18 | -use crate::render::Shell; | |
| 20 | +use crate::render::{Shell, TimerView}; | |
| 19 | 21 | use crate::state::AppState; |
| 20 | 22 | use crate::team::ActiveTeam; |
| 21 | 23 | |
| @@ -44,6 +46,9 @@ pub fn router(state: AppState) -> Router { | ||
| 44 | 46 | "/parametres/mot-de-passe", |
| 45 | 47 | post(settings::password_change), |
| 46 | 48 | ) |
| 49 | + .route("/timer/demarrer", post(timer::timer_start)) | |
| 50 | + .route("/timer/arreter", post(timer::timer_stop)) | |
| 51 | + .route("/timer/annuler", post(timer::timer_cancel)) | |
| 47 | 52 | .route("/equipes", post(teams::team_create)) |
| 48 | 53 | .route("/equipes/basculer", post(teams::team_switch)) |
| 49 | 54 | .route("/equipes/renommer", post(teams::team_rename)) |
| @@ -105,6 +110,33 @@ pub async fn shell( | ||
| 105 | 110 | .await? |
| 106 | 111 | .unwrap_or_default(); |
| 107 | 112 | let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; |
| 113 | + let timer = timers::get(&state.pool, ctx.user.id).await?.map(|t| { | |
| 114 | + let foreign = t.team_id != ctx.team_id; | |
| 115 | + match t.measured_minutes() { | |
| 116 | + Some(m) => TimerView { | |
| 117 | + running: false, | |
| 118 | + foreign, | |
| 119 | + label: format!("{} mesurées", fmt_minutes(m)), | |
| 120 | + note: if foreign { | |
| 121 | + format!("dans l’équipe {}", t.team_name) | |
| 122 | + } else { | |
| 123 | + String::new() | |
| 124 | + }, | |
| 125 | + started_ms: t.started_at.timestamp_millis(), | |
| 126 | + }, | |
| 127 | + None => TimerView { | |
| 128 | + running: true, | |
| 129 | + foreign, | |
| 130 | + label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())), | |
| 131 | + note: if foreign { | |
| 132 | + format!("timer dans l’équipe {}", t.team_name) | |
| 133 | + } else { | |
| 134 | + "en cours".to_string() | |
| 135 | + }, | |
| 136 | + started_ms: t.started_at.timestamp_millis(), | |
| 137 | + }, | |
| 138 | + } | |
| 139 | + }); | |
| 108 | 140 | Ok(Shell { |
| 109 | 141 | title: title.to_string(), |
| 110 | 142 | username, |
| @@ -112,5 +144,13 @@ pub async fn shell( | ||
| 112 | 144 | tab, |
| 113 | 145 | toast, |
| 114 | 146 | invite_count, |
| 147 | + csrf: ctx.user.csrf_token(state), | |
| 148 | + start_return: match tab { | |
| 149 | + "stats" => "/stats", | |
| 150 | + "times" => "/temps", | |
| 151 | + "settings" => "/parametres", | |
| 152 | + _ => "/temps/nouveau", | |
| 153 | + }, | |
| 154 | + timer, | |
| 115 | 155 | }) |
| 116 | 156 | } |
| @@ -4,6 +4,7 @@ mod settings; | |||
| 4 | mod statics; | 4 | mod statics; |
| 5 | mod stats; | 5 | mod stats; |
| 6 | mod teams; | 6 | mod teams; |
| 7 | +mod timer; | ||
| 7 | 8 | ||
| 8 | use axum::Router; | 9 | use axum::Router; |
| 9 | use axum::extract::Request; | 10 | use axum::extract::Request; |
| @@ -13,9 +14,10 @@ use axum::response::{Redirect, Response}; | |||
| 13 | use axum::routing::{get, post}; | 14 | use axum::routing::{get, post}; |
| 14 | 15 | ||
| 15 | use crate::auth::extractor::AuthUser; | 16 | use crate::auth::extractor::AuthUser; |
| 16 | -use crate::db::{invitations, users}; | 17 | +use crate::charts::fmt_minutes; |
| 18 | +use crate::db::{invitations, timers, users}; | ||
| 17 | use crate::error::AppError; | 19 | use crate::error::AppError; |
| 18 | -use crate::render::Shell; | 20 | +use crate::render::{Shell, TimerView}; |
| 19 | use crate::state::AppState; | 21 | use crate::state::AppState; |
| 20 | use crate::team::ActiveTeam; | 22 | use crate::team::ActiveTeam; |
| 21 | 23 | ||
| @@ -44,6 +46,9 @@ pub fn router(state: AppState) -> Router { | |||
| 44 | "/parametres/mot-de-passe", | 46 | "/parametres/mot-de-passe", |
| 45 | post(settings::password_change), | 47 | post(settings::password_change), |
| 46 | ) | 48 | ) |
| 49 | + .route("/timer/demarrer", post(timer::timer_start)) | ||
| 50 | + .route("/timer/arreter", post(timer::timer_stop)) | ||
| 51 | + .route("/timer/annuler", post(timer::timer_cancel)) | ||
| 47 | .route("/equipes", post(teams::team_create)) | 52 | .route("/equipes", post(teams::team_create)) |
| 48 | .route("/equipes/basculer", post(teams::team_switch)) | 53 | .route("/equipes/basculer", post(teams::team_switch)) |
| 49 | .route("/equipes/renommer", post(teams::team_rename)) | 54 | .route("/equipes/renommer", post(teams::team_rename)) |
| @@ -105,6 +110,33 @@ pub async fn shell( | |||
| 105 | .await? | 110 | .await? |
| 106 | .unwrap_or_default(); | 111 | .unwrap_or_default(); |
| 107 | let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; | 112 | let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?; |
| 113 | + let timer = timers::get(&state.pool, ctx.user.id).await?.map(|t| { | ||
| 114 | + let foreign = t.team_id != ctx.team_id; | ||
| 115 | + match t.measured_minutes() { | ||
| 116 | + Some(m) => TimerView { | ||
| 117 | + running: false, | ||
| 118 | + foreign, | ||
| 119 | + label: format!("{} mesurées", fmt_minutes(m)), | ||
| 120 | + note: if foreign { | ||
| 121 | + format!("dans l’équipe {}", t.team_name) | ||
| 122 | + } else { | ||
| 123 | + String::new() | ||
| 124 | + }, | ||
| 125 | + started_ms: t.started_at.timestamp_millis(), | ||
| 126 | + }, | ||
| 127 | + None => TimerView { | ||
| 128 | + running: true, | ||
| 129 | + foreign, | ||
| 130 | + label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())), | ||
| 131 | + note: if foreign { | ||
| 132 | + format!("timer dans l’équipe {}", t.team_name) | ||
| 133 | + } else { | ||
| 134 | + "en cours".to_string() | ||
| 135 | + }, | ||
| 136 | + started_ms: t.started_at.timestamp_millis(), | ||
| 137 | + }, | ||
| 138 | + } | ||
| 139 | + }); | ||
| 108 | Ok(Shell { | 140 | Ok(Shell { |
| 109 | title: title.to_string(), | 141 | title: title.to_string(), |
| 110 | username, | 142 | username, |
| @@ -112,5 +144,13 @@ pub async fn shell( | |||
| 112 | tab, | 144 | tab, |
| 113 | toast, | 145 | toast, |
| 114 | invite_count, | 146 | invite_count, |
| 147 | + csrf: ctx.user.csrf_token(state), | ||
| 148 | + start_return: match tab { | ||
| 149 | + "stats" => "/stats", | ||
| 150 | + "times" => "/temps", | ||
| 151 | + "settings" => "/parametres", | ||
| 152 | + _ => "/temps/nouveau", | ||
| 153 | + }, | ||
| 154 | + timer, | ||
| 115 | }) | 155 | }) |
| 116 | } | 156 | } |
modified
src/routes/settings.rs +10 -2 | @@ -7,7 +7,7 @@ use uuid::Uuid; | ||
| 7 | 7 | |
| 8 | 8 | use crate::auth::password; |
| 9 | 9 | use crate::charts::color_map; |
| 10 | -use crate::db::{invitations, tasks, teams, users}; | |
| 10 | +use crate::db::{invitations, tasks, teams, timers, users}; | |
| 11 | 11 | use crate::error::AppError; |
| 12 | 12 | use crate::render::{HtmlTemplate, Shell}; |
| 13 | 13 | use crate::routes::shell; |
| @@ -50,6 +50,9 @@ pub struct SettingsPage { | ||
| 50 | 50 | pub invite_error: Option<String>, |
| 51 | 51 | pub sent: Vec<SentView>, |
| 52 | 52 | pub can_leave: bool, |
| 53 | + /// Timer en cours ou mesure en attente : bascule / création / acceptation | |
| 54 | + /// / départ d'équipe désactivés. | |
| 55 | + pub timer_lock: bool, | |
| 53 | 56 | } |
| 54 | 57 | |
| 55 | 58 | #[derive(Deserialize)] |
| @@ -78,6 +81,9 @@ pub async fn settings_page( | ||
| 78 | 81 | Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), |
| 79 | 82 | Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), |
| 80 | 83 | Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), |
| 84 | + Some("timer") => { | |
| 85 | + Some("Impossible de changer d'équipe pendant qu'un timer tourne".to_string()) | |
| 86 | + } | |
| 81 | 87 | _ => None, |
| 82 | 88 | }, |
| 83 | 89 | }; |
| @@ -115,7 +121,8 @@ pub async fn settings_page( | ||
| 115 | 121 | name: t.name, |
| 116 | 122 | }) |
| 117 | 123 | .collect(); |
| 118 | - let can_leave = my_teams.len() > 1; | |
| 124 | + let timer_lock = timers::exists(&state.pool, ctx.user.id).await?; | |
| 125 | + let can_leave = my_teams.len() > 1 && !timer_lock; | |
| 119 | 126 | let account_line = if my_teams.len() > 1 { |
| 120 | 127 | format!( |
| 121 | 128 | "Membre de {} équipes. Les comptes sont créés en ligne de commande.", |
| @@ -152,6 +159,7 @@ pub async fn settings_page( | ||
| 152 | 159 | invite_error, |
| 153 | 160 | sent, |
| 154 | 161 | can_leave, |
| 162 | + timer_lock, | |
| 155 | 163 | })) |
| 156 | 164 | } |
| 157 | 165 | |
| @@ -7,7 +7,7 @@ use uuid::Uuid; | |||
| 7 | 7 | ||
| 8 | use crate::auth::password; | 8 | use crate::auth::password; |
| 9 | use crate::charts::color_map; | 9 | use crate::charts::color_map; |
| 10 | -use crate::db::{invitations, tasks, teams, users}; | 10 | +use crate::db::{invitations, tasks, teams, timers, users}; |
| 11 | use crate::error::AppError; | 11 | use crate::error::AppError; |
| 12 | use crate::render::{HtmlTemplate, Shell}; | 12 | use crate::render::{HtmlTemplate, Shell}; |
| 13 | use crate::routes::shell; | 13 | use crate::routes::shell; |
| @@ -50,6 +50,9 @@ pub struct SettingsPage { | |||
| 50 | pub invite_error: Option<String>, | 50 | pub invite_error: Option<String>, |
| 51 | pub sent: Vec<SentView>, | 51 | pub sent: Vec<SentView>, |
| 52 | pub can_leave: bool, | 52 | pub can_leave: bool, |
| 53 | + /// Timer en cours ou mesure en attente : bascule / création / acceptation | ||
| 54 | + /// / départ d'équipe désactivés. | ||
| 55 | + pub timer_lock: bool, | ||
| 53 | } | 56 | } |
| 54 | 57 | ||
| 55 | #[derive(Deserialize)] | 58 | #[derive(Deserialize)] |
| @@ -78,6 +81,9 @@ pub async fn settings_page( | |||
| 78 | Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), | 81 | Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), |
| 79 | Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), | 82 | Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), |
| 80 | Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), | 83 | Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), |
| 84 | + Some("timer") => { | ||
| 85 | + Some("Impossible de changer d'équipe pendant qu'un timer tourne".to_string()) | ||
| 86 | + } | ||
| 81 | _ => None, | 87 | _ => None, |
| 82 | }, | 88 | }, |
| 83 | }; | 89 | }; |
| @@ -115,7 +121,8 @@ pub async fn settings_page( | |||
| 115 | name: t.name, | 121 | name: t.name, |
| 116 | }) | 122 | }) |
| 117 | .collect(); | 123 | .collect(); |
| 118 | - let can_leave = my_teams.len() > 1; | 124 | + let timer_lock = timers::exists(&state.pool, ctx.user.id).await?; |
| 125 | + let can_leave = my_teams.len() > 1 && !timer_lock; | ||
| 119 | let account_line = if my_teams.len() > 1 { | 126 | let account_line = if my_teams.len() > 1 { |
| 120 | format!( | 127 | format!( |
| 121 | "Membre de {} équipes. Les comptes sont créés en ligne de commande.", | 128 | "Membre de {} équipes. Les comptes sont créés en ligne de commande.", |
| @@ -152,6 +159,7 @@ pub async fn settings_page( | |||
| 152 | invite_error, | 159 | invite_error, |
| 153 | sent, | 160 | sent, |
| 154 | can_leave, | 161 | can_leave, |
| 162 | + timer_lock, | ||
| 155 | })) | 163 | })) |
| 156 | } | 164 | } |
| 157 | 165 | ||
modified
src/routes/teams.rs +19 -1 | @@ -5,7 +5,7 @@ use axum_extra::extract::CookieJar; | ||
| 5 | 5 | use serde::Deserialize; |
| 6 | 6 | use uuid::Uuid; |
| 7 | 7 | |
| 8 | -use crate::db::{invitations, teams, users}; | |
| 8 | +use crate::db::{invitations, teams, timers, users}; | |
| 9 | 9 | use crate::error::AppError; |
| 10 | 10 | use crate::state::AppState; |
| 11 | 11 | use crate::team::{ActiveTeam, clear_team_cookie, team_cookie}; |
| @@ -14,6 +14,12 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | ||
| 14 | 14 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | +/// Un timer (en cours ou mesure en attente) verrouille tout ce qui pourrait | |
| 18 | +/// changer l'équipe active : bascule, création, acceptation, départ. | |
| 19 | +async fn timer_locked(state: &AppState, ctx: &ActiveTeam) -> Result<bool, AppError> { | |
| 20 | + Ok(timers::exists(&state.pool, ctx.user.id).await?) | |
| 21 | +} | |
| 22 | + | |
| 17 | 23 | /// Encodage minimal pour rejouer un username dans une query string. |
| 18 | 24 | fn urlenc(s: &str) -> String { |
| 19 | 25 | let mut out = String::with_capacity(s.len()); |
| @@ -44,6 +50,9 @@ pub async fn team_create( | ||
| 44 | 50 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 45 | 51 | return Err(AppError::Csrf); |
| 46 | 52 | } |
| 53 | + if timer_locked(&state, &ctx).await? { | |
| 54 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 55 | + } | |
| 47 | 56 | let name = form.nom.trim(); |
| 48 | 57 | if name.is_empty() { |
| 49 | 58 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); |
| @@ -77,6 +86,9 @@ pub async fn team_switch( | ||
| 77 | 86 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 78 | 87 | return Err(AppError::Csrf); |
| 79 | 88 | } |
| 89 | + if timer_locked(&state, &ctx).await? { | |
| 90 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 91 | + } | |
| 80 | 92 | if teams::membership(&state.pool, ctx.user.id, form.equipe) |
| 81 | 93 | .await? |
| 82 | 94 | .is_none() |
| @@ -123,6 +135,9 @@ pub async fn team_leave( | ||
| 123 | 135 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 124 | 136 | return Err(AppError::Csrf); |
| 125 | 137 | } |
| 138 | + if timer_locked(&state, &ctx).await? { | |
| 139 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 140 | + } | |
| 126 | 141 | if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 { |
| 127 | 142 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); |
| 128 | 143 | } |
| @@ -185,6 +200,9 @@ pub async fn invite_accept( | ||
| 185 | 200 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 186 | 201 | return Err(AppError::Csrf); |
| 187 | 202 | } |
| 203 | + if timer_locked(&state, &ctx).await? { | |
| 204 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 205 | + } | |
| 188 | 206 | match invitations::accept(&state.pool, id, ctx.user.id).await? { |
| 189 | 207 | Some(team_id) => Ok(( |
| 190 | 208 | jar.add(team_cookie(team_id)), |
| @@ -5,7 +5,7 @@ use axum_extra::extract::CookieJar; | |||
| 5 | use serde::Deserialize; | 5 | use serde::Deserialize; |
| 6 | use uuid::Uuid; | 6 | use uuid::Uuid; |
| 7 | 7 | ||
| 8 | -use crate::db::{invitations, teams, users}; | 8 | +use crate::db::{invitations, teams, timers, users}; |
| 9 | use crate::error::AppError; | 9 | use crate::error::AppError; |
| 10 | use crate::state::AppState; | 10 | use crate::state::AppState; |
| 11 | use crate::team::{ActiveTeam, clear_team_cookie, team_cookie}; | 11 | use crate::team::{ActiveTeam, clear_team_cookie, team_cookie}; |
| @@ -14,6 +14,12 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | |||
| 14 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) | 14 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | +/// Un timer (en cours ou mesure en attente) verrouille tout ce qui pourrait | ||
| 18 | +/// changer l'équipe active : bascule, création, acceptation, départ. | ||
| 19 | +async fn timer_locked(state: &AppState, ctx: &ActiveTeam) -> Result<bool, AppError> { | ||
| 20 | + Ok(timers::exists(&state.pool, ctx.user.id).await?) | ||
| 21 | +} | ||
| 22 | + | ||
| 17 | /// Encodage minimal pour rejouer un username dans une query string. | 23 | /// Encodage minimal pour rejouer un username dans une query string. |
| 18 | fn urlenc(s: &str) -> String { | 24 | fn urlenc(s: &str) -> String { |
| 19 | let mut out = String::with_capacity(s.len()); | 25 | let mut out = String::with_capacity(s.len()); |
| @@ -44,6 +50,9 @@ pub async fn team_create( | |||
| 44 | if !ctx.user.check_csrf(&state, &form.csrf) { | 50 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 45 | return Err(AppError::Csrf); | 51 | return Err(AppError::Csrf); |
| 46 | } | 52 | } |
| 53 | + if timer_locked(&state, &ctx).await? { | ||
| 54 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 55 | + } | ||
| 47 | let name = form.nom.trim(); | 56 | let name = form.nom.trim(); |
| 48 | if name.is_empty() { | 57 | if name.is_empty() { |
| 49 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); | 58 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); |
| @@ -77,6 +86,9 @@ pub async fn team_switch( | |||
| 77 | if !ctx.user.check_csrf(&state, &form.csrf) { | 86 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 78 | return Err(AppError::Csrf); | 87 | return Err(AppError::Csrf); |
| 79 | } | 88 | } |
| 89 | + if timer_locked(&state, &ctx).await? { | ||
| 90 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 91 | + } | ||
| 80 | if teams::membership(&state.pool, ctx.user.id, form.equipe) | 92 | if teams::membership(&state.pool, ctx.user.id, form.equipe) |
| 81 | .await? | 93 | .await? |
| 82 | .is_none() | 94 | .is_none() |
| @@ -123,6 +135,9 @@ pub async fn team_leave( | |||
| 123 | if !ctx.user.check_csrf(&state, &form.csrf) { | 135 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 124 | return Err(AppError::Csrf); | 136 | return Err(AppError::Csrf); |
| 125 | } | 137 | } |
| 138 | + if timer_locked(&state, &ctx).await? { | ||
| 139 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 140 | + } | ||
| 126 | if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 { | 141 | if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 { |
| 127 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); | 142 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); |
| 128 | } | 143 | } |
| @@ -185,6 +200,9 @@ pub async fn invite_accept( | |||
| 185 | if !ctx.user.check_csrf(&state, &form.csrf) { | 200 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 186 | return Err(AppError::Csrf); | 201 | return Err(AppError::Csrf); |
| 187 | } | 202 | } |
| 203 | + if timer_locked(&state, &ctx).await? { | ||
| 204 | + return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 205 | + } | ||
| 188 | match invitations::accept(&state.pool, id, ctx.user.id).await? { | 206 | match invitations::accept(&state.pool, id, ctx.user.id).await? { |
| 189 | Some(team_id) => Ok(( | 207 | Some(team_id) => Ok(( |
| 190 | jar.add(team_cookie(team_id)), | 208 | jar.add(team_cookie(team_id)), |
added
src/routes/timer.rs +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +use axum::Form; | |
| 2 | +use axum::extract::State; | |
| 3 | +use axum::response::Redirect; | |
| 4 | +use serde::Deserialize; | |
| 5 | + | |
| 6 | +use crate::db::timers; | |
| 7 | +use crate::error::AppError; | |
| 8 | +use crate::state::AppState; | |
| 9 | +use crate::team::ActiveTeam; | |
| 10 | + | |
| 11 | +#[derive(Deserialize)] | |
| 12 | +pub struct StartForm { | |
| 13 | + csrf: String, | |
| 14 | + /// Page d'origine de la pill — on y revient après le démarrage. | |
| 15 | + retour: Option<String>, | |
| 16 | +} | |
| 17 | + | |
| 18 | +/// POST /timer/demarrer — la pill ▶ est présente sur toutes les pages. | |
| 19 | +/// Timer déjà existant : 0 ligne, redirection douce (la pill reflète l'état). | |
| 20 | +pub async fn timer_start( | |
| 21 | + State(state): State<AppState>, | |
| 22 | + ctx: ActiveTeam, | |
| 23 | + Form(form): Form<StartForm>, | |
| 24 | +) -> Result<Redirect, AppError> { | |
| 25 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 26 | + return Err(AppError::Csrf); | |
| 27 | + } | |
| 28 | + timers::start(&state.pool, ctx.user.id, ctx.team_id).await?; | |
| 29 | + let back = form | |
| 30 | + .retour | |
| 31 | + .as_deref() | |
| 32 | + .filter(|p| p.starts_with('/') && !p.starts_with("//")) | |
| 33 | + .unwrap_or("/temps/nouveau"); | |
| 34 | + Ok(Redirect::to(back)) | |
| 35 | +} | |
| 36 | + | |
| 37 | +#[derive(Deserialize)] | |
| 38 | +pub struct CsrfOnly { | |
| 39 | + csrf: String, | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// POST /timer/arreter — fige la mesure et ouvre le choix de la tâche. | |
| 43 | +/// Timer déjà stoppé ailleurs ou appareil sur une autre équipe : 0 ligne, | |
| 44 | +/// redirection douce vers le flux qui affichera l'état réel. | |
| 45 | +pub async fn timer_stop( | |
| 46 | + State(state): State<AppState>, | |
| 47 | + ctx: ActiveTeam, | |
| 48 | + Form(form): Form<CsrfOnly>, | |
| 49 | +) -> Result<Redirect, AppError> { | |
| 50 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 51 | + return Err(AppError::Csrf); | |
| 52 | + } | |
| 53 | + timers::stop(&state.pool, ctx.user.id, ctx.team_id).await?; | |
| 54 | + Ok(Redirect::to("/temps/nouveau")) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// POST /timer/annuler — jette la mesure en attente sans rien enregistrer. | |
| 58 | +pub async fn timer_cancel( | |
| 59 | + State(state): State<AppState>, | |
| 60 | + ctx: ActiveTeam, | |
| 61 | + Form(form): Form<CsrfOnly>, | |
| 62 | +) -> Result<Redirect, AppError> { | |
| 63 | + if !ctx.user.check_csrf(&state, &form.csrf) { | |
| 64 | + return Err(AppError::Csrf); | |
| 65 | + } | |
| 66 | + let n = timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?; | |
| 67 | + Ok(Redirect::to(if n > 0 { | |
| 68 | + "/temps/nouveau?annule=1" | |
| 69 | + } else { | |
| 70 | + "/temps/nouveau" | |
| 71 | + })) | |
| 72 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +use axum::Form; | ||
| 2 | +use axum::extract::State; | ||
| 3 | +use axum::response::Redirect; | ||
| 4 | +use serde::Deserialize; | ||
| 5 | + | ||
| 6 | +use crate::db::timers; | ||
| 7 | +use crate::error::AppError; | ||
| 8 | +use crate::state::AppState; | ||
| 9 | +use crate::team::ActiveTeam; | ||
| 10 | + | ||
| 11 | +#[derive(Deserialize)] | ||
| 12 | +pub struct StartForm { | ||
| 13 | + csrf: String, | ||
| 14 | + /// Page d'origine de la pill — on y revient après le démarrage. | ||
| 15 | + retour: Option<String>, | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +/// POST /timer/demarrer — la pill ▶ est présente sur toutes les pages. | ||
| 19 | +/// Timer déjà existant : 0 ligne, redirection douce (la pill reflète l'état). | ||
| 20 | +pub async fn timer_start( | ||
| 21 | + State(state): State<AppState>, | ||
| 22 | + ctx: ActiveTeam, | ||
| 23 | + Form(form): Form<StartForm>, | ||
| 24 | +) -> Result<Redirect, AppError> { | ||
| 25 | + if !ctx.user.check_csrf(&state, &form.csrf) { | ||
| 26 | + return Err(AppError::Csrf); | ||
| 27 | + } | ||
| 28 | + timers::start(&state.pool, ctx.user.id, ctx.team_id).await?; | ||
| 29 | + let back = form | ||
| 30 | + .retour | ||
| 31 | + .as_deref() | ||
| 32 | + .filter(|p| p.starts_with('/') && !p.starts_with("//")) | ||
| 33 | + .unwrap_or("/temps/nouveau"); | ||
| 34 | + Ok(Redirect::to(back)) | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +#[derive(Deserialize)] | ||
| 38 | +pub struct CsrfOnly { | ||
| 39 | + csrf: String, | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +/// POST /timer/arreter — fige la mesure et ouvre le choix de la tâche. | ||
| 43 | +/// Timer déjà stoppé ailleurs ou appareil sur une autre équipe : 0 ligne, | ||
| 44 | +/// redirection douce vers le flux qui affichera l'état réel. | ||
| 45 | +pub async fn timer_stop( | ||
| 46 | + State(state): State<AppState>, | ||
| 47 | + ctx: ActiveTeam, | ||
| 48 | + Form(form): Form<CsrfOnly>, | ||
| 49 | +) -> Result<Redirect, AppError> { | ||
| 50 | + if !ctx.user.check_csrf(&state, &form.csrf) { | ||
| 51 | + return Err(AppError::Csrf); | ||
| 52 | + } | ||
| 53 | + timers::stop(&state.pool, ctx.user.id, ctx.team_id).await?; | ||
| 54 | + Ok(Redirect::to("/temps/nouveau")) | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +/// POST /timer/annuler — jette la mesure en attente sans rien enregistrer. | ||
| 58 | +pub async fn timer_cancel( | ||
| 59 | + State(state): State<AppState>, | ||
| 60 | + ctx: ActiveTeam, | ||
| 61 | + Form(form): Form<CsrfOnly>, | ||
| 62 | +) -> Result<Redirect, AppError> { | ||
| 63 | + if !ctx.user.check_csrf(&state, &form.csrf) { | ||
| 64 | + return Err(AppError::Csrf); | ||
| 65 | + } | ||
| 66 | + let n = timers::discard_pending(&state.pool, ctx.user.id, ctx.team_id).await?; | ||
| 67 | + Ok(Redirect::to(if n > 0 { | ||
| 68 | + "/temps/nouveau?annule=1" | ||
| 69 | + } else { | ||
| 70 | + "/temps/nouveau" | ||
| 71 | + })) | ||
| 72 | +} | ||
modified
templates/add_step1.html +10 -0 | @@ -1,5 +1,15 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 2 | {% block content %} |
| 3 | +{% if let Some(m) = measured %} | |
| 4 | +<div class="measure-card"> | |
| 5 | + <p>{{ m.title }}</p> | |
| 6 | + {% if m.warning %}<p class="warn">Timer oublié ? Tu pourras corriger la durée à l'étape suivante.</p>{% endif %} | |
| 7 | + <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0"> | |
| 8 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | |
| 9 | + <button type="submit" class="btn btn-ghost" style="font-size:13px;padding-left:0;color:var(--color-accent-900)">Annuler ce temps</button> | |
| 10 | + </form> | |
| 11 | +</div> | |
| 12 | +{% endif %} | |
| 3 | 13 | {% if tasks.is_empty() %} |
| 4 | 14 | <p class="step-hint">Aucune tâche pour l'instant — commence par en créer une.</p> |
| 5 | 15 | {% else %} |
| @@ -1,5 +1,15 @@ | |||
| 1 | {% extends "app.html" %} | 1 | {% extends "app.html" %} |
| 2 | {% block content %} | 2 | {% block content %} |
| 3 | +{% if let Some(m) = measured %} | ||
| 4 | +<div class="measure-card"> | ||
| 5 | + <p>{{ m.title }}</p> | ||
| 6 | + {% if m.warning %}<p class="warn">Timer oublié ? Tu pourras corriger la durée à l'étape suivante.</p>{% endif %} | ||
| 7 | + <form method="post" action="/timer/annuler" style="margin:var(--space-2) 0 0"> | ||
| 8 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | ||
| 9 | + <button type="submit" class="btn btn-ghost" style="font-size:13px;padding-left:0;color:var(--color-accent-900)">Annuler ce temps</button> | ||
| 10 | + </form> | ||
| 11 | +</div> | ||
| 12 | +{% endif %} | ||
| 3 | {% if tasks.is_empty() %} | 13 | {% if tasks.is_empty() %} |
| 4 | <p class="step-hint">Aucune tâche pour l'instant — commence par en créer une.</p> | 14 | <p class="step-hint">Aucune tâche pour l'instant — commence par en créer une.</p> |
| 5 | {% else %} | 15 | {% else %} |
modified
templates/app.html +59 -0 | @@ -14,6 +14,46 @@ | ||
| 14 | 14 | <main class="app-main"> |
| 15 | 15 | {% block content %}{% endblock %} |
| 16 | 16 | </main> |
| 17 | +{% if let Some(t) = shell.timer %} | |
| 18 | + {% if t.foreign %} | |
| 19 | +<div class="timer-slot"> | |
| 20 | + <span class="timer-pill timer-foreign"> | |
| 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> | |
| 23 | + <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | |
| 24 | + {% else %} | |
| 25 | + <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> | |
| 26 | + <span class="t-elapsed">{{ t.label }}</span> | |
| 27 | + {% endif %} | |
| 28 | + <span class="t-note">{{ t.note }}</span> | |
| 29 | + </span> | |
| 30 | +</div> | |
| 31 | + {% else if t.running %} | |
| 32 | +<form class="timer-slot" method="post" action="/timer/arreter"> | |
| 33 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | |
| 34 | + <button type="submit" class="timer-pill 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> | |
| 36 | + <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | |
| 37 | + <span class="t-note">{{ t.note }}</span> | |
| 38 | + </button> | |
| 39 | +</form> | |
| 40 | + {% else %} | |
| 41 | +<div class="timer-slot"> | |
| 42 | + <a class="timer-pill 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> | |
| 44 | + <span class="t-elapsed">{{ t.label }}</span> | |
| 45 | + </a> | |
| 46 | +</div> | |
| 47 | + {% endif %} | |
| 48 | +{% else %} | |
| 49 | +<form class="timer-slot" method="post" action="/timer/demarrer"> | |
| 50 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | |
| 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> | |
| 54 | + </button> | |
| 55 | +</form> | |
| 56 | +{% endif %} | |
| 17 | 57 | {% if let Some(t) = shell.toast %}<div class="toast">{{ t }}</div>{% endif %} |
| 18 | 58 | <nav class="tabbar"> |
| 19 | 59 | <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> |
| @@ -36,4 +76,23 @@ | ||
| 36 | 76 | <span>Réglages</span> |
| 37 | 77 | </a> |
| 38 | 78 | </nav> |
| 79 | +<script> | |
| 80 | +// Amélioration progressive : le temps écoulé rendu par le serveur reste | |
| 81 | +// juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger. | |
| 82 | +(function () { | |
| 83 | + var els = document.querySelectorAll('.t-elapsed[data-started]'); | |
| 84 | + if (!els.length) return; | |
| 85 | + function fmt(m) { | |
| 86 | + var h = Math.floor(m / 60), r = m % 60; | |
| 87 | + if (!h) return m + ' min'; | |
| 88 | + return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h'; | |
| 89 | + } | |
| 90 | + setInterval(function () { | |
| 91 | + els.forEach(function (el) { | |
| 92 | + var m = Math.round((Date.now() - Number(el.dataset.started)) / 60000); | |
| 93 | + el.textContent = fmt(Math.max(0, m)); | |
| 94 | + }); | |
| 95 | + }, 10000); | |
| 96 | +})(); | |
| 97 | +</script> | |
| 39 | 98 | {% endblock %} |
| @@ -14,6 +14,46 @@ | |||
| 14 | <main class="app-main"> | 14 | <main class="app-main"> |
| 15 | {% block content %}{% endblock %} | 15 | {% block content %}{% endblock %} |
| 16 | </main> | 16 | </main> |
| 17 | +{% if let Some(t) = shell.timer %} | ||
| 18 | + {% if t.foreign %} | ||
| 19 | +<div class="timer-slot"> | ||
| 20 | + <span class="timer-pill timer-foreign"> | ||
| 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> | ||
| 23 | + <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | ||
| 24 | + {% else %} | ||
| 25 | + <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> | ||
| 26 | + <span class="t-elapsed">{{ t.label }}</span> | ||
| 27 | + {% endif %} | ||
| 28 | + <span class="t-note">{{ t.note }}</span> | ||
| 29 | + </span> | ||
| 30 | +</div> | ||
| 31 | + {% else if t.running %} | ||
| 32 | +<form class="timer-slot" method="post" action="/timer/arreter"> | ||
| 33 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | ||
| 34 | + <button type="submit" class="timer-pill 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> | ||
| 36 | + <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | ||
| 37 | + <span class="t-note">{{ t.note }}</span> | ||
| 38 | + </button> | ||
| 39 | +</form> | ||
| 40 | + {% else %} | ||
| 41 | +<div class="timer-slot"> | ||
| 42 | + <a class="timer-pill 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> | ||
| 44 | + <span class="t-elapsed">{{ t.label }}</span> | ||
| 45 | + </a> | ||
| 46 | +</div> | ||
| 47 | + {% endif %} | ||
| 48 | +{% else %} | ||
| 49 | +<form class="timer-slot" method="post" action="/timer/demarrer"> | ||
| 50 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | ||
| 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> | ||
| 54 | + </button> | ||
| 55 | +</form> | ||
| 56 | +{% endif %} | ||
| 17 | {% if let Some(t) = shell.toast %}<div class="toast">{{ t }}</div>{% endif %} | 57 | {% if let Some(t) = shell.toast %}<div class="toast">{{ t }}</div>{% endif %} |
| 18 | <nav class="tabbar"> | 58 | <nav class="tabbar"> |
| 19 | <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> | 59 | <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> |
| @@ -36,4 +76,23 @@ | |||
| 36 | <span>Réglages</span> | 76 | <span>Réglages</span> |
| 37 | </a> | 77 | </a> |
| 38 | </nav> | 78 | </nav> |
| 79 | +<script> | ||
| 80 | +// Amélioration progressive : le temps écoulé rendu par le serveur reste | ||
| 81 | +// juste sans JS, ceci le rafraîchit toutes les 10 s sans recharger. | ||
| 82 | +(function () { | ||
| 83 | + var els = document.querySelectorAll('.t-elapsed[data-started]'); | ||
| 84 | + if (!els.length) return; | ||
| 85 | + function fmt(m) { | ||
| 86 | + var h = Math.floor(m / 60), r = m % 60; | ||
| 87 | + if (!h) return m + ' min'; | ||
| 88 | + return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h'; | ||
| 89 | + } | ||
| 90 | + setInterval(function () { | ||
| 91 | + els.forEach(function (el) { | ||
| 92 | + var m = Math.round((Date.now() - Number(el.dataset.started)) / 60000); | ||
| 93 | + el.textContent = fmt(Math.max(0, m)); | ||
| 94 | + }); | ||
| 95 | + }, 10000); | ||
| 96 | +})(); | ||
| 97 | +</script> | ||
| 39 | {% endblock %} | 98 | {% endblock %} |
modified
templates/settings.html +10 -4 | @@ -9,13 +9,14 @@ | ||
| 9 | 9 | <div style="display:flex;gap:var(--space-2)"> |
| 10 | 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> |
| 11 | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button> | |
| 12 | + <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}>Accepter</button> | |
| 13 | 13 | </form> |
| 14 | 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> |
| 15 | 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 16 | 16 | <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> |
| 17 | 17 | </form> |
| 18 | 18 | </div> |
| 19 | + {% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-900);opacity:.8;margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | |
| 19 | 20 | </div> |
| 20 | 21 | {% endfor %} |
| 21 | 22 | </div> |
| @@ -34,7 +35,7 @@ | ||
| 34 | 35 | <form method="post" action="/equipes/basculer" style="margin:0"> |
| 35 | 36 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 36 | 37 | <input type="hidden" name="equipe" value="{{ t.id }}"> |
| 37 | - <button type="submit" class="team-choice{% if t.current %} current{% endif %}"> | |
| 38 | + <button type="submit" class="team-choice{% if t.current %} current{% endif %}" {% if timer_lock %}disabled{% endif %}> | |
| 38 | 39 | <span class="team-dot"></span> |
| 39 | 40 | <span style="flex:1;text-align:left"> |
| 40 | 41 | <span style="display:block;font-size:16px">{{ t.name }}</span> |
| @@ -45,6 +46,7 @@ | ||
| 45 | 46 | </form> |
| 46 | 47 | {% endfor %} |
| 47 | 48 | </div> |
| 49 | +{% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-700);margin:calc(-1 * var(--space-2)) 0 var(--space-4)">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | |
| 48 | 50 | |
| 49 | 51 | <form method="post" action="/equipes/renommer"> |
| 50 | 52 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| @@ -96,11 +98,15 @@ | ||
| 96 | 98 | <form method="post" action="/equipes"> |
| 97 | 99 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 98 | 100 | <div style="display:flex;gap:var(--space-2)"> |
| 99 | - <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px"> | |
| 100 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button> | |
| 101 | + <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}> | |
| 102 | + <button type="submit" class="btn btn-primary" style="min-height:44px" {% if timer_lock %}disabled{% endif %}>Créer</button> | |
| 101 | 103 | </div> |
| 102 | 104 | </form> |
| 105 | +{% if timer_lock %} | |
| 106 | +<p style="font-size:12px;color:var(--color-accent-700);margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p> | |
| 107 | +{% else %} | |
| 103 | 108 | <p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> |
| 109 | +{% endif %} | |
| 104 | 110 | |
| 105 | 111 | {% if can_leave %} |
| 106 | 112 | <form method="post" action="/equipes/quitter"> |
| @@ -9,13 +9,14 @@ | |||
| 9 | <div style="display:flex;gap:var(--space-2)"> | 9 | <div style="display:flex;gap:var(--space-2)"> |
| 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> | 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> |
| 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button> | 12 | + <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}>Accepter</button> |
| 13 | </form> | 13 | </form> |
| 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> | 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> |
| 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 16 | <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> | 16 | <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> |
| 17 | </form> | 17 | </form> |
| 18 | </div> | 18 | </div> |
| 19 | + {% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-900);opacity:.8;margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | ||
| 19 | </div> | 20 | </div> |
| 20 | {% endfor %} | 21 | {% endfor %} |
| 21 | </div> | 22 | </div> |
| @@ -34,7 +35,7 @@ | |||
| 34 | <form method="post" action="/equipes/basculer" style="margin:0"> | 35 | <form method="post" action="/equipes/basculer" style="margin:0"> |
| 35 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 36 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 36 | <input type="hidden" name="equipe" value="{{ t.id }}"> | 37 | <input type="hidden" name="equipe" value="{{ t.id }}"> |
| 37 | - <button type="submit" class="team-choice{% if t.current %} current{% endif %}"> | 38 | + <button type="submit" class="team-choice{% if t.current %} current{% endif %}" {% if timer_lock %}disabled{% endif %}> |
| 38 | <span class="team-dot"></span> | 39 | <span class="team-dot"></span> |
| 39 | <span style="flex:1;text-align:left"> | 40 | <span style="flex:1;text-align:left"> |
| 40 | <span style="display:block;font-size:16px">{{ t.name }}</span> | 41 | <span style="display:block;font-size:16px">{{ t.name }}</span> |
| @@ -45,6 +46,7 @@ | |||
| 45 | </form> | 46 | </form> |
| 46 | {% endfor %} | 47 | {% endfor %} |
| 47 | </div> | 48 | </div> |
| 49 | +{% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-700);margin:calc(-1 * var(--space-2)) 0 var(--space-4)">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | ||
| 48 | 50 | ||
| 49 | <form method="post" action="/equipes/renommer"> | 51 | <form method="post" action="/equipes/renommer"> |
| 50 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 52 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| @@ -96,11 +98,15 @@ | |||
| 96 | <form method="post" action="/equipes"> | 98 | <form method="post" action="/equipes"> |
| 97 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 99 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 98 | <div style="display:flex;gap:var(--space-2)"> | 100 | <div style="display:flex;gap:var(--space-2)"> |
| 99 | - <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px"> | 101 | + <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}> |
| 100 | - <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button> | 102 | + <button type="submit" class="btn btn-primary" style="min-height:44px" {% if timer_lock %}disabled{% endif %}>Créer</button> |
| 101 | </div> | 103 | </div> |
| 102 | </form> | 104 | </form> |
| 105 | +{% if timer_lock %} | ||
| 106 | +<p style="font-size:12px;color:var(--color-accent-700);margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p> | ||
| 107 | +{% else %} | ||
| 103 | <p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> | 108 | <p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> |
| 109 | +{% endif %} | ||
| 104 | 110 | ||
| 105 | {% if can_leave %} | 111 | {% if can_leave %} |
| 106 | <form method="post" action="/equipes/quitter"> | 112 | <form method="post" action="/equipes/quitter"> |