judu/ex-egopublic Fork
⑂ 0c22849
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): changement d'équipe libre pendant un timer — bandeau « dans l'équipe X », stop = bascule vers l'équipe du timerUnverified

Julien Durillon committed 2026-08-08T09:47:36+02:00 Browse files
0c22849 parent: ea1fd01
modified assets/style.css +0 -13
@@ -187,19 +187,6 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
187187 transition: background .15s ease, color .15s ease;
188188 }
189189 .timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); }
190-/* Timer ou mesure d'une autre équipe : pill compacte inerte. */
191-.timer-foreign {
192- pointer-events: auto; display: flex; align-items: center; gap: 9px;
193- padding: 10px 18px; border-radius: 999px; box-shadow: var(--shadow-md);
194- background: var(--color-neutral-200);
195- color: color-mix(in srgb, var(--color-text) 62%, transparent);
196- font-size: 15px; cursor: default;
197-}
198-.timer-foreign .t-elapsed {
199- font-family: var(--font-heading); font-weight: var(--font-heading-weight);
200- letter-spacing: -0.01em;
201-}
202-.timer-foreign .t-note { font-size: 12px; opacity: .85; max-width: 13em; text-align: left; line-height: 1.25; }
203190
204191 /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */
205192 .chrono-card {
@@ -187,19 +187,6 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; }
187 transition: background .15s ease, color .15s ease;187 transition: background .15s ease, color .15s ease;
188 }188 }
189 .timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); }189 .timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); }
190-/* Timer ou mesure d'une autre équipe : pill compacte inerte. */
191-.timer-foreign {
192- pointer-events: auto; display: flex; align-items: center; gap: 9px;
193- padding: 10px 18px; border-radius: 999px; box-shadow: var(--shadow-md);
194- background: var(--color-neutral-200);
195- color: color-mix(in srgb, var(--color-text) 62%, transparent);
196- font-size: 15px; cursor: default;
197-}
198-.timer-foreign .t-elapsed {
199- font-family: var(--font-heading); font-weight: var(--font-heading-weight);
200- letter-spacing: -0.01em;
201-}
202-.timer-foreign .t-note { font-size: 12px; opacity: .85; max-width: 13em; text-align: left; line-height: 1.25; }
203 190
204 /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */191 /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */
205 .chrono-card {192 .chrono-card {
modified src/db/timers.rs +15 -14
@@ -79,20 +79,30 @@ pub async fn start(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<
7979 Ok(res.rows_affected())
8080 }
8181
82-/// Fige la mesure — seulement depuis l'équipe du timer, 0 ligne sinon
83-/// (timer déjà stoppé ailleurs, ou appareil sur une autre équipe).
84-pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
82+/// Fige la mesure, quelle que soit l'équipe active de l'appareil — 0 ligne
83+/// si le timer est déjà stoppé (ou inexistant).
84+pub async fn stop(pool: &PgPool, user_id: Uuid) -> sqlx::Result<u64> {
8585 let res = sqlx::query(
8686 "UPDATE timers SET stopped_at = now()
87- WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL",
87+ WHERE user_id = $1 AND stopped_at IS NULL",
8888 )
8989 .bind(user_id)
90- .bind(team_id)
9190 .execute(pool)
9291 .await?;
9392 Ok(res.rows_affected())
9493 }
9594
95+/// Nettoyage au départ d'une équipe : un timer ou une mesure y devient
96+/// inutilisable (plus membre), on le jette.
97+pub async fn discard_in_team(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
98+ let res = sqlx::query("DELETE FROM timers WHERE user_id = $1 AND team_id = $2")
99+ .bind(user_id)
100+ .bind(team_id)
101+ .execute(pool)
102+ .await?;
103+ Ok(res.rows_affected())
104+}
105+
96106 /// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure.
97107 pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
98108 let res = sqlx::query(
@@ -120,15 +130,6 @@ pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sql
120130 Ok(res.rows_affected())
121131 }
122132
123-/// Vrai si un timer existe (en cours ou en attente) — verrous sur tout ce
124-/// qui changerait l'équipe active.
125-pub async fn exists(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> {
126- sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM timers WHERE user_id = $1)")
127- .bind(user_id)
128- .fetch_one(pool)
129- .await
130-}
131-
132133 #[cfg(test)]
133134 mod tests {
134135 use super::*;
@@ -79,20 +79,30 @@ pub async fn start(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<
79 Ok(res.rows_affected())79 Ok(res.rows_affected())
80 }80 }
81 81
82-/// Fige la mesure — seulement depuis l'équipe du timer, 0 ligne sinon82+/// Fige la mesure, quelle que soit l'équipe active de l'appareil — 0 ligne
83-/// (timer déjà stoppé ailleurs, ou appareil sur une autre équipe).83+/// si le timer est déjà stoppé (ou inexistant).
84-pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {84+pub async fn stop(pool: &PgPool, user_id: Uuid) -> sqlx::Result<u64> {
85 let res = sqlx::query(85 let res = sqlx::query(
86 "UPDATE timers SET stopped_at = now()86 "UPDATE timers SET stopped_at = now()
87- WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL",87+ WHERE user_id = $1 AND stopped_at IS NULL",
88 )88 )
89 .bind(user_id)89 .bind(user_id)
90- .bind(team_id)
91 .execute(pool)90 .execute(pool)
92 .await?;91 .await?;
93 Ok(res.rows_affected())92 Ok(res.rows_affected())
94 }93 }
95 94
95+/// Nettoyage au départ d'une équipe : un timer ou une mesure y devient
96+/// inutilisable (plus membre), on le jette.
97+pub async fn discard_in_team(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
98+ let res = sqlx::query("DELETE FROM timers WHERE user_id = $1 AND team_id = $2")
99+ .bind(user_id)
100+ .bind(team_id)
101+ .execute(pool)
102+ .await?;
103+ Ok(res.rows_affected())
104+}
105+
96 /// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure.106 /// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure.
97 pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {107 pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> {
98 let res = sqlx::query(108 let res = sqlx::query(
@@ -120,15 +130,6 @@ pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sql
120 Ok(res.rows_affected())130 Ok(res.rows_affected())
121 }131 }
122 132
123-/// Vrai si un timer existe (en cours ou en attente) — verrous sur tout ce
124-/// qui changerait l'équipe active.
125-pub async fn exists(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> {
126- sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM timers WHERE user_id = $1)")
127- .bind(user_id)
128- .fetch_one(pool)
129- .await
130-}
131-
132 #[cfg(test)]133 #[cfg(test)]
133 mod tests {134 mod tests {
134 use super::*;135 use super::*;
modified src/routes/settings.rs +2 -10
@@ -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, timers, users};
10+use crate::db::{invitations, tasks, teams, users};
1111 use crate::error::AppError;
1212 use crate::render::{HtmlTemplate, Shell};
1313 use crate::routes::shell;
@@ -50,9 +50,6 @@ 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,
5653 }
5754
5855 #[derive(Deserialize)]
@@ -81,9 +78,6 @@ pub async fn settings_page(
8178 Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()),
8279 Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()),
8380 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- }
8781 _ => None,
8882 },
8983 };
@@ -121,8 +115,7 @@ pub async fn settings_page(
121115 name: t.name,
122116 })
123117 .collect();
124- let timer_lock = timers::exists(&state.pool, ctx.user.id).await?;
125- let can_leave = my_teams.len() > 1 && !timer_lock;
118+ let can_leave = my_teams.len() > 1;
126119 let account_line = if my_teams.len() > 1 {
127120 format!(
128121 "Membre de {} équipes. Les comptes sont créés en ligne de commande.",
@@ -159,7 +152,6 @@ pub async fn settings_page(
159152 invite_error,
160153 sent,
161154 can_leave,
162- timer_lock,
163155 }))
164156 }
165157
@@ -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, timers, users};10+use crate::db::{invitations, tasks, teams, 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,9 +50,6 @@ 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,
56 }53 }
57 54
58 #[derive(Deserialize)]55 #[derive(Deserialize)]
@@ -81,9 +78,6 @@ pub async fn settings_page(
81 Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()),78 Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()),
82 Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()),79 Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()),
83 Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()),80 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- }
87 _ => None,81 _ => None,
88 },82 },
89 };83 };
@@ -121,8 +115,7 @@ pub async fn settings_page(
121 name: t.name,115 name: t.name,
122 })116 })
123 .collect();117 .collect();
124- let timer_lock = timers::exists(&state.pool, ctx.user.id).await?;118+ let can_leave = my_teams.len() > 1;
125- let can_leave = my_teams.len() > 1 && !timer_lock;
126 let account_line = if my_teams.len() > 1 {119 let account_line = if my_teams.len() > 1 {
127 format!(120 format!(
128 "Membre de {} équipes. Les comptes sont créés en ligne de commande.",121 "Membre de {} équipes. Les comptes sont créés en ligne de commande.",
@@ -159,7 +152,6 @@ pub async fn settings_page(
159 invite_error,152 invite_error,
160 sent,153 sent,
161 can_leave,154 can_leave,
162- timer_lock,
163 }))155 }))
164 }156 }
165 157
modified src/routes/teams.rs +2 -18
@@ -14,12 +14,6 @@ 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-
2317 /// Encodage minimal pour rejouer un username dans une query string.
2418 fn urlenc(s: &str) -> String {
2519 let mut out = String::with_capacity(s.len());
@@ -50,9 +44,6 @@ pub async fn team_create(
5044 if !ctx.user.check_csrf(&state, &form.csrf) {
5145 return Err(AppError::Csrf);
5246 }
53- if timer_locked(&state, &ctx).await? {
54- return Ok(Redirect::to("/parametres?err=timer").into_response());
55- }
5647 let name = form.nom.trim();
5748 if name.is_empty() {
5849 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
@@ -86,9 +77,6 @@ pub async fn team_switch(
8677 if !ctx.user.check_csrf(&state, &form.csrf) {
8778 return Err(AppError::Csrf);
8879 }
89- if timer_locked(&state, &ctx).await? {
90- return Ok(Redirect::to("/parametres?err=timer").into_response());
91- }
9280 if teams::membership(&state.pool, ctx.user.id, form.equipe)
9381 .await?
9482 .is_none()
@@ -135,12 +123,11 @@ pub async fn team_leave(
135123 if !ctx.user.check_csrf(&state, &form.csrf) {
136124 return Err(AppError::Csrf);
137125 }
138- if timer_locked(&state, &ctx).await? {
139- return Ok(Redirect::to("/parametres?err=timer").into_response());
140- }
141126 if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 {
142127 return Ok(Redirect::to("/parametres?err=derniere").into_response());
143128 }
129+ // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté.
130+ timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?;
144131 teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
145132 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
146133 Ok((
@@ -202,9 +189,6 @@ pub async fn invite_accept(
202189 if !ctx.user.check_csrf(&state, &form.csrf) {
203190 return Err(AppError::Csrf);
204191 }
205- if timer_locked(&state, &ctx).await? {
206- return Ok(Redirect::to("/parametres?err=timer").into_response());
207- }
208192 match invitations::accept(&state.pool, id, ctx.user.id).await? {
209193 Some(team_id) => Ok((
210194 jar.add(team_cookie(team_id)),
@@ -14,12 +14,6 @@ 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-
23 /// Encodage minimal pour rejouer un username dans une query string.17 /// Encodage minimal pour rejouer un username dans une query string.
24 fn urlenc(s: &str) -> String {18 fn urlenc(s: &str) -> String {
25 let mut out = String::with_capacity(s.len());19 let mut out = String::with_capacity(s.len());
@@ -50,9 +44,6 @@ pub async fn team_create(
50 if !ctx.user.check_csrf(&state, &form.csrf) {44 if !ctx.user.check_csrf(&state, &form.csrf) {
51 return Err(AppError::Csrf);45 return Err(AppError::Csrf);
52 }46 }
53- if timer_locked(&state, &ctx).await? {
54- return Ok(Redirect::to("/parametres?err=timer").into_response());
55- }
56 let name = form.nom.trim();47 let name = form.nom.trim();
57 if name.is_empty() {48 if name.is_empty() {
58 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());49 return Ok(Redirect::to("/parametres?err=eq-vide").into_response());
@@ -86,9 +77,6 @@ pub async fn team_switch(
86 if !ctx.user.check_csrf(&state, &form.csrf) {77 if !ctx.user.check_csrf(&state, &form.csrf) {
87 return Err(AppError::Csrf);78 return Err(AppError::Csrf);
88 }79 }
89- if timer_locked(&state, &ctx).await? {
90- return Ok(Redirect::to("/parametres?err=timer").into_response());
91- }
92 if teams::membership(&state.pool, ctx.user.id, form.equipe)80 if teams::membership(&state.pool, ctx.user.id, form.equipe)
93 .await?81 .await?
94 .is_none()82 .is_none()
@@ -135,12 +123,11 @@ pub async fn team_leave(
135 if !ctx.user.check_csrf(&state, &form.csrf) {123 if !ctx.user.check_csrf(&state, &form.csrf) {
136 return Err(AppError::Csrf);124 return Err(AppError::Csrf);
137 }125 }
138- if timer_locked(&state, &ctx).await? {
139- return Ok(Redirect::to("/parametres?err=timer").into_response());
140- }
141 if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 {126 if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 {
142 return Ok(Redirect::to("/parametres?err=derniere").into_response());127 return Ok(Redirect::to("/parametres?err=derniere").into_response());
143 }128 }
129+ // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté.
130+ timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?;
144 teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;131 teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
145 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.132 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
146 Ok((133 Ok((
@@ -202,9 +189,6 @@ pub async fn invite_accept(
202 if !ctx.user.check_csrf(&state, &form.csrf) {189 if !ctx.user.check_csrf(&state, &form.csrf) {
203 return Err(AppError::Csrf);190 return Err(AppError::Csrf);
204 }191 }
205- if timer_locked(&state, &ctx).await? {
206- return Ok(Redirect::to("/parametres?err=timer").into_response());
207- }
208 match invitations::accept(&state.pool, id, ctx.user.id).await? {192 match invitations::accept(&state.pool, id, ctx.user.id).await? {
209 Some(team_id) => Ok((193 Some(team_id) => Ok((
210 jar.add(team_cookie(team_id)),194 jar.add(team_cookie(team_id)),
modified src/routes/timer.rs +22 -7
@@ -1,12 +1,13 @@
11 use axum::Form;
22 use axum::extract::State;
3-use axum::response::Redirect;
3+use axum::response::{IntoResponse, Redirect, Response};
4+use axum_extra::extract::CookieJar;
45 use serde::Deserialize;
56
67 use crate::db::timers;
78 use crate::error::AppError;
89 use crate::state::AppState;
9-use crate::team::ActiveTeam;
10+use crate::team::{ActiveTeam, team_cookie};
1011
1112 #[derive(Deserialize)]
1213 pub struct StartForm {
@@ -40,18 +41,32 @@ pub struct CsrfOnly {
4041 }
4142
4243 /// 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.
44+/// Si le timer (ou la mesure) vit dans une autre équipe que l'équipe active
45+/// de l'appareil, on bascule dessus au passage : l'enregistrement ne peut se
46+/// faire que là-bas. Timer inexistant : redirection douce.
4547 pub async fn timer_stop(
4648 State(state): State<AppState>,
4749 ctx: ActiveTeam,
50+ jar: CookieJar,
4851 Form(form): Form<CsrfOnly>,
49-) -> Result<Redirect, AppError> {
52+) -> Result<Response, AppError> {
5053 if !ctx.user.check_csrf(&state, &form.csrf) {
5154 return Err(AppError::Csrf);
5255 }
53- timers::stop(&state.pool, ctx.user.id, ctx.team_id).await?;
54- Ok(Redirect::to("/temps/nouveau"))
56+ let Some(row) = timers::get(&state.pool, ctx.user.id).await? else {
57+ return Ok(Redirect::to("/temps/nouveau").into_response());
58+ };
59+ if row.stopped_at.is_none() {
60+ timers::stop(&state.pool, ctx.user.id).await?;
61+ }
62+ // L'appartenance à l'équipe du timer est garantie : quitter une équipe
63+ // jette ses timers (cf. team_leave).
64+ let jar = if row.team_id != ctx.team_id {
65+ jar.add(team_cookie(row.team_id))
66+ } else {
67+ jar
68+ };
69+ Ok((jar, Redirect::to("/temps/nouveau")).into_response())
5570 }
5671
5772 /// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en
@@ -1,12 +1,13 @@
1 use axum::Form;1 use axum::Form;
2 use axum::extract::State;2 use axum::extract::State;
3-use axum::response::Redirect;3+use axum::response::{IntoResponse, Redirect, Response};
4+use axum_extra::extract::CookieJar;
4 use serde::Deserialize;5 use serde::Deserialize;
5 6
6 use crate::db::timers;7 use crate::db::timers;
7 use crate::error::AppError;8 use crate::error::AppError;
8 use crate::state::AppState;9 use crate::state::AppState;
9-use crate::team::ActiveTeam;10+use crate::team::{ActiveTeam, team_cookie};
10 11
11 #[derive(Deserialize)]12 #[derive(Deserialize)]
12 pub struct StartForm {13 pub struct StartForm {
@@ -40,18 +41,32 @@ pub struct CsrfOnly {
40 }41 }
41 42
42 /// POST /timer/arreter — fige la mesure et ouvre le choix de la tâche.43 /// 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+/// Si le timer (ou la mesure) vit dans une autre équipe que l'équipe active
44-/// redirection douce vers le flux qui affichera l'état réel.45+/// de l'appareil, on bascule dessus au passage : l'enregistrement ne peut se
46+/// faire que là-bas. Timer inexistant : redirection douce.
45 pub async fn timer_stop(47 pub async fn timer_stop(
46 State(state): State<AppState>,48 State(state): State<AppState>,
47 ctx: ActiveTeam,49 ctx: ActiveTeam,
50+ jar: CookieJar,
48 Form(form): Form<CsrfOnly>,51 Form(form): Form<CsrfOnly>,
49-) -> Result<Redirect, AppError> {52+) -> Result<Response, AppError> {
50 if !ctx.user.check_csrf(&state, &form.csrf) {53 if !ctx.user.check_csrf(&state, &form.csrf) {
51 return Err(AppError::Csrf);54 return Err(AppError::Csrf);
52 }55 }
53- timers::stop(&state.pool, ctx.user.id, ctx.team_id).await?;56+ let Some(row) = timers::get(&state.pool, ctx.user.id).await? else {
54- Ok(Redirect::to("/temps/nouveau"))57+ return Ok(Redirect::to("/temps/nouveau").into_response());
58+ };
59+ if row.stopped_at.is_none() {
60+ timers::stop(&state.pool, ctx.user.id).await?;
61+ }
62+ // L'appartenance à l'équipe du timer est garantie : quitter une équipe
63+ // jette ses timers (cf. team_leave).
64+ let jar = if row.team_id != ctx.team_id {
65+ jar.add(team_cookie(row.team_id))
66+ } else {
67+ jar
68+ };
69+ Ok((jar, Redirect::to("/temps/nouveau")).into_response())
55 }70 }
56 71
57 /// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en72 /// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en
modified templates/app.html +10 -14
@@ -15,20 +15,7 @@
1515 {% block content %}{% endblock %}
1616 </main>
1717 {% if let Some(t) = shell.timer %}
18- {% if t.foreign %}
19-<div class="timer-slot">
20- <span class="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 %}
18+ {% if t.running %}
3219 <form class="timer-slot" method="post" action="/timer/arreter">
3320 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
3421 <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer">
@@ -36,6 +23,15 @@
3623 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
3724 <span class="t-note">{{ t.note }}</span>
3825 </button>
26+</form>
27+ {% else if t.foreign %}
28+<form class="timer-slot" method="post" action="/timer/arreter">
29+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
30+ <button type="submit" class="timer-banner timer-waiting" aria-label="Enregistrer le temps mesuré (bascule d'équipe)">
31+ <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>
32+ <span class="t-elapsed">{{ t.label }}</span>
33+ <span class="t-note">{{ t.note }}</span>
34+ </button>
3935 </form>
4036 {% else %}
4137 <div class="timer-slot">
@@ -15,20 +15,7 @@
15 {% block content %}{% endblock %}15 {% block content %}{% endblock %}
16 </main>16 </main>
17 {% if let Some(t) = shell.timer %}17 {% if let Some(t) = shell.timer %}
18- {% if t.foreign %}18+ {% if t.running %}
19-<div class="timer-slot">
20- <span class="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">19 <form class="timer-slot" method="post" action="/timer/arreter">
33 <input type="hidden" name="csrf" value="{{ shell.csrf }}">20 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
34 <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer">21 <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer">
@@ -36,6 +23,15 @@
36 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>23 <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
37 <span class="t-note">{{ t.note }}</span>24 <span class="t-note">{{ t.note }}</span>
38 </button>25 </button>
26+</form>
27+ {% else if t.foreign %}
28+<form class="timer-slot" method="post" action="/timer/arreter">
29+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
30+ <button type="submit" class="timer-banner timer-waiting" aria-label="Enregistrer le temps mesuré (bascule d'équipe)">
31+ <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>
32+ <span class="t-elapsed">{{ t.label }}</span>
33+ <span class="t-note">{{ t.note }}</span>
34+ </button>
39 </form>35 </form>
40 {% else %}36 {% else %}
41 <div class="timer-slot">37 <div class="timer-slot">
modified templates/settings.html +4 -10
@@ -9,14 +9,13 @@
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" {% if timer_lock %}disabled{% endif %}>Accepter</button>
12+ <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">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 %}
2019 </div>
2120 {% endfor %}
2221 </div>
@@ -35,7 +34,7 @@
3534 <form method="post" action="/equipes/basculer" style="margin:0">
3635 <input type="hidden" name="csrf" value="{{ csrf }}">
3736 <input type="hidden" name="equipe" value="{{ t.id }}">
38- <button type="submit" class="team-choice{% if t.current %} current{% endif %}" {% if timer_lock %}disabled{% endif %}>
37+ <button type="submit" class="team-choice{% if t.current %} current{% endif %}">
3938 <span class="team-dot"></span>
4039 <span style="flex:1;text-align:left">
4140 <span style="display:block;font-size:16px">{{ t.name }}</span>
@@ -46,7 +45,6 @@
4645 </form>
4746 {% endfor %}
4847 </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 %}
5048
5149 <form method="post" action="/equipes/renommer">
5250 <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -98,15 +96,11 @@
9896 <form method="post" action="/equipes">
9997 <input type="hidden" name="csrf" value="{{ csrf }}">
10098 <div style="display:flex;gap:var(--space-2)">
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>
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>
103101 </div>
104102 </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 %}
108103 <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 %}
110104
111105 {% if can_leave %}
112106 <form method="post" action="/equipes/quitter">
@@ -9,14 +9,13 @@
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" {% if timer_lock %}disabled{% endif %}>Accepter</button>12+ <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">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 %}
20 </div>19 </div>
21 {% endfor %}20 {% endfor %}
22 </div>21 </div>
@@ -35,7 +34,7 @@
35 <form method="post" action="/equipes/basculer" style="margin:0">34 <form method="post" action="/equipes/basculer" style="margin:0">
36 <input type="hidden" name="csrf" value="{{ csrf }}">35 <input type="hidden" name="csrf" value="{{ csrf }}">
37 <input type="hidden" name="equipe" value="{{ t.id }}">36 <input type="hidden" name="equipe" value="{{ t.id }}">
38- <button type="submit" class="team-choice{% if t.current %} current{% endif %}" {% if timer_lock %}disabled{% endif %}>37+ <button type="submit" class="team-choice{% if t.current %} current{% endif %}">
39 <span class="team-dot"></span>38 <span class="team-dot"></span>
40 <span style="flex:1;text-align:left">39 <span style="flex:1;text-align:left">
41 <span style="display:block;font-size:16px">{{ t.name }}</span>40 <span style="display:block;font-size:16px">{{ t.name }}</span>
@@ -46,7 +45,6 @@
46 </form>45 </form>
47 {% endfor %}46 {% endfor %}
48 </div>47 </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 %}
50 48
51 <form method="post" action="/equipes/renommer">49 <form method="post" action="/equipes/renommer">
52 <input type="hidden" name="csrf" value="{{ csrf }}">50 <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -98,15 +96,11 @@
98 <form method="post" action="/equipes">96 <form method="post" action="/equipes">
99 <input type="hidden" name="csrf" value="{{ csrf }}">97 <input type="hidden" name="csrf" value="{{ csrf }}">
100 <div style="display:flex;gap:var(--space-2)">98 <div style="display:flex;gap:var(--space-2)">
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 %}>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">
102- <button type="submit" class="btn btn-primary" style="min-height:44px" {% if timer_lock %}disabled{% endif %}>Créer</button>100+ <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button>
103 </div>101 </div>
104 </form>102 </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 %}
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>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>
109-{% endif %}
110 104
111 {% if can_leave %}105 {% if can_leave %}
112 <form method="post" action="/equipes/quitter">106 <form method="post" action="/equipes/quitter">