judu/ex-egopublic Fork
⑂ 6183065
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(timer): mesure du temps par timer — pill flottante, flux post-stop, verrous d'équipeUnverified

Julien Durillon committed 2026-08-07T22:10:08+02:00 Browse files
6183065 parent: 804247e
modified assets/style.css +49 -1
@@ -144,7 +144,52 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
144144 .app-topline .who { margin-left: auto; font-size: 12px; opacity: .6; }
145145 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }
146146
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; }
148193
149194 /* — tab bar — */
150195 .tabbar {
@@ -182,6 +227,9 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
182227 color: var(--color-text); font: inherit;
183228 }
184229 .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); }
185233 .team-choice .team-dot {
186234 width: 12px; height: 12px; border-radius: 999px; flex: none;
187235 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;
22 pub mod invitations;
33 pub mod stats;
44 pub mod tasks;
5+pub mod timers;
56 pub mod teams;
67 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> {
1616 }
1717 }
1818
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).
2034 pub struct Shell {
2135 pub title: String,
2236 pub username: String,
@@ -27,4 +41,9 @@ pub struct Shell {
2741 pub toast: Option<String>,
2842 /// Invitations en attente — badge sur l'onglet Réglages.
2943 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>,
3049 }
@@ -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;
1111 use crate::charts::{color_map, fmt_minutes};
1212 use crate::db::entries::{self, EntryRow};
1313 use crate::db::tasks::{self, TaskRow};
14+use crate::db::timers;
1415 use crate::error::AppError;
1516 use crate::period::{Week, day_label, paris_today};
1617 use crate::render::{HtmlTemplate, Shell};
@@ -30,11 +31,19 @@ pub struct PickTask {
3031 pub week_label: String,
3132 }
3233
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+
3341 #[derive(Template)]
3442 #[template(path = "add_step1.html")]
3543 pub struct AddStep1Page {
3644 pub shell: Shell,
3745 pub tasks: Vec<PickTask>,
46+ pub measured: Option<MeasuredView>,
3847 }
3948
4049 /// ─── Étape 2 : durée + date ───
@@ -74,6 +83,8 @@ pub struct NewQuery {
7483 date: Option<NaiveDate>,
7584 /// minutes de la saisie qui vient d'aboutir (toast).
7685 ok: Option<i64>,
86+ /// mesure de timer jetée (toast).
87+ annule: Option<u8>,
7788 }
7889
7990 fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String {
@@ -85,17 +96,35 @@ pub async fn entry_new(
8596 ctx: ActiveTeam,
8697 Query(q): Query<NewQuery>,
8798 ) -> 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+ };
89104 let all_tasks = tasks::list_by_creation(&state.pool, ctx.team_id).await?;
90105 let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>());
91106
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+
92112 // Étape 2 si une tâche (active) est choisie.
93113 if let Some(picked) = q
94114 .tache
95115 .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived))
96116 {
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());
99128 let presets = [
100129 (5, "5 min"),
101130 (15, "15 min"),
@@ -153,9 +182,14 @@ pub async fn entry_new(
153182 .unwrap_or_default(),
154183 })
155184 .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+ });
156189 Ok(HtmlTemplate(AddStep1Page {
157190 shell: shell(&state, &ctx, "Ajouter du temps", "add", toast).await?,
158191 tasks,
192+ measured,
159193 })
160194 .into_response())
161195 }
@@ -200,6 +234,8 @@ pub async fn entry_create(
200234 "Cette tâche n'appartient pas à l'équipe active.".into(),
201235 ));
202236 }
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?;
203239 Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes)))
204240 }
205241
@@ -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) = q113 if let Some(picked) = q
94 .tache114 .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;
44 mod statics;
55 mod stats;
66 mod teams;
7+mod timer;
78
89 use axum::Router;
910 use axum::extract::Request;
@@ -13,9 +14,10 @@ use axum::response::{Redirect, Response};
1314 use axum::routing::{get, post};
1415
1516 use crate::auth::extractor::AuthUser;
16-use crate::db::{invitations, users};
17+use crate::charts::fmt_minutes;
18+use crate::db::{invitations, timers, users};
1719 use crate::error::AppError;
18-use crate::render::Shell;
20+use crate::render::{Shell, TimerView};
1921 use crate::state::AppState;
2022 use crate::team::ActiveTeam;
2123
@@ -44,6 +46,9 @@ pub fn router(state: AppState) -> Router {
4446 "/parametres/mot-de-passe",
4547 post(settings::password_change),
4648 )
49+ .route("/timer/demarrer", post(timer::timer_start))
50+ .route("/timer/arreter", post(timer::timer_stop))
51+ .route("/timer/annuler", post(timer::timer_cancel))
4752 .route("/equipes", post(teams::team_create))
4853 .route("/equipes/basculer", post(teams::team_switch))
4954 .route("/equipes/renommer", post(teams::team_rename))
@@ -105,6 +110,33 @@ pub async fn shell(
105110 .await?
106111 .unwrap_or_default();
107112 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+ });
108140 Ok(Shell {
109141 title: title.to_string(),
110142 username,
@@ -112,5 +144,13 @@ pub async fn shell(
112144 tab,
113145 toast,
114146 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,
115155 })
116156 }
@@ -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;
77
88 use crate::auth::password;
99 use crate::charts::color_map;
10-use crate::db::{invitations, tasks, teams, users};
10+use crate::db::{invitations, tasks, teams, timers, users};
1111 use crate::error::AppError;
1212 use crate::render::{HtmlTemplate, Shell};
1313 use crate::routes::shell;
@@ -50,6 +50,9 @@ pub struct SettingsPage {
5050 pub invite_error: Option<String>,
5151 pub sent: Vec<SentView>,
5252 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,
5356 }
5457
5558 #[derive(Deserialize)]
@@ -78,6 +81,9 @@ pub async fn settings_page(
7881 Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()),
7982 Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()),
8083 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+ }
8187 _ => None,
8288 },
8389 };
@@ -115,7 +121,8 @@ pub async fn settings_page(
115121 name: t.name,
116122 })
117123 .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;
119126 let account_line = if my_teams.len() > 1 {
120127 format!(
121128 "Membre de {} équipes. Les comptes sont créés en ligne de commande.",
@@ -152,6 +159,7 @@ pub async fn settings_page(
152159 invite_error,
153160 sent,
154161 can_leave,
162+ timer_lock,
155163 }))
156164 }
157165
@@ -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;
55 use serde::Deserialize;
66 use uuid::Uuid;
77
8-use crate::db::{invitations, teams, users};
8+use crate::db::{invitations, teams, timers, users};
99 use crate::error::AppError;
1010 use crate::state::AppState;
1111 use crate::team::{ActiveTeam, clear_team_cookie, team_cookie};
@@ -14,6 +14,12 @@ fn is_unique_violation(e: &sqlx::Error) -> bool {
1414 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
1515 }
1616
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+
1723 /// Encodage minimal pour rejouer un username dans une query string.
1824 fn urlenc(s: &str) -> String {
1925 let mut out = String::with_capacity(s.len());
@@ -44,6 +50,9 @@ pub async fn team_create(
4450 if !ctx.user.check_csrf(&state, &form.csrf) {
4551 return Err(AppError::Csrf);
4652 }
53+ if timer_locked(&state, &ctx).await? {
54+ return Ok(Redirect::to("/parametres?err=timer").into_response());
55+ }
4756 let name = form.nom.trim();
4857 if name.is_empty() {
4958 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
@@ -77,6 +86,9 @@ pub async fn team_switch(
7786 if !ctx.user.check_csrf(&state, &form.csrf) {
7887 return Err(AppError::Csrf);
7988 }
89+ if timer_locked(&state, &ctx).await? {
90+ return Ok(Redirect::to("/parametres?err=timer").into_response());
91+ }
8092 if teams::membership(&state.pool, ctx.user.id, form.equipe)
8193 .await?
8294 .is_none()
@@ -123,6 +135,9 @@ pub async fn team_leave(
123135 if !ctx.user.check_csrf(&state, &form.csrf) {
124136 return Err(AppError::Csrf);
125137 }
138+ if timer_locked(&state, &ctx).await? {
139+ return Ok(Redirect::to("/parametres?err=timer").into_response());
140+ }
126141 if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 {
127142 return Ok(Redirect::to("/parametres?err=derniere").into_response());
128143 }
@@ -185,6 +200,9 @@ pub async fn invite_accept(
185200 if !ctx.user.check_csrf(&state, &form.csrf) {
186201 return Err(AppError::Csrf);
187202 }
203+ if timer_locked(&state, &ctx).await? {
204+ return Ok(Redirect::to("/parametres?err=timer").into_response());
205+ }
188206 match invitations::accept(&state.pool, id, ctx.user.id).await? {
189207 Some(team_id) => Ok((
190208 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 @@
11 {% extends "app.html" %}
22 {% 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 %}
313 {% if tasks.is_empty() %}
414 <p class="step-hint">Aucune tâche pour l'instant — commence par en créer une.</p>
515 {% 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 @@
1414 <main class="app-main">
1515 {% block content %}{% endblock %}
1616 </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 %}
1757 {% if let Some(t) = shell.toast %}<div class="toast">{{ t }}</div>{% endif %}
1858 <nav class="tabbar">
1959 <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}>
@@ -36,4 +76,23 @@
3676 <span>Réglages</span>
3777 </a>
3878 </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>
3998 {% 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 @@
99 <div style="display:flex;gap:var(--space-2)">
1010 <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex">
1111 <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>
1313 </form>
1414 <form method="post" action="{{ inv.refuse_action }}" style="display:flex">
1515 <input type="hidden" name="csrf" value="{{ csrf }}">
1616 <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button>
1717 </form>
1818 </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 %}
1920 </div>
2021 {% endfor %}
2122 </div>
@@ -34,7 +35,7 @@
3435 <form method="post" action="/equipes/basculer" style="margin:0">
3536 <input type="hidden" name="csrf" value="{{ csrf }}">
3637 <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 %}>
3839 <span class="team-dot"></span>
3940 <span style="flex:1;text-align:left">
4041 <span style="display:block;font-size:16px">{{ t.name }}</span>
@@ -45,6 +46,7 @@
4546 </form>
4647 {% endfor %}
4748 </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 %}
4850
4951 <form method="post" action="/equipes/renommer">
5052 <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -96,11 +98,15 @@
9698 <form method="post" action="/equipes">
9799 <input type="hidden" name="csrf" value="{{ csrf }}">
98100 <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>
101103 </div>
102104 </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 %}
103108 <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 %}
104110
105111 {% if can_leave %}
106112 <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">