judu/ex-egopublic Fork
⑂ e824891
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(minuteur, stats): dock du minuteur et graphiques en lignesUnverified

Le minuteur quitte la pill flottante pour le dock du design : bandeau olive
sous l'en-tête (bas du rail sur large écran) avec durée, note et pastille
d'action. Masqué sur l'onglet Ajouter, sauf quand le timer vit dans une autre
équipe — l'atterrissage y désactive alors « Lancer le minuteur » en disant
pourquoi (timers::start ne créerait aucune ligne).

Ajoute un réglage Graphiques (users.stats_layout, migration 0011) qui bascule
tous les graphiques de Stats — activité de la semaine comprise — en barres
horizontales. Même markup, .cols-rows fait tourner l'axe.

Écarts AA assumés vs le design : pastille d'action en aplat crème plein
(le voile à 24 % tombe à 3.35:1) et note à 0,88 d'opacité.
Julien Durillon committed 2026-08-18T12:55:17+02:00 Browse files
e824891 parent: 892cf36
modified assets/style.css +101 -22
@@ -27,6 +27,7 @@
2727 --color-accent-900: #402310;
2828
2929 --color-accent-2-200: #e1eecc;
30+ --color-accent-2-700: #56633f;
3031 --color-accent-2-900: #272e1b;
3132 --color-neutral-800: #474238;
3233
@@ -79,6 +80,7 @@
7980 --color-accent-900: #ffffff;
8081
8182 --color-accent-2-200: #242424;
83+ --color-accent-2-700: #ffffff;
8284 --color-accent-2-900: #ffffff;
8385
8486 --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28);
@@ -213,32 +215,49 @@ a.team-pill:hover { filter: brightness(.96); }
213215 .app-topline .who { margin-left: auto; font-size: 12px; opacity: .7; }
214216 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }
215217
216-/* Dégagement bas = tab bar + bandeau timer + marge, safe area comprise.
218+/* Dégagement bas = tab bar + marge, safe area comprise (le dock du minuteur
219+ est passé au-dessus du contenu, il ne mange plus le bas).
217220 Seul conteneur scrollable de l'app (cf. body overflow hidden). */
218221 .app-main {
219222 flex: 1; min-height: 0;
220- padding: 0 var(--space-4) calc(140px + var(--tabbar-pad));
223+ padding: 0 var(--space-4) calc(84px + var(--tabbar-pad));
221224 overflow-y: auto; -webkit-overflow-scrolling: touch;
222225 }
223226
224-/* — timer — */
225-.timer-slot {
226- position: absolute; left: 0; right: 0; margin: 0;
227- padding: 0 var(--space-4);
228- /* ≈ 23px d'écart au-dessus de la tab bar, quel que soit l'appareil. */
229- bottom: calc(72px + var(--tabbar-pad));
230- display: flex; justify-content: center; pointer-events: none; z-index: 4;
231-}
232-/* Bandeau pleine largeur (lancer / en cours / mesure en attente). */
233-.timer-banner {
234- pointer-events: auto; width: 100%; display: flex; align-items: center;
235- gap: 12px; padding: 14px 20px; border: none; border-radius: var(--radius-lg);
236- box-shadow: var(--shadow-lg); cursor: pointer; text-decoration: none;
237- font: inherit; font-size: 15px; font-weight: 700;
238-}
239-.timer-banner .t-note { font-size: 13px; font-weight: 400; opacity: .85; }
240-.timer-running { background: var(--color-accent-deep); color: var(--color-bg); }
241-.timer-waiting { background: var(--color-accent-200); color: var(--color-accent-900); }
227+/* — dock du minuteur —
228+ Bandeau sous l'en-tête (rail en bas de la nav sur large écran) : pastille,
229+ durée, note, et la pastille d'action poussée à droite. Il ne s'affiche
230+ pas sur l'onglet Ajouter, où l'écran porte déjà le minuteur ou la mesure
231+ — sauf si le timer tourne dans une AUTRE équipe (rien ne le dirait). */
232+.timer-dock { flex: none; margin: 0; padding: 0 var(--space-4) var(--space-3); }
233+.timer-btn {
234+ display: flex; align-items: center; gap: 10px; width: 100%;
235+ border: none; border-radius: 999px; padding: 10px 14px;
236+ background: var(--color-accent-2-700); color: var(--color-bg);
237+ font: inherit; text-align: left; text-decoration: none; cursor: pointer;
238+}
239+.timer-btn .t-dot {
240+ width: 8px; height: 8px; border-radius: 999px; flex: none;
241+ background: currentColor; opacity: .9;
242+}
243+.timer-btn .t-elapsed {
244+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
245+ font-size: 15px; letter-spacing: -0.01em;
246+ /* La durée ne se coupe jamais : c'est la note (min-width:0) qui passe à la
247+ ligne quand elle nomme une équipe au nom long. */
248+ flex: none; white-space: nowrap;
249+}
250+/* .88 et pas le .8 du design : sur l'olive accent-2-700, la crème à 80 %
251+ tombe à 4.15:1 — sous le plancher AA pour du 12px. */
252+.timer-btn .t-note { font-size: 12px; opacity: .88; line-height: 1.25; min-width: 0; }
253+/* Écart assumé vs le design (pastille crème à 24 % sur l'olive) : ce voile
254+ ne tient que 3.35:1 avec le texte crème. Aplat crème plein + encre olive
255+ foncée → 12:1, et l'action se lit comme un bouton. */
256+.timer-btn .t-cta {
257+ margin-left: auto; flex: none; font-size: 13px; font-weight: 700;
258+ padding: 4px 12px; border-radius: 999px;
259+ background: var(--color-bg); color: var(--color-accent-2-900);
260+}
242261 /* Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle. */
243262 .choice-hint { font-size: 15px; opacity: .7; margin: 0 0 var(--space-6); text-wrap: pretty; }
244263 .choice-stack { display: grid; gap: var(--space-3); }
@@ -263,6 +282,16 @@ a.team-pill:hover { filter: brightness(.96); }
263282 .choice-timer:hover { background: var(--color-accent-700); }
264283 .choice-manual { background: var(--color-surface); color: var(--color-text); border: 1.5px solid var(--color-accent-2); }
265284 .choice-manual:hover { background: color-mix(in srgb, var(--color-accent-2) 12%, var(--color-surface)); }
285+/* Minuteur déjà pris ailleurs : c'est l'APLAT qui s'efface, jamais le texte
286+ (convention a11y du projet) — la carte foncée redevient un aplat neutre et
287+ ses libellés gardent l'encre pleine. */
288+.choice-card:disabled { background: var(--color-neutral-200); color: var(--color-text); box-shadow: none; cursor: default; }
289+.choice-card:disabled:hover { background: var(--color-neutral-200); }
290+.choice-card:disabled .choice-sub { opacity: .7; }
291+.choice-busy {
292+ margin: calc(-1 * var(--space-1)) var(--space-2) 0; font-size: 13px;
293+ color: var(--color-accent-900); opacity: .8; text-wrap: pretty;
294+}
266295 .choice-manage { justify-self: center; margin-top: var(--space-2); }
267296
268297 /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */
@@ -477,6 +506,41 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
477506 .col-muted .user { font-weight: 400; }
478507 .col .user.day-today { font-weight: 700; color: var(--color-accent-deep); }
479508
509+/* — variante « lignes » (users.stats_layout = 'rows') —
510+ Même markup que les colonnes : la grille remet le nom à gauche, le total à
511+ droite, et la barre sur toute la ligne en dessous. La longueur reste --h
512+ (le % du max) ; c'est l'axe qui bascule, pas les données. */
513+.cols-rows {
514+ display: flex; flex-direction: column; align-items: stretch;
515+ height: auto; gap: var(--space-4); padding-top: 0;
516+}
517+.cols-rows .col {
518+ display: grid; height: auto;
519+ grid-template-columns: 1fr auto;
520+ grid-template-areas: "user total" "bar bar";
521+ align-items: baseline; gap: 5px var(--space-2);
522+}
523+.cols-rows .col .user {
524+ grid-area: user; height: auto; font-size: 14px; font-weight: 600;
525+ text-align: left; white-space: normal;
526+}
527+.cols-rows .col .total {
528+ grid-area: total; height: auto; font-size: 13px; text-align: right;
529+}
530+.cols-rows .col-bar {
531+ grid-area: bar; flex-direction: row; align-self: stretch;
532+ width: calc(var(--h, 2) * 1%); max-width: none;
533+ height: 26px; min-width: 2px; border-radius: 999px;
534+}
535+/* 24px de large : même cible tactile que la hauteur des segments en
536+ colonnes (WCAG 2.5.8) — la barre fait déjà 26px de haut. */
537+.cols-rows .col-bar a { width: auto; height: 100%; min-height: 0; min-width: 24px; }
538+/* En lignes la semaine tient dans la largeur : le compromis à 16px des
539+ colonnes de jour n'a plus lieu d'être. */
540+.cols-rows.cols-7 .col-bar a { min-height: 0; min-width: 24px; }
541+/* Jour futur : ni barre ni total, la ligne se réduit à son libellé. */
542+.cols-rows .col-muted { grid-template-areas: "user total"; }
543+
480544 /* — groupes de catégories : carrousel de scopes (page Stats) — */
481545 .scope-pills {
482546 display: flex; gap: var(--space-2); overflow-x: auto; scrollbar-width: none;
@@ -726,6 +790,15 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
726790 .swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); }
727791 .swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); }
728792
793+/* Sélecteur de format des graphiques (Réglages) — même gabarit que le thème,
794+ pictogramme au lieu de la pastille de couleurs. */
795+.chart-swatch {
796+ width: 34px; height: 34px; flex: none; border-radius: var(--radius-md);
797+ border: 1px solid var(--color-divider);
798+ display: flex; align-items: center; justify-content: center;
799+ color: var(--color-accent-700);
800+}
801+
729802 /* Sélecteur de langue (Réglages). */
730803 .lang-pill {
731804 display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;
@@ -774,7 +847,7 @@ a.times-link { flex-direction: row; align-items: center; text-decoration: none;
774847 padding-top: 0;
775848 }
776849 .app-header { margin-left: 232px; padding: var(--space-8) var(--space-8) var(--space-4); }
777- .app-main { margin-left: 232px; max-width: 700px; padding: 0 var(--space-8) 128px; }
850+ .app-main { margin-left: 232px; max-width: 700px; padding: 0 var(--space-8) var(--space-8); }
778851 .tabbar {
779852 top: 0; bottom: 0; right: auto; width: 232px;
780853 flex-direction: column; gap: 4px; align-items: stretch;
@@ -794,7 +867,13 @@ a.times-link { flex-direction: row; align-items: center; text-decoration: none;
794867 color: color-mix(in srgb, var(--color-text) 70%, transparent);
795868 }
796869 .tab[aria-current="page"] { background: var(--color-accent-2-200); color: var(--color-accent-700); }
797- .timer-slot { left: 232px; bottom: var(--space-6); padding: 0 var(--space-8); }
870+ /* Large écran : le dock quitte le bandeau pour le bas du rail. */
871+ .timer-dock {
872+ position: absolute; left: 0; bottom: 0; width: 232px;
873+ padding: var(--space-4) var(--space-4) var(--space-8); z-index: 5;
874+ background: var(--color-surface);
875+ }
876+ .timer-btn { border-radius: var(--radius-lg); padding: 13px 15px; flex-wrap: wrap; }
798877 .sheet-wrap:target, .sheet-wrap.open { align-items: center; padding: var(--space-6); }
799878 .sheet {
800879 max-width: 520px; max-height: 100%; overflow-y: auto;
@@ -27,6 +27,7 @@
27 --color-accent-900: #402310;27 --color-accent-900: #402310;
28 28
29 --color-accent-2-200: #e1eecc;29 --color-accent-2-200: #e1eecc;
30+ --color-accent-2-700: #56633f;
30 --color-accent-2-900: #272e1b;31 --color-accent-2-900: #272e1b;
31 --color-neutral-800: #474238;32 --color-neutral-800: #474238;
32 33
@@ -79,6 +80,7 @@
79 --color-accent-900: #ffffff;80 --color-accent-900: #ffffff;
80 81
81 --color-accent-2-200: #242424;82 --color-accent-2-200: #242424;
83+ --color-accent-2-700: #ffffff;
82 --color-accent-2-900: #ffffff;84 --color-accent-2-900: #ffffff;
83 85
84 --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28);86 --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28);
@@ -213,32 +215,49 @@ a.team-pill:hover { filter: brightness(.96); }
213 .app-topline .who { margin-left: auto; font-size: 12px; opacity: .7; }215 .app-topline .who { margin-left: auto; font-size: 12px; opacity: .7; }
214 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }216 .app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; }
215 217
216-/* Dégagement bas = tab bar + bandeau timer + marge, safe area comprise.218+/* Dégagement bas = tab bar + marge, safe area comprise (le dock du minuteur
219+ est passé au-dessus du contenu, il ne mange plus le bas).
217 Seul conteneur scrollable de l'app (cf. body overflow hidden). */220 Seul conteneur scrollable de l'app (cf. body overflow hidden). */
218 .app-main {221 .app-main {
219 flex: 1; min-height: 0;222 flex: 1; min-height: 0;
220- padding: 0 var(--space-4) calc(140px + var(--tabbar-pad));223+ padding: 0 var(--space-4) calc(84px + var(--tabbar-pad));
221 overflow-y: auto; -webkit-overflow-scrolling: touch;224 overflow-y: auto; -webkit-overflow-scrolling: touch;
222 }225 }
223 226
224-/* — timer — */227+/* — dock du minuteur —
225-.timer-slot {228+ Bandeau sous l'en-tête (rail en bas de la nav sur large écran) : pastille,
226- position: absolute; left: 0; right: 0; margin: 0;229+ durée, note, et la pastille d'action poussée à droite. Il ne s'affiche
227- padding: 0 var(--space-4);230+ pas sur l'onglet Ajouter, où l'écran porte déjà le minuteur ou la mesure
228- /* ≈ 23px d'écart au-dessus de la tab bar, quel que soit l'appareil. */231+ — sauf si le timer tourne dans une AUTRE équipe (rien ne le dirait). */
229- bottom: calc(72px + var(--tabbar-pad));232+.timer-dock { flex: none; margin: 0; padding: 0 var(--space-4) var(--space-3); }
230- display: flex; justify-content: center; pointer-events: none; z-index: 4;233+.timer-btn {
231-}234+ display: flex; align-items: center; gap: 10px; width: 100%;
232-/* Bandeau pleine largeur (lancer / en cours / mesure en attente). */235+ border: none; border-radius: 999px; padding: 10px 14px;
233-.timer-banner {236+ background: var(--color-accent-2-700); color: var(--color-bg);
234- pointer-events: auto; width: 100%; display: flex; align-items: center;237+ font: inherit; text-align: left; text-decoration: none; cursor: pointer;
235- gap: 12px; padding: 14px 20px; border: none; border-radius: var(--radius-lg);238+}
236- box-shadow: var(--shadow-lg); cursor: pointer; text-decoration: none;239+.timer-btn .t-dot {
237- font: inherit; font-size: 15px; font-weight: 700;240+ width: 8px; height: 8px; border-radius: 999px; flex: none;
238-}241+ background: currentColor; opacity: .9;
239-.timer-banner .t-note { font-size: 13px; font-weight: 400; opacity: .85; }242+}
240-.timer-running { background: var(--color-accent-deep); color: var(--color-bg); }243+.timer-btn .t-elapsed {
241-.timer-waiting { background: var(--color-accent-200); color: var(--color-accent-900); }244+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);
245+ font-size: 15px; letter-spacing: -0.01em;
246+ /* La durée ne se coupe jamais : c'est la note (min-width:0) qui passe à la
247+ ligne quand elle nomme une équipe au nom long. */
248+ flex: none; white-space: nowrap;
249+}
250+/* .88 et pas le .8 du design : sur l'olive accent-2-700, la crème à 80 %
251+ tombe à 4.15:1 — sous le plancher AA pour du 12px. */
252+.timer-btn .t-note { font-size: 12px; opacity: .88; line-height: 1.25; min-width: 0; }
253+/* Écart assumé vs le design (pastille crème à 24 % sur l'olive) : ce voile
254+ ne tient que 3.35:1 avec le texte crème. Aplat crème plein + encre olive
255+ foncée → 12:1, et l'action se lit comme un bouton. */
256+.timer-btn .t-cta {
257+ margin-left: auto; flex: none; font-size: 13px; font-weight: 700;
258+ padding: 4px 12px; border-radius: 999px;
259+ background: var(--color-bg); color: var(--color-accent-2-900);
260+}
242 /* Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle. */261 /* Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle. */
243 .choice-hint { font-size: 15px; opacity: .7; margin: 0 0 var(--space-6); text-wrap: pretty; }262 .choice-hint { font-size: 15px; opacity: .7; margin: 0 0 var(--space-6); text-wrap: pretty; }
244 .choice-stack { display: grid; gap: var(--space-3); }263 .choice-stack { display: grid; gap: var(--space-3); }
@@ -263,6 +282,16 @@ a.team-pill:hover { filter: brightness(.96); }
263 .choice-timer:hover { background: var(--color-accent-700); }282 .choice-timer:hover { background: var(--color-accent-700); }
264 .choice-manual { background: var(--color-surface); color: var(--color-text); border: 1.5px solid var(--color-accent-2); }283 .choice-manual { background: var(--color-surface); color: var(--color-text); border: 1.5px solid var(--color-accent-2); }
265 .choice-manual:hover { background: color-mix(in srgb, var(--color-accent-2) 12%, var(--color-surface)); }284 .choice-manual:hover { background: color-mix(in srgb, var(--color-accent-2) 12%, var(--color-surface)); }
285+/* Minuteur déjà pris ailleurs : c'est l'APLAT qui s'efface, jamais le texte
286+ (convention a11y du projet) — la carte foncée redevient un aplat neutre et
287+ ses libellés gardent l'encre pleine. */
288+.choice-card:disabled { background: var(--color-neutral-200); color: var(--color-text); box-shadow: none; cursor: default; }
289+.choice-card:disabled:hover { background: var(--color-neutral-200); }
290+.choice-card:disabled .choice-sub { opacity: .7; }
291+.choice-busy {
292+ margin: calc(-1 * var(--space-1)) var(--space-2) 0; font-size: 13px;
293+ color: var(--color-accent-900); opacity: .8; text-wrap: pretty;
294+}
266 .choice-manage { justify-self: center; margin-top: var(--space-2); }295 .choice-manage { justify-self: center; margin-top: var(--space-2); }
267 296
268 /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */297 /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */
@@ -477,6 +506,41 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
477 .col-muted .user { font-weight: 400; }506 .col-muted .user { font-weight: 400; }
478 .col .user.day-today { font-weight: 700; color: var(--color-accent-deep); }507 .col .user.day-today { font-weight: 700; color: var(--color-accent-deep); }
479 508
509+/* — variante « lignes » (users.stats_layout = 'rows') —
510+ Même markup que les colonnes : la grille remet le nom à gauche, le total à
511+ droite, et la barre sur toute la ligne en dessous. La longueur reste --h
512+ (le % du max) ; c'est l'axe qui bascule, pas les données. */
513+.cols-rows {
514+ display: flex; flex-direction: column; align-items: stretch;
515+ height: auto; gap: var(--space-4); padding-top: 0;
516+}
517+.cols-rows .col {
518+ display: grid; height: auto;
519+ grid-template-columns: 1fr auto;
520+ grid-template-areas: "user total" "bar bar";
521+ align-items: baseline; gap: 5px var(--space-2);
522+}
523+.cols-rows .col .user {
524+ grid-area: user; height: auto; font-size: 14px; font-weight: 600;
525+ text-align: left; white-space: normal;
526+}
527+.cols-rows .col .total {
528+ grid-area: total; height: auto; font-size: 13px; text-align: right;
529+}
530+.cols-rows .col-bar {
531+ grid-area: bar; flex-direction: row; align-self: stretch;
532+ width: calc(var(--h, 2) * 1%); max-width: none;
533+ height: 26px; min-width: 2px; border-radius: 999px;
534+}
535+/* 24px de large : même cible tactile que la hauteur des segments en
536+ colonnes (WCAG 2.5.8) — la barre fait déjà 26px de haut. */
537+.cols-rows .col-bar a { width: auto; height: 100%; min-height: 0; min-width: 24px; }
538+/* En lignes la semaine tient dans la largeur : le compromis à 16px des
539+ colonnes de jour n'a plus lieu d'être. */
540+.cols-rows.cols-7 .col-bar a { min-height: 0; min-width: 24px; }
541+/* Jour futur : ni barre ni total, la ligne se réduit à son libellé. */
542+.cols-rows .col-muted { grid-template-areas: "user total"; }
543+
480 /* — groupes de catégories : carrousel de scopes (page Stats) — */544 /* — groupes de catégories : carrousel de scopes (page Stats) — */
481 .scope-pills {545 .scope-pills {
482 display: flex; gap: var(--space-2); overflow-x: auto; scrollbar-width: none;546 display: flex; gap: var(--space-2); overflow-x: auto; scrollbar-width: none;
@@ -726,6 +790,15 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d
726 .swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); }790 .swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); }
727 .swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); }791 .swatch-hc { background: linear-gradient(135deg, #000 0 50%, #fff 50% 100%); }
728 792
793+/* Sélecteur de format des graphiques (Réglages) — même gabarit que le thème,
794+ pictogramme au lieu de la pastille de couleurs. */
795+.chart-swatch {
796+ width: 34px; height: 34px; flex: none; border-radius: var(--radius-md);
797+ border: 1px solid var(--color-divider);
798+ display: flex; align-items: center; justify-content: center;
799+ color: var(--color-accent-700);
800+}
801+
729 /* Sélecteur de langue (Réglages). */802 /* Sélecteur de langue (Réglages). */
730 .lang-pill {803 .lang-pill {
731 display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;804 display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;
@@ -774,7 +847,7 @@ a.times-link { flex-direction: row; align-items: center; text-decoration: none;
774 padding-top: 0;847 padding-top: 0;
775 }848 }
776 .app-header { margin-left: 232px; padding: var(--space-8) var(--space-8) var(--space-4); }849 .app-header { margin-left: 232px; padding: var(--space-8) var(--space-8) var(--space-4); }
777- .app-main { margin-left: 232px; max-width: 700px; padding: 0 var(--space-8) 128px; }850+ .app-main { margin-left: 232px; max-width: 700px; padding: 0 var(--space-8) var(--space-8); }
778 .tabbar {851 .tabbar {
779 top: 0; bottom: 0; right: auto; width: 232px;852 top: 0; bottom: 0; right: auto; width: 232px;
780 flex-direction: column; gap: 4px; align-items: stretch;853 flex-direction: column; gap: 4px; align-items: stretch;
@@ -794,7 +867,13 @@ a.times-link { flex-direction: row; align-items: center; text-decoration: none;
794 color: color-mix(in srgb, var(--color-text) 70%, transparent);867 color: color-mix(in srgb, var(--color-text) 70%, transparent);
795 }868 }
796 .tab[aria-current="page"] { background: var(--color-accent-2-200); color: var(--color-accent-700); }869 .tab[aria-current="page"] { background: var(--color-accent-2-200); color: var(--color-accent-700); }
797- .timer-slot { left: 232px; bottom: var(--space-6); padding: 0 var(--space-8); }870+ /* Large écran : le dock quitte le bandeau pour le bas du rail. */
871+ .timer-dock {
872+ position: absolute; left: 0; bottom: 0; width: 232px;
873+ padding: var(--space-4) var(--space-4) var(--space-8); z-index: 5;
874+ background: var(--color-surface);
875+ }
876+ .timer-btn { border-radius: var(--radius-lg); padding: 13px 15px; flex-wrap: wrap; }
798 .sheet-wrap:target, .sheet-wrap.open { align-items: center; padding: var(--space-6); }877 .sheet-wrap:target, .sheet-wrap.open { align-items: center; padding: var(--space-6); }
799 .sheet {878 .sheet {
800 max-width: 520px; max-height: 100%; overflow-y: auto;879 max-width: 520px; max-height: 100%; overflow-y: auto;
added migrations/0011_user_stats_layout.sql +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+-- Format des graphiques de Stats, PAR UTILISATEUR (comme la langue et le
2+-- thème) : 'columns' = colonnes empilées (défaut historique), 'rows' = barres
3+-- horizontales. S'applique aux quatre graphiques ET à « Activité de la
4+-- semaine ».
5+ALTER TABLE users ADD COLUMN stats_layout TEXT NOT NULL DEFAULT 'columns'
6+ CHECK (stats_layout IN ('columns', 'rows'));
new file mode 100644
@@ -0,0 +1,6 @@
1+-- Format des graphiques de Stats, PAR UTILISATEUR (comme la langue et le
2+-- thème) : 'columns' = colonnes empilées (défaut historique), 'rows' = barres
3+-- horizontales. S'applique aux quatre graphiques ET à « Activité de la
4+-- semaine ».
5+ALTER TABLE users ADD COLUMN stats_layout TEXT NOT NULL DEFAULT 'columns'
6+ CHECK (stats_layout IN ('columns', 'rows'));
modified src/db/users.rs +17 -3
@@ -37,9 +37,13 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq
3737 Ok(())
3838 }
3939
40-/// (username, code langue, thème) — profil minimal chargé à chaque requête.
41-pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String, String)>> {
42- sqlx::query_as("SELECT username, lang, theme FROM users WHERE id = $1")
40+/// (username, code langue, thème, format des stats) — profil minimal chargé
41+/// à chaque requête.
42+pub async fn profile(
43+ pool: &PgPool,
44+ id: Uuid,
45+) -> sqlx::Result<Option<(String, String, String, String)>> {
46+ sqlx::query_as("SELECT username, lang, theme, stats_layout FROM users WHERE id = $1")
4347 .bind(id)
4448 .fetch_optional(pool)
4549 .await
@@ -65,6 +69,16 @@ pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()>
6569 Ok(())
6670 }
6771
72+/// Le CHECK en base garde le garde-fou final ('columns' | 'rows').
73+pub async fn set_stats_layout(pool: &PgPool, id: Uuid, layout: &str) -> sqlx::Result<()> {
74+ sqlx::query("UPDATE users SET stats_layout = $1 WHERE id = $2")
75+ .bind(layout)
76+ .bind(id)
77+ .execute(pool)
78+ .await?;
79+ Ok(())
80+}
81+
6882 /// Cible d'une invitation.
6983 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
7084 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
@@ -37,9 +37,13 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq
37 Ok(())37 Ok(())
38 }38 }
39 39
40-/// (username, code langue, thème) — profil minimal chargé à chaque requête.40+/// (username, code langue, thème, format des stats) — profil minimal chargé
41-pub async fn profile(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<(String, String, String)>> {41+/// à chaque requête.
42- sqlx::query_as("SELECT username, lang, theme FROM users WHERE id = $1")42+pub async fn profile(
43+ pool: &PgPool,
44+ id: Uuid,
45+) -> sqlx::Result<Option<(String, String, String, String)>> {
46+ sqlx::query_as("SELECT username, lang, theme, stats_layout FROM users WHERE id = $1")
43 .bind(id)47 .bind(id)
44 .fetch_optional(pool)48 .fetch_optional(pool)
45 .await49 .await
@@ -65,6 +69,16 @@ pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()>
65 Ok(())69 Ok(())
66 }70 }
67 71
72+/// Le CHECK en base garde le garde-fou final ('columns' | 'rows').
73+pub async fn set_stats_layout(pool: &PgPool, id: Uuid, layout: &str) -> sqlx::Result<()> {
74+ sqlx::query("UPDATE users SET stats_layout = $1 WHERE id = $2")
75+ .bind(layout)
76+ .bind(id)
77+ .execute(pool)
78+ .await?;
79+ Ok(())
80+}
81+
68 /// Cible d'une invitation.82 /// Cible d'une invitation.
69 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {83 pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> {
70 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")84 sqlx::query_as("SELECT id, username FROM users WHERE username = $1")
modified src/i18n.rs +72 -13
@@ -80,11 +80,15 @@ pub struct Tr {
8080 pub start_timer: &'static str,
8181 pub stop_timer_aria: &'static str,
8282 pub timer_running_note: &'static str,
83+ pub measure_pending_note: &'static str,
8384 pub record_measured_aria: &'static str,
84- pub record_measured_switch_aria: &'static str,
8585 pub measured_pat: &'static str,
86+ /// Notes du dock quand le timer (ou la mesure) vit dans une autre équipe.
8687 pub in_team_pat: &'static str,
8788 pub timer_in_team_pat: &'static str,
89+ /// Pastille d'action du dock du minuteur.
90+ pub stop_cta: &'static str,
91+ pub record_cta: &'static str,
8892 // ─── login ───
8993 pub login_title: &'static str,
9094 pub tagline: &'static str,
@@ -99,6 +103,9 @@ pub struct Tr {
99103 /// Écran de choix (deux boutons) de l'onglet Ajouter.
100104 pub add_choice_hint: &'static str,
101105 pub choice_timer_sub: &'static str,
106+ /// Pourquoi « Lancer le minuteur » est désactivé (timer/mesure ailleurs).
107+ pub timer_elsewhere_pat: &'static str,
108+ pub measure_elsewhere_pat: &'static str,
102109 pub choice_manual_sub: &'static str,
103110 pub back: &'static str,
104111 pub step1_hint: &'static str,
@@ -230,6 +237,14 @@ pub struct Tr {
230237 pub theme_hc: &'static str,
231238 pub theme_hc_desc: &'static str,
232239 pub toast_theme_updated: &'static str,
240+ /// Section « Graphiques » des réglages (format des stats).
241+ pub chart_style: &'static str,
242+ pub chart_style_hint: &'static str,
243+ pub chart_style_columns: &'static str,
244+ pub chart_style_columns_desc: &'static str,
245+ pub chart_style_rows: &'static str,
246+ pub chart_style_rows_desc: &'static str,
247+ pub toast_chart_style_updated: &'static str,
233248 pub session: &'static str,
234249 pub current_password: &'static str,
235250 pub new_password: &'static str,
@@ -360,12 +375,14 @@ pub static FR: Tr = Tr {
360375 member_many: "membres",
361376 start_timer: "Lancer le minuteur",
362377 stop_timer_aria: "Arrêter le timer",
363- timer_running_note: "en cours",
378+ timer_running_note: "minuteur en cours",
379+ measure_pending_note: "à enregistrer",
364380 record_measured_aria: "Enregistrer le temps mesuré",
365- record_measured_switch_aria: "Enregistrer le temps mesuré (bascule d'équipe)",
366381 measured_pat: "{} mesurées",
367382 in_team_pat: "dans l'équipe {}",
368- timer_in_team_pat: "timer dans l'équipe {}",
383+ timer_in_team_pat: "minuteur en cours dans l'équipe {}",
384+ stop_cta: "Arrêter",
385+ record_cta: "Enregistrer",
369386 login_title: "Connexion",
370387 tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.",
371388 username_label: "Identifiant",
@@ -377,6 +394,8 @@ pub static FR: Tr = Tr {
377394 title_select_duration: "Combien de temps ?",
378395 add_choice_hint: "Compte le temps au fur et à mesure, ou saisis une durée que tu as déjà passée.",
379396 choice_timer_sub: "Tu choisiras la catégorie en l'arrêtant.",
397+ timer_elsewhere_pat: "Un minuteur tourne déjà dans l'équipe {} — arrête-le avant d'en lancer un ici.",
398+ measure_elsewhere_pat: "Un temps mesuré attend d'être enregistré dans l'équipe {} — enregistre-le avant de relancer un minuteur.",
380399 choice_manual_sub: "Une durée déjà passée, en deux étapes.",
381400 back: "← Retour",
382401 step1_hint: "Étape 1 sur 2 — choisis la catégorie",
@@ -495,6 +514,13 @@ pub static FR: Tr = Tr {
495514 theme_hc: "Contraste élevé",
496515 theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.",
497516 toast_theme_updated: "Affichage mis à jour",
517+ chart_style: "Graphiques",
518+ chart_style_hint: "Le format des graphiques de l'onglet Stats, activité de la semaine comprise.",
519+ chart_style_columns: "Colonnes",
520+ chart_style_columns_desc: "Des barres verticales côte à côte.",
521+ chart_style_rows: "Lignes",
522+ chart_style_rows_desc: "Des barres horizontales empilées les unes sous les autres.",
523+ toast_chart_style_updated: "Graphiques mis à jour",
498524 session: "Session",
499525 current_password: "Mot de passe actuel",
500526 new_password: "Nouveau mot de passe",
@@ -595,12 +621,14 @@ pub static EN: Tr = Tr {
595621 member_many: "members",
596622 start_timer: "Start the timer",
597623 stop_timer_aria: "Stop timer",
598- timer_running_note: "running",
624+ timer_running_note: "timer running",
625+ measure_pending_note: "to record",
599626 record_measured_aria: "Record the measured time",
600- record_measured_switch_aria: "Record the measured time (switches team)",
601627 measured_pat: "{} measured",
602628 in_team_pat: "in team {}",
603- timer_in_team_pat: "timer in team {}",
629+ timer_in_team_pat: "timer running in team {}",
630+ stop_cta: "Stop",
631+ record_cta: "Record",
604632 login_title: "Sign in",
605633 tagline: "Time spent on each category, shared with your team.",
606634 username_label: "Username",
@@ -612,6 +640,8 @@ pub static EN: Tr = Tr {
612640 title_select_duration: "How much time?",
613641 add_choice_hint: "Track time as it passes, or enter a duration you already spent.",
614642 choice_timer_sub: "You'll pick the category when stopping it.",
643+ timer_elsewhere_pat: "A timer is already running in team {} — stop it before starting one here.",
644+ measure_elsewhere_pat: "A measured time is waiting to be recorded in team {} — record it before starting another timer.",
615645 choice_manual_sub: "A duration already spent, in two steps.",
616646 back: "← Back",
617647 step1_hint: "Step 1 of 2 — pick the category",
@@ -730,6 +760,13 @@ pub static EN: Tr = Tr {
730760 theme_hc: "High contrast",
731761 theme_hc_desc: "Black and white, readable in very low light or with a warm filter.",
732762 toast_theme_updated: "Display updated",
763+ chart_style: "Charts",
764+ chart_style_hint: "How the charts of the Stats tab are drawn, weekly activity included.",
765+ chart_style_columns: "Columns",
766+ chart_style_columns_desc: "Vertical bars, side by side.",
767+ chart_style_rows: "Rows",
768+ chart_style_rows_desc: "Horizontal bars, stacked one under the other.",
769+ toast_chart_style_updated: "Charts updated",
733770 session: "Session",
734771 current_password: "Current password",
735772 new_password: "New password",
@@ -830,12 +867,14 @@ pub static DE: Tr = Tr {
830867 member_many: "Mitglieder",
831868 start_timer: "Timer starten",
832869 stop_timer_aria: "Timer stoppen",
833- timer_running_note: "läuft",
870+ timer_running_note: "Timer läuft",
871+ measure_pending_note: "zu erfassen",
834872 record_measured_aria: "Gemessene Zeit erfassen",
835- record_measured_switch_aria: "Gemessene Zeit erfassen (wechselt das Team)",
836873 measured_pat: "{} gemessen",
837874 in_team_pat: "im Team {}",
838- timer_in_team_pat: "Timer im Team {}",
875+ timer_in_team_pat: "Timer läuft im Team {}",
876+ stop_cta: "Stoppen",
877+ record_cta: "Erfassen",
839878 login_title: "Anmeldung",
840879 tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.",
841880 username_label: "Benutzername",
@@ -847,6 +886,8 @@ pub static DE: Tr = Tr {
847886 title_select_duration: "Wieviel Zeit?",
848887 add_choice_hint: "Miss die Zeit, während sie läuft, oder erfasse eine bereits verbrachte Dauer.",
849888 choice_timer_sub: "Die Kategorie wählst du beim Stoppen.",
889+ timer_elsewhere_pat: "Im Team {} läuft schon ein Timer — stoppe ihn, bevor du hier einen startest.",
890+ measure_elsewhere_pat: "Im Team {} wartet eine gemessene Zeit auf die Erfassung — erfasse sie, bevor du wieder einen Timer startest.",
850891 choice_manual_sub: "Eine bereits verbrachte Dauer, in zwei Schritten.",
851892 back: "← Zurück",
852893 step1_hint: "Schritt 1 von 2 — wähle die Kategorie",
@@ -965,6 +1006,13 @@ pub static DE: Tr = Tr {
9651006 theme_hc: "Hoher Kontrast",
9661007 theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.",
9671008 toast_theme_updated: "Anzeige aktualisiert",
1009+ chart_style: "Diagramme",
1010+ chart_style_hint: "Die Darstellung der Diagramme im Tab Statistik, Wochenaktivität inklusive.",
1011+ chart_style_columns: "Spalten",
1012+ chart_style_columns_desc: "Senkrechte Balken nebeneinander.",
1013+ chart_style_rows: "Zeilen",
1014+ chart_style_rows_desc: "Waagerechte Balken untereinander.",
1015+ toast_chart_style_updated: "Diagramme aktualisiert",
9681016 session: "Sitzung",
9691017 current_password: "Aktuelles Passwort",
9701018 new_password: "Neues Passwort",
@@ -1065,12 +1113,14 @@ pub static PT_BR: Tr = Tr {
10651113 member_many: "membros",
10661114 start_timer: "Iniciar o cronômetro",
10671115 stop_timer_aria: "Parar o cronômetro",
1068- timer_running_note: "em andamento",
1116+ timer_running_note: "cronômetro em andamento",
1117+ measure_pending_note: "a registrar",
10691118 record_measured_aria: "Registrar o tempo medido",
1070- record_measured_switch_aria: "Registrar o tempo medido (troca de equipe)",
10711119 measured_pat: "{} medidos",
10721120 in_team_pat: "na equipe {}",
1073- timer_in_team_pat: "cronômetro na equipe {}",
1121+ timer_in_team_pat: "cronômetro em andamento na equipe {}",
1122+ stop_cta: "Parar",
1123+ record_cta: "Registrar",
10741124 login_title: "Entrar",
10751125 tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.",
10761126 username_label: "Usuário",
@@ -1082,6 +1132,8 @@ pub static PT_BR: Tr = Tr {
10821132 title_select_duration: "Quanto tempo?",
10831133 add_choice_hint: "Meça o tempo enquanto ele passa, ou informe uma duração que você já gastou.",
10841134 choice_timer_sub: "Você escolhe a categoria ao parar.",
1135+ timer_elsewhere_pat: "Já há um cronômetro em andamento na equipe {} — pare-o antes de iniciar um aqui.",
1136+ measure_elsewhere_pat: "Um tempo medido espera para ser registrado na equipe {} — registre-o antes de iniciar outro cronômetro.",
10851137 choice_manual_sub: "Uma duração já gasta, em duas etapas.",
10861138 back: "← Voltar",
10871139 step1_hint: "Etapa 1 de 2 — escolha a categoria",
@@ -1200,6 +1252,13 @@ pub static PT_BR: Tr = Tr {
12001252 theme_hc: "Alto contraste",
12011253 theme_hc_desc: "Preto e branco, legível com pouquíssima luz ou com filtro quente.",
12021254 toast_theme_updated: "Exibição atualizada",
1255+ chart_style: "Gráficos",
1256+ chart_style_hint: "O formato dos gráficos da aba Estatísticas, incluindo a atividade da semana.",
1257+ chart_style_columns: "Colunas",
1258+ chart_style_columns_desc: "Barras verticais lado a lado.",
1259+ chart_style_rows: "Linhas",
1260+ chart_style_rows_desc: "Barras horizontais empilhadas uma sob a outra.",
1261+ toast_chart_style_updated: "Gráficos atualizados",
12031262 session: "Sessão",
12041263 current_password: "Senha atual",
12051264 new_password: "Nova senha",
@@ -80,11 +80,15 @@ pub struct Tr {
80 pub start_timer: &'static str,80 pub start_timer: &'static str,
81 pub stop_timer_aria: &'static str,81 pub stop_timer_aria: &'static str,
82 pub timer_running_note: &'static str,82 pub timer_running_note: &'static str,
83+ pub measure_pending_note: &'static str,
83 pub record_measured_aria: &'static str,84 pub record_measured_aria: &'static str,
84- pub record_measured_switch_aria: &'static str,
85 pub measured_pat: &'static str,85 pub measured_pat: &'static str,
86+ /// Notes du dock quand le timer (ou la mesure) vit dans une autre équipe.
86 pub in_team_pat: &'static str,87 pub in_team_pat: &'static str,
87 pub timer_in_team_pat: &'static str,88 pub timer_in_team_pat: &'static str,
89+ /// Pastille d'action du dock du minuteur.
90+ pub stop_cta: &'static str,
91+ pub record_cta: &'static str,
88 // ─── login ───92 // ─── login ───
89 pub login_title: &'static str,93 pub login_title: &'static str,
90 pub tagline: &'static str,94 pub tagline: &'static str,
@@ -99,6 +103,9 @@ pub struct Tr {
99 /// Écran de choix (deux boutons) de l'onglet Ajouter.103 /// Écran de choix (deux boutons) de l'onglet Ajouter.
100 pub add_choice_hint: &'static str,104 pub add_choice_hint: &'static str,
101 pub choice_timer_sub: &'static str,105 pub choice_timer_sub: &'static str,
106+ /// Pourquoi « Lancer le minuteur » est désactivé (timer/mesure ailleurs).
107+ pub timer_elsewhere_pat: &'static str,
108+ pub measure_elsewhere_pat: &'static str,
102 pub choice_manual_sub: &'static str,109 pub choice_manual_sub: &'static str,
103 pub back: &'static str,110 pub back: &'static str,
104 pub step1_hint: &'static str,111 pub step1_hint: &'static str,
@@ -230,6 +237,14 @@ pub struct Tr {
230 pub theme_hc: &'static str,237 pub theme_hc: &'static str,
231 pub theme_hc_desc: &'static str,238 pub theme_hc_desc: &'static str,
232 pub toast_theme_updated: &'static str,239 pub toast_theme_updated: &'static str,
240+ /// Section « Graphiques » des réglages (format des stats).
241+ pub chart_style: &'static str,
242+ pub chart_style_hint: &'static str,
243+ pub chart_style_columns: &'static str,
244+ pub chart_style_columns_desc: &'static str,
245+ pub chart_style_rows: &'static str,
246+ pub chart_style_rows_desc: &'static str,
247+ pub toast_chart_style_updated: &'static str,
233 pub session: &'static str,248 pub session: &'static str,
234 pub current_password: &'static str,249 pub current_password: &'static str,
235 pub new_password: &'static str,250 pub new_password: &'static str,
@@ -360,12 +375,14 @@ pub static FR: Tr = Tr {
360 member_many: "membres",375 member_many: "membres",
361 start_timer: "Lancer le minuteur",376 start_timer: "Lancer le minuteur",
362 stop_timer_aria: "Arrêter le timer",377 stop_timer_aria: "Arrêter le timer",
363- timer_running_note: "en cours",378+ timer_running_note: "minuteur en cours",
379+ measure_pending_note: "à enregistrer",
364 record_measured_aria: "Enregistrer le temps mesuré",380 record_measured_aria: "Enregistrer le temps mesuré",
365- record_measured_switch_aria: "Enregistrer le temps mesuré (bascule d'équipe)",
366 measured_pat: "{} mesurées",381 measured_pat: "{} mesurées",
367 in_team_pat: "dans l'équipe {}",382 in_team_pat: "dans l'équipe {}",
368- timer_in_team_pat: "timer dans l'équipe {}",383+ timer_in_team_pat: "minuteur en cours dans l'équipe {}",
384+ stop_cta: "Arrêter",
385+ record_cta: "Enregistrer",
369 login_title: "Connexion",386 login_title: "Connexion",
370 tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.",387 tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.",
371 username_label: "Identifiant",388 username_label: "Identifiant",
@@ -377,6 +394,8 @@ pub static FR: Tr = Tr {
377 title_select_duration: "Combien de temps ?",394 title_select_duration: "Combien de temps ?",
378 add_choice_hint: "Compte le temps au fur et à mesure, ou saisis une durée que tu as déjà passée.",395 add_choice_hint: "Compte le temps au fur et à mesure, ou saisis une durée que tu as déjà passée.",
379 choice_timer_sub: "Tu choisiras la catégorie en l'arrêtant.",396 choice_timer_sub: "Tu choisiras la catégorie en l'arrêtant.",
397+ timer_elsewhere_pat: "Un minuteur tourne déjà dans l'équipe {} — arrête-le avant d'en lancer un ici.",
398+ measure_elsewhere_pat: "Un temps mesuré attend d'être enregistré dans l'équipe {} — enregistre-le avant de relancer un minuteur.",
380 choice_manual_sub: "Une durée déjà passée, en deux étapes.",399 choice_manual_sub: "Une durée déjà passée, en deux étapes.",
381 back: "← Retour",400 back: "← Retour",
382 step1_hint: "Étape 1 sur 2 — choisis la catégorie",401 step1_hint: "Étape 1 sur 2 — choisis la catégorie",
@@ -495,6 +514,13 @@ pub static FR: Tr = Tr {
495 theme_hc: "Contraste élevé",514 theme_hc: "Contraste élevé",
496 theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.",515 theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.",
497 toast_theme_updated: "Affichage mis à jour",516 toast_theme_updated: "Affichage mis à jour",
517+ chart_style: "Graphiques",
518+ chart_style_hint: "Le format des graphiques de l'onglet Stats, activité de la semaine comprise.",
519+ chart_style_columns: "Colonnes",
520+ chart_style_columns_desc: "Des barres verticales côte à côte.",
521+ chart_style_rows: "Lignes",
522+ chart_style_rows_desc: "Des barres horizontales empilées les unes sous les autres.",
523+ toast_chart_style_updated: "Graphiques mis à jour",
498 session: "Session",524 session: "Session",
499 current_password: "Mot de passe actuel",525 current_password: "Mot de passe actuel",
500 new_password: "Nouveau mot de passe",526 new_password: "Nouveau mot de passe",
@@ -595,12 +621,14 @@ pub static EN: Tr = Tr {
595 member_many: "members",621 member_many: "members",
596 start_timer: "Start the timer",622 start_timer: "Start the timer",
597 stop_timer_aria: "Stop timer",623 stop_timer_aria: "Stop timer",
598- timer_running_note: "running",624+ timer_running_note: "timer running",
625+ measure_pending_note: "to record",
599 record_measured_aria: "Record the measured time",626 record_measured_aria: "Record the measured time",
600- record_measured_switch_aria: "Record the measured time (switches team)",
601 measured_pat: "{} measured",627 measured_pat: "{} measured",
602 in_team_pat: "in team {}",628 in_team_pat: "in team {}",
603- timer_in_team_pat: "timer in team {}",629+ timer_in_team_pat: "timer running in team {}",
630+ stop_cta: "Stop",
631+ record_cta: "Record",
604 login_title: "Sign in",632 login_title: "Sign in",
605 tagline: "Time spent on each category, shared with your team.",633 tagline: "Time spent on each category, shared with your team.",
606 username_label: "Username",634 username_label: "Username",
@@ -612,6 +640,8 @@ pub static EN: Tr = Tr {
612 title_select_duration: "How much time?",640 title_select_duration: "How much time?",
613 add_choice_hint: "Track time as it passes, or enter a duration you already spent.",641 add_choice_hint: "Track time as it passes, or enter a duration you already spent.",
614 choice_timer_sub: "You'll pick the category when stopping it.",642 choice_timer_sub: "You'll pick the category when stopping it.",
643+ timer_elsewhere_pat: "A timer is already running in team {} — stop it before starting one here.",
644+ measure_elsewhere_pat: "A measured time is waiting to be recorded in team {} — record it before starting another timer.",
615 choice_manual_sub: "A duration already spent, in two steps.",645 choice_manual_sub: "A duration already spent, in two steps.",
616 back: "← Back",646 back: "← Back",
617 step1_hint: "Step 1 of 2 — pick the category",647 step1_hint: "Step 1 of 2 — pick the category",
@@ -730,6 +760,13 @@ pub static EN: Tr = Tr {
730 theme_hc: "High contrast",760 theme_hc: "High contrast",
731 theme_hc_desc: "Black and white, readable in very low light or with a warm filter.",761 theme_hc_desc: "Black and white, readable in very low light or with a warm filter.",
732 toast_theme_updated: "Display updated",762 toast_theme_updated: "Display updated",
763+ chart_style: "Charts",
764+ chart_style_hint: "How the charts of the Stats tab are drawn, weekly activity included.",
765+ chart_style_columns: "Columns",
766+ chart_style_columns_desc: "Vertical bars, side by side.",
767+ chart_style_rows: "Rows",
768+ chart_style_rows_desc: "Horizontal bars, stacked one under the other.",
769+ toast_chart_style_updated: "Charts updated",
733 session: "Session",770 session: "Session",
734 current_password: "Current password",771 current_password: "Current password",
735 new_password: "New password",772 new_password: "New password",
@@ -830,12 +867,14 @@ pub static DE: Tr = Tr {
830 member_many: "Mitglieder",867 member_many: "Mitglieder",
831 start_timer: "Timer starten",868 start_timer: "Timer starten",
832 stop_timer_aria: "Timer stoppen",869 stop_timer_aria: "Timer stoppen",
833- timer_running_note: "läuft",870+ timer_running_note: "Timer läuft",
871+ measure_pending_note: "zu erfassen",
834 record_measured_aria: "Gemessene Zeit erfassen",872 record_measured_aria: "Gemessene Zeit erfassen",
835- record_measured_switch_aria: "Gemessene Zeit erfassen (wechselt das Team)",
836 measured_pat: "{} gemessen",873 measured_pat: "{} gemessen",
837 in_team_pat: "im Team {}",874 in_team_pat: "im Team {}",
838- timer_in_team_pat: "Timer im Team {}",875+ timer_in_team_pat: "Timer läuft im Team {}",
876+ stop_cta: "Stoppen",
877+ record_cta: "Erfassen",
839 login_title: "Anmeldung",878 login_title: "Anmeldung",
840 tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.",879 tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.",
841 username_label: "Benutzername",880 username_label: "Benutzername",
@@ -847,6 +886,8 @@ pub static DE: Tr = Tr {
847 title_select_duration: "Wieviel Zeit?",886 title_select_duration: "Wieviel Zeit?",
848 add_choice_hint: "Miss die Zeit, während sie läuft, oder erfasse eine bereits verbrachte Dauer.",887 add_choice_hint: "Miss die Zeit, während sie läuft, oder erfasse eine bereits verbrachte Dauer.",
849 choice_timer_sub: "Die Kategorie wählst du beim Stoppen.",888 choice_timer_sub: "Die Kategorie wählst du beim Stoppen.",
889+ timer_elsewhere_pat: "Im Team {} läuft schon ein Timer — stoppe ihn, bevor du hier einen startest.",
890+ measure_elsewhere_pat: "Im Team {} wartet eine gemessene Zeit auf die Erfassung — erfasse sie, bevor du wieder einen Timer startest.",
850 choice_manual_sub: "Eine bereits verbrachte Dauer, in zwei Schritten.",891 choice_manual_sub: "Eine bereits verbrachte Dauer, in zwei Schritten.",
851 back: "← Zurück",892 back: "← Zurück",
852 step1_hint: "Schritt 1 von 2 — wähle die Kategorie",893 step1_hint: "Schritt 1 von 2 — wähle die Kategorie",
@@ -965,6 +1006,13 @@ pub static DE: Tr = Tr {
965 theme_hc: "Hoher Kontrast",1006 theme_hc: "Hoher Kontrast",
966 theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.",1007 theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.",
967 toast_theme_updated: "Anzeige aktualisiert",1008 toast_theme_updated: "Anzeige aktualisiert",
1009+ chart_style: "Diagramme",
1010+ chart_style_hint: "Die Darstellung der Diagramme im Tab Statistik, Wochenaktivität inklusive.",
1011+ chart_style_columns: "Spalten",
1012+ chart_style_columns_desc: "Senkrechte Balken nebeneinander.",
1013+ chart_style_rows: "Zeilen",
1014+ chart_style_rows_desc: "Waagerechte Balken untereinander.",
1015+ toast_chart_style_updated: "Diagramme aktualisiert",
968 session: "Sitzung",1016 session: "Sitzung",
969 current_password: "Aktuelles Passwort",1017 current_password: "Aktuelles Passwort",
970 new_password: "Neues Passwort",1018 new_password: "Neues Passwort",
@@ -1065,12 +1113,14 @@ pub static PT_BR: Tr = Tr {
1065 member_many: "membros",1113 member_many: "membros",
1066 start_timer: "Iniciar o cronômetro",1114 start_timer: "Iniciar o cronômetro",
1067 stop_timer_aria: "Parar o cronômetro",1115 stop_timer_aria: "Parar o cronômetro",
1068- timer_running_note: "em andamento",1116+ timer_running_note: "cronômetro em andamento",
1117+ measure_pending_note: "a registrar",
1069 record_measured_aria: "Registrar o tempo medido",1118 record_measured_aria: "Registrar o tempo medido",
1070- record_measured_switch_aria: "Registrar o tempo medido (troca de equipe)",
1071 measured_pat: "{} medidos",1119 measured_pat: "{} medidos",
1072 in_team_pat: "na equipe {}",1120 in_team_pat: "na equipe {}",
1073- timer_in_team_pat: "cronômetro na equipe {}",1121+ timer_in_team_pat: "cronômetro em andamento na equipe {}",
1122+ stop_cta: "Parar",
1123+ record_cta: "Registrar",
1074 login_title: "Entrar",1124 login_title: "Entrar",
1075 tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.",1125 tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.",
1076 username_label: "Usuário",1126 username_label: "Usuário",
@@ -1082,6 +1132,8 @@ pub static PT_BR: Tr = Tr {
1082 title_select_duration: "Quanto tempo?",1132 title_select_duration: "Quanto tempo?",
1083 add_choice_hint: "Meça o tempo enquanto ele passa, ou informe uma duração que você já gastou.",1133 add_choice_hint: "Meça o tempo enquanto ele passa, ou informe uma duração que você já gastou.",
1084 choice_timer_sub: "Você escolhe a categoria ao parar.",1134 choice_timer_sub: "Você escolhe a categoria ao parar.",
1135+ timer_elsewhere_pat: "Já há um cronômetro em andamento na equipe {} — pare-o antes de iniciar um aqui.",
1136+ measure_elsewhere_pat: "Um tempo medido espera para ser registrado na equipe {} — registre-o antes de iniciar outro cronômetro.",
1085 choice_manual_sub: "Uma duração já gasta, em duas etapas.",1137 choice_manual_sub: "Uma duração já gasta, em duas etapas.",
1086 back: "← Voltar",1138 back: "← Voltar",
1087 step1_hint: "Etapa 1 de 2 — escolha a categoria",1139 step1_hint: "Etapa 1 de 2 — escolha a categoria",
@@ -1200,6 +1252,13 @@ pub static PT_BR: Tr = Tr {
1200 theme_hc: "Alto contraste",1252 theme_hc: "Alto contraste",
1201 theme_hc_desc: "Preto e branco, legível com pouquíssima luz ou com filtro quente.",1253 theme_hc_desc: "Preto e branco, legível com pouquíssima luz ou com filtro quente.",
1202 toast_theme_updated: "Exibição atualizada",1254 toast_theme_updated: "Exibição atualizada",
1255+ chart_style: "Gráficos",
1256+ chart_style_hint: "O formato dos gráficos da aba Estatísticas, incluindo a atividade da semana.",
1257+ chart_style_columns: "Colunas",
1258+ chart_style_columns_desc: "Barras verticais lado a lado.",
1259+ chart_style_rows: "Linhas",
1260+ chart_style_rows_desc: "Barras horizontais empilhadas uma sob a outra.",
1261+ toast_chart_style_updated: "Gráficos atualizados",
1203 session: "Sessão",1262 session: "Sessão",
1204 current_password: "Senha atual",1263 current_password: "Senha atual",
1205 new_password: "Nova senha",1264 new_password: "Nova senha",
modified src/render.rs +11 -4
@@ -27,16 +27,21 @@ pub struct TeamChoiceView {
2727 pub color: &'static str,
2828 }
2929
30-/// Pill timer flottante du shell — None dans Shell = repos (bouton ▶).
30+/// Dock du minuteur (bandeau sous l'en-tête) — None dans Shell = repos.
3131 pub struct TimerView {
3232 /// Timer en cours (sinon : mesure figée en attente d'enregistrement).
3333 pub running: bool,
34- /// Timer d'une autre équipe que l'équipe active : pill inerte.
34+ /// Timer (ou mesure) d'une autre équipe que l'équipe active : le clic
35+ /// bascule dessus au passage — d'où un dock visible même sur Ajouter.
3536 pub foreign: bool,
3637 /// « 47 min » (en cours) ou « 47 min mesurées » (en attente).
3738 pub label: String,
38- /// « en cours », « timer dans l'équipe X »… — vide = rien à afficher.
39+ /// « minuteur en cours », « à enregistrer », « équipe Chez nous ».
3940 pub note: String,
41+ /// Libellé de la pastille d'action (« Arrêter » / « Enregistrer »).
42+ pub cta: &'static str,
43+ /// Libellé accessible du bouton entier (l'équipe y est nommée).
44+ pub aria: String,
4045 /// Départ en epoch ms — rafraîchissement JS toutes les 10 s.
4146 pub started_ms: i64,
4247 }
@@ -55,6 +60,8 @@ pub struct Shell {
5560 pub team_color: &'static str,
5661 /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>).
5762 pub hc: bool,
63+ /// Graphiques de Stats en barres horizontales plutôt qu'en colonnes.
64+ pub stats_rows: bool,
5865 /// "add" | "stats" | "times" | "settings"
5966 pub tab: &'static str,
6067 pub toast: Option<String>,
@@ -62,7 +69,7 @@ pub struct Shell {
6269 pub invite_count: i64,
6370 /// Tâches « à faire » actives de l'équipe — badge sur l'onglet Tâches.
6471 pub todo_count: i64,
65- /// Jeton des forms de la pill timer (démarrer / arrêter).
72+ /// Jeton des forms du dock timer (démarrer / arrêter).
6673 pub csrf: String,
6774 /// Où revenir après un démarrage de timer (page courante, déduite du tab).
6875 pub start_return: &'static str,
@@ -27,16 +27,21 @@ pub struct TeamChoiceView {
27 pub color: &'static str,27 pub color: &'static str,
28 }28 }
29 29
30-/// Pill timer flottante du shell — None dans Shell = repos (bouton ▶).30+/// Dock du minuteur (bandeau sous l'en-tête) — None dans Shell = repos.
31 pub struct TimerView {31 pub struct TimerView {
32 /// Timer en cours (sinon : mesure figée en attente d'enregistrement).32 /// Timer en cours (sinon : mesure figée en attente d'enregistrement).
33 pub running: bool,33 pub running: bool,
34- /// Timer d'une autre équipe que l'équipe active : pill inerte.34+ /// Timer (ou mesure) d'une autre équipe que l'équipe active : le clic
35+ /// bascule dessus au passage — d'où un dock visible même sur Ajouter.
35 pub foreign: bool,36 pub foreign: bool,
36 /// « 47 min » (en cours) ou « 47 min mesurées » (en attente).37 /// « 47 min » (en cours) ou « 47 min mesurées » (en attente).
37 pub label: String,38 pub label: String,
38- /// « en cours », « timer dans l'équipe X »… — vide = rien à afficher.39+ /// « minuteur en cours », « à enregistrer », « équipe Chez nous ».
39 pub note: String,40 pub note: String,
41+ /// Libellé de la pastille d'action (« Arrêter » / « Enregistrer »).
42+ pub cta: &'static str,
43+ /// Libellé accessible du bouton entier (l'équipe y est nommée).
44+ pub aria: String,
40 /// Départ en epoch ms — rafraîchissement JS toutes les 10 s.45 /// Départ en epoch ms — rafraîchissement JS toutes les 10 s.
41 pub started_ms: i64,46 pub started_ms: i64,
42 }47 }
@@ -55,6 +60,8 @@ pub struct Shell {
55 pub team_color: &'static str,60 pub team_color: &'static str,
56 /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>).61 /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>).
57 pub hc: bool,62 pub hc: bool,
63+ /// Graphiques de Stats en barres horizontales plutôt qu'en colonnes.
64+ pub stats_rows: bool,
58 /// "add" | "stats" | "times" | "settings"65 /// "add" | "stats" | "times" | "settings"
59 pub tab: &'static str,66 pub tab: &'static str,
60 pub toast: Option<String>,67 pub toast: Option<String>,
@@ -62,7 +69,7 @@ pub struct Shell {
62 pub invite_count: i64,69 pub invite_count: i64,
63 /// Tâches « à faire » actives de l'équipe — badge sur l'onglet Tâches.70 /// Tâches « à faire » actives de l'équipe — badge sur l'onglet Tâches.
64 pub todo_count: i64,71 pub todo_count: i64,
65- /// Jeton des forms de la pill timer (démarrer / arrêter).72+ /// Jeton des forms du dock timer (démarrer / arrêter).
66 pub csrf: String,73 pub csrf: String,
67 /// Où revenir après un démarrage de timer (page courante, déduite du tab).74 /// Où revenir après un démarrage de timer (page courante, déduite du tab).
68 pub start_return: &'static str,75 pub start_return: &'static str,
modified src/routes/entries.rs +16 -0
@@ -44,6 +44,10 @@ pub struct MeasuredView {
4444 #[template(path = "add_choice.html")]
4545 pub struct AddChoicePage {
4646 pub shell: Shell,
47+ /// Un timer (ou une mesure) occupe déjà une AUTRE équipe : `timers::start`
48+ /// ne créerait aucune ligne (`ON CONFLICT (user_id) DO NOTHING`), donc le
49+ /// bouton « Lancer le minuteur » est désactivé et dit pourquoi.
50+ pub busy: Option<String>,
4751 }
4852
4953 #[derive(Template)]
@@ -148,6 +152,17 @@ pub async fn entry_new(
148152 .into_response());
149153 }
150154
155+ // Ici, tout timer restant vit dans une AUTRE équipe (celui de l'équipe
156+ // active en cours est parti sur l'écran minuteur juste au-dessus).
157+ let busy = timer_row.as_ref().filter(|t| t.team_id != ctx.team_id).map(|t| {
158+ let pat = if t.stopped_at.is_some() {
159+ tr.measure_elsewhere_pat
160+ } else {
161+ tr.timer_elsewhere_pat
162+ };
163+ p1(pat, &t.team_name)
164+ });
165+
151166 // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2.
152167 let pending = timer_row.filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());
153168
@@ -157,6 +172,7 @@ pub async fn entry_new(
157172 if q.categorie.is_none() && q.mode.is_none() && pending.is_none() {
158173 return Ok(HtmlTemplate(AddChoicePage {
159174 shell: shell(&state, &ctx, tr.title_add, "add", toast).await?,
175+ busy,
160176 })
161177 .into_response());
162178 }
@@ -44,6 +44,10 @@ pub struct MeasuredView {
44 #[template(path = "add_choice.html")]44 #[template(path = "add_choice.html")]
45 pub struct AddChoicePage {45 pub struct AddChoicePage {
46 pub shell: Shell,46 pub shell: Shell,
47+ /// Un timer (ou une mesure) occupe déjà une AUTRE équipe : `timers::start`
48+ /// ne créerait aucune ligne (`ON CONFLICT (user_id) DO NOTHING`), donc le
49+ /// bouton « Lancer le minuteur » est désactivé et dit pourquoi.
50+ pub busy: Option<String>,
47 }51 }
48 52
49 #[derive(Template)]53 #[derive(Template)]
@@ -148,6 +152,17 @@ pub async fn entry_new(
148 .into_response());152 .into_response());
149 }153 }
150 154
155+ // Ici, tout timer restant vit dans une AUTRE équipe (celui de l'équipe
156+ // active en cours est parti sur l'écran minuteur juste au-dessus).
157+ let busy = timer_row.as_ref().filter(|t| t.team_id != ctx.team_id).map(|t| {
158+ let pat = if t.stopped_at.is_some() {
159+ tr.measure_elsewhere_pat
160+ } else {
161+ tr.timer_elsewhere_pat
162+ };
163+ p1(pat, &t.team_name)
164+ });
165+
151 // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2.166 // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2.
152 let pending = timer_row.filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());167 let pending = timer_row.filter(|t| t.team_id == ctx.team_id && t.stopped_at.is_some());
153 168
@@ -157,6 +172,7 @@ pub async fn entry_new(
157 if q.categorie.is_none() && q.mode.is_none() && pending.is_none() {172 if q.categorie.is_none() && q.mode.is_none() && pending.is_none() {
158 return Ok(HtmlTemplate(AddChoicePage {173 return Ok(HtmlTemplate(AddChoicePage {
159 shell: shell(&state, &ctx, tr.title_add, "add", toast).await?,174 shell: shell(&state, &ctx, tr.title_add, "add", toast).await?,
175+ busy,
160 })176 })
161 .into_response());177 .into_response());
162 }178 }
modified src/routes/mod.rs +38 -23
@@ -50,6 +50,7 @@ pub fn router(state: AppState) -> Router {
5050 )
5151 .route("/parametres/langue", post(settings::lang_change))
5252 .route("/parametres/affichage", post(settings::theme_change))
53+ .route("/parametres/graphiques", post(settings::chart_style_change))
5354 .route("/taches", get(todos::todos_page).post(todos::todo_create))
5455 .route("/taches/nouvelle", get(todos::todo_new))
5556 .route("/taches/{id}/modifier", get(todos::todo_edit))
@@ -169,31 +170,44 @@ pub async fn shell(
169170 let badge = timer_row.is_some();
170171 let timer = timer_row.map(|t| {
171172 let foreign = t.team_id != ctx.team_id;
172- match t.measured_minutes() {
173- Some(m) => TimerView {
174- running: false,
175- foreign,
176- label: crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),
177- note: if foreign {
178- crate::i18n::p1(tr.in_team_pat, &t.team_name)
179- } else {
180- String::new()
181- },
182- started_ms: t.started_at.timestamp_millis(),
183- },
184- None => TimerView {
185- running: true,
186- foreign,
187- label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())),
188- note: if foreign {
189- crate::i18n::p1(tr.timer_in_team_pat, &t.team_name)
190- } else {
191- tr.timer_running_note.to_string()
192- },
193- started_ms: t.started_at.timestamp_millis(),
194- },
173+ let pending = t.measured_minutes().is_some();
174+ // Quand le timer vit ailleurs, la note nomme l'équipe — c'est
175+ // l'information que le dock est seul à porter (le clic y bascule au
176+ // passage). Le libellé « 47 min mesurées » dit déjà de quoi il s'agit
177+ // pour une mesure ; un minuteur en cours, non, d'où le motif long.
178+ let note = match (foreign, pending) {
179+ (true, true) => crate::i18n::p1(tr.in_team_pat, &t.team_name),
180+ (true, false) => crate::i18n::p1(tr.timer_in_team_pat, &t.team_name),
181+ (false, true) => tr.measure_pending_note.to_string(),
182+ (false, false) => tr.timer_running_note.to_string(),
183+ };
184+ let (running, label, cta, action) = match t.measured_minutes() {
185+ Some(m) => (
186+ false,
187+ crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),
188+ tr.record_cta,
189+ tr.record_measured_aria,
190+ ),
191+ None => (
192+ true,
193+ fmt_minutes(t.elapsed_minutes(chrono::Utc::now())),
194+ tr.stop_cta,
195+ tr.stop_timer_aria,
196+ ),
197+ };
198+ TimerView {
199+ running,
200+ foreign,
201+ // Le libellé accessible reprend tout le bouton : sans lui, la
202+ // durée et l'équipe échapperaient aux lecteurs d'écran.
203+ aria: format!("{label}, {note} — {action}"),
204+ label,
205+ note,
206+ cta,
207+ started_ms: t.started_at.timestamp_millis(),
195208 }
196209 });
210+
197211 Ok(Shell {
198212 title: title.to_string(),
199213 username: ctx.username.clone(),
@@ -202,6 +216,7 @@ pub async fn shell(
202216 team_name: ctx.team_name.clone(),
203217 team_color: team_color(ctx.team_id, ctx.hc),
204218 hc: ctx.hc,
219+ stats_rows: ctx.stats_rows,
205220 tab,
206221 toast,
207222 invite_count,
@@ -50,6 +50,7 @@ pub fn router(state: AppState) -> Router {
50 )50 )
51 .route("/parametres/langue", post(settings::lang_change))51 .route("/parametres/langue", post(settings::lang_change))
52 .route("/parametres/affichage", post(settings::theme_change))52 .route("/parametres/affichage", post(settings::theme_change))
53+ .route("/parametres/graphiques", post(settings::chart_style_change))
53 .route("/taches", get(todos::todos_page).post(todos::todo_create))54 .route("/taches", get(todos::todos_page).post(todos::todo_create))
54 .route("/taches/nouvelle", get(todos::todo_new))55 .route("/taches/nouvelle", get(todos::todo_new))
55 .route("/taches/{id}/modifier", get(todos::todo_edit))56 .route("/taches/{id}/modifier", get(todos::todo_edit))
@@ -169,31 +170,44 @@ pub async fn shell(
169 let badge = timer_row.is_some();170 let badge = timer_row.is_some();
170 let timer = timer_row.map(|t| {171 let timer = timer_row.map(|t| {
171 let foreign = t.team_id != ctx.team_id;172 let foreign = t.team_id != ctx.team_id;
172- match t.measured_minutes() {173+ let pending = t.measured_minutes().is_some();
173- Some(m) => TimerView {174+ // Quand le timer vit ailleurs, la note nomme l'équipe — c'est
174- running: false,175+ // l'information que le dock est seul à porter (le clic y bascule au
175- foreign,176+ // passage). Le libellé « 47 min mesurées » dit déjà de quoi il s'agit
176- label: crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),177+ // pour une mesure ; un minuteur en cours, non, d'où le motif long.
177- note: if foreign {178+ let note = match (foreign, pending) {
178- crate::i18n::p1(tr.in_team_pat, &t.team_name)179+ (true, true) => crate::i18n::p1(tr.in_team_pat, &t.team_name),
179- } else {180+ (true, false) => crate::i18n::p1(tr.timer_in_team_pat, &t.team_name),
180- String::new()181+ (false, true) => tr.measure_pending_note.to_string(),
181- },182+ (false, false) => tr.timer_running_note.to_string(),
182- started_ms: t.started_at.timestamp_millis(),183+ };
183- },184+ let (running, label, cta, action) = match t.measured_minutes() {
184- None => TimerView {185+ Some(m) => (
185- running: true,186+ false,
186- foreign,187+ crate::i18n::p1(tr.measured_pat, fmt_minutes(m)),
187- label: fmt_minutes(t.elapsed_minutes(chrono::Utc::now())),188+ tr.record_cta,
188- note: if foreign {189+ tr.record_measured_aria,
189- crate::i18n::p1(tr.timer_in_team_pat, &t.team_name)190+ ),
190- } else {191+ None => (
191- tr.timer_running_note.to_string()192+ true,
192- },193+ fmt_minutes(t.elapsed_minutes(chrono::Utc::now())),
193- started_ms: t.started_at.timestamp_millis(),194+ tr.stop_cta,
194- },195+ tr.stop_timer_aria,
196+ ),
197+ };
198+ TimerView {
199+ running,
200+ foreign,
201+ // Le libellé accessible reprend tout le bouton : sans lui, la
202+ // durée et l'équipe échapperaient aux lecteurs d'écran.
203+ aria: format!("{label}, {note} — {action}"),
204+ label,
205+ note,
206+ cta,
207+ started_ms: t.started_at.timestamp_millis(),
195 }208 }
196 });209 });
210+
197 Ok(Shell {211 Ok(Shell {
198 title: title.to_string(),212 title: title.to_string(),
199 username: ctx.username.clone(),213 username: ctx.username.clone(),
@@ -202,6 +216,7 @@ pub async fn shell(
202 team_name: ctx.team_name.clone(),216 team_name: ctx.team_name.clone(),
203 team_color: team_color(ctx.team_id, ctx.hc),217 team_color: team_color(ctx.team_id, ctx.hc),
204 hc: ctx.hc,218 hc: ctx.hc,
219+ stats_rows: ctx.stats_rows,
205 tab,220 tab,
206 toast,221 toast,
207 invite_count,222 invite_count,
modified src/routes/settings.rs +41 -0
@@ -47,6 +47,8 @@ pub struct SettingsPage {
4747 pub langs: Vec<LangView>,
4848 /// Sélecteur de thème (par utilisateur, en base — section Affichage).
4949 pub themes: Vec<ThemeView>,
50+ /// Sélecteur de format des graphiques (colonnes / barres).
51+ pub chart_styles: Vec<ThemeView>,
5052 pub members_title: String,
5153 pub leave_label: String,
5254 pub sent: Vec<SentView>,
@@ -78,6 +80,7 @@ pub async fn settings_page(
7880 Some("quittee") => Some(tr.toast_team_left.to_string()),
7981 Some("langue") => Some(tr.toast_lang_updated.to_string()),
8082 Some("affichage") => Some(tr.toast_theme_updated.to_string()),
83+ Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()),
8184 _ => match q.err.as_deref() {
8285 Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),
8386 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),
@@ -151,12 +154,27 @@ pub async fn settings_page(
151154 current: ctx.hc,
152155 },
153156 ];
157+ let chart_styles = vec![
158+ ThemeView {
159+ code: "columns",
160+ name: tr.chart_style_columns,
161+ desc: tr.chart_style_columns_desc,
162+ current: !ctx.stats_rows,
163+ },
164+ ThemeView {
165+ code: "rows",
166+ name: tr.chart_style_rows,
167+ desc: tr.chart_style_rows_desc,
168+ current: ctx.stats_rows,
169+ },
170+ ];
154171 Ok(HtmlTemplate(SettingsPage {
155172 csrf: ctx.user.csrf_token(&state),
156173 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,
157174 error,
158175 langs,
159176 themes,
177+ chart_styles,
160178 members_title: p1(tr.members_of_pat, &ctx.team_name),
161179 leave_label: p1(tr.leave_team_pat, &ctx.team_name),
162180 invites,
@@ -353,6 +371,12 @@ pub struct ThemeForm {
353371 csrf: String,
354372 }
355373
374+#[derive(Deserialize)]
375+pub struct ChartStyleForm {
376+ format: String,
377+ csrf: String,
378+}
379+
356380 /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base
357381 /// (jamais par navigateur : le réglage suit le compte partout).
358382 pub async fn lang_change(
@@ -387,6 +411,23 @@ pub async fn theme_change(
387411 Ok(Redirect::to("/parametres?ok=affichage"))
388412 }
389413
414+/// POST /parametres/graphiques — format des graphiques de Stats, PAR
415+/// UTILISATEUR (colonnes empilées ou barres horizontales).
416+pub async fn chart_style_change(
417+ State(state): State<AppState>,
418+ ctx: ActiveTeam,
419+ Form(form): Form<ChartStyleForm>,
420+) -> Result<Redirect, AppError> {
421+ if !ctx.user.check_csrf(&state, &form.csrf) {
422+ return Err(AppError::Csrf);
423+ }
424+ if !matches!(form.format.as_str(), "columns" | "rows") {
425+ return Err(AppError::BadRequest("bad stats layout".into()));
426+ }
427+ users::set_stats_layout(&state.pool, ctx.user.id, &form.format).await?;
428+ Ok(Redirect::to("/parametres?ok=graphiques"))
429+}
430+
390431 fn is_unique_violation(e: &sqlx::Error) -> bool {
391432 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
392433 }
@@ -47,6 +47,8 @@ pub struct SettingsPage {
47 pub langs: Vec<LangView>,47 pub langs: Vec<LangView>,
48 /// Sélecteur de thème (par utilisateur, en base — section Affichage).48 /// Sélecteur de thème (par utilisateur, en base — section Affichage).
49 pub themes: Vec<ThemeView>,49 pub themes: Vec<ThemeView>,
50+ /// Sélecteur de format des graphiques (colonnes / barres).
51+ pub chart_styles: Vec<ThemeView>,
50 pub members_title: String,52 pub members_title: String,
51 pub leave_label: String,53 pub leave_label: String,
52 pub sent: Vec<SentView>,54 pub sent: Vec<SentView>,
@@ -78,6 +80,7 @@ pub async fn settings_page(
78 Some("quittee") => Some(tr.toast_team_left.to_string()),80 Some("quittee") => Some(tr.toast_team_left.to_string()),
79 Some("langue") => Some(tr.toast_lang_updated.to_string()),81 Some("langue") => Some(tr.toast_lang_updated.to_string()),
80 Some("affichage") => Some(tr.toast_theme_updated.to_string()),82 Some("affichage") => Some(tr.toast_theme_updated.to_string()),
83+ Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()),
81 _ => match q.err.as_deref() {84 _ => match q.err.as_deref() {
82 Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),85 Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()),
83 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),86 Some("eq-vide") => Some(tr.err_team_name_empty.to_string()),
@@ -151,12 +154,27 @@ pub async fn settings_page(
151 current: ctx.hc,154 current: ctx.hc,
152 },155 },
153 ];156 ];
157+ let chart_styles = vec![
158+ ThemeView {
159+ code: "columns",
160+ name: tr.chart_style_columns,
161+ desc: tr.chart_style_columns_desc,
162+ current: !ctx.stats_rows,
163+ },
164+ ThemeView {
165+ code: "rows",
166+ name: tr.chart_style_rows,
167+ desc: tr.chart_style_rows_desc,
168+ current: ctx.stats_rows,
169+ },
170+ ];
154 Ok(HtmlTemplate(SettingsPage {171 Ok(HtmlTemplate(SettingsPage {
155 csrf: ctx.user.csrf_token(&state),172 csrf: ctx.user.csrf_token(&state),
156 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,173 shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?,
157 error,174 error,
158 langs,175 langs,
159 themes,176 themes,
177+ chart_styles,
160 members_title: p1(tr.members_of_pat, &ctx.team_name),178 members_title: p1(tr.members_of_pat, &ctx.team_name),
161 leave_label: p1(tr.leave_team_pat, &ctx.team_name),179 leave_label: p1(tr.leave_team_pat, &ctx.team_name),
162 invites,180 invites,
@@ -353,6 +371,12 @@ pub struct ThemeForm {
353 csrf: String,371 csrf: String,
354 }372 }
355 373
374+#[derive(Deserialize)]
375+pub struct ChartStyleForm {
376+ format: String,
377+ csrf: String,
378+}
379+
356 /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base380 /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base
357 /// (jamais par navigateur : le réglage suit le compte partout).381 /// (jamais par navigateur : le réglage suit le compte partout).
358 pub async fn lang_change(382 pub async fn lang_change(
@@ -387,6 +411,23 @@ pub async fn theme_change(
387 Ok(Redirect::to("/parametres?ok=affichage"))411 Ok(Redirect::to("/parametres?ok=affichage"))
388 }412 }
389 413
414+/// POST /parametres/graphiques — format des graphiques de Stats, PAR
415+/// UTILISATEUR (colonnes empilées ou barres horizontales).
416+pub async fn chart_style_change(
417+ State(state): State<AppState>,
418+ ctx: ActiveTeam,
419+ Form(form): Form<ChartStyleForm>,
420+) -> Result<Redirect, AppError> {
421+ if !ctx.user.check_csrf(&state, &form.csrf) {
422+ return Err(AppError::Csrf);
423+ }
424+ if !matches!(form.format.as_str(), "columns" | "rows") {
425+ return Err(AppError::BadRequest("bad stats layout".into()));
426+ }
427+ users::set_stats_layout(&state.pool, ctx.user.id, &form.format).await?;
428+ Ok(Redirect::to("/parametres?ok=graphiques"))
429+}
430+
390 fn is_unique_violation(e: &sqlx::Error) -> bool {431 fn is_unique_violation(e: &sqlx::Error) -> bool {
391 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())432 matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
392 }433 }
modified src/team.rs +7 -1
@@ -42,6 +42,9 @@ pub struct ActiveTeam {
4242 pub lang: Lang,
4343 /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base.
4444 pub hc: bool,
45+ /// Graphiques de Stats en barres horizontales (users.stats_layout =
46+ /// 'rows') — par utilisateur, en base, comme la langue et le thème.
47+ pub stats_rows: bool,
4548 pub team_id: Uuid,
4649 pub team_name: String,
4750 }
@@ -80,11 +83,12 @@ impl FromRequestParts<AppState> for ActiveTeam {
8083 state: &AppState,
8184 ) -> Result<Self, TeamRejection> {
8285 let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;
83- let (username, lang_code, theme) = users::profile(&state.pool, user.id)
86+ let (username, lang_code, theme, stats_layout) = users::profile(&state.pool, user.id)
8487 .await?
8588 .unwrap_or_default();
8689 let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr);
8790 let hc = theme == "hc";
91+ let stats_rows = stats_layout == "rows";
8892 let jar = CookieJar::from_headers(&parts.headers);
8993 let wanted = jar
9094 .get(TEAM_COOKIE)
@@ -97,6 +101,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
97101 username,
98102 lang,
99103 hc,
104+ stats_rows,
100105 team_id: id,
101106 team_name: name,
102107 });
@@ -108,6 +113,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
108113 username,
109114 lang,
110115 hc,
116+ stats_rows,
111117 team_id: id,
112118 team_name: name,
113119 }),
@@ -42,6 +42,9 @@ pub struct ActiveTeam {
42 pub lang: Lang,42 pub lang: Lang,
43 /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base.43 /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base.
44 pub hc: bool,44 pub hc: bool,
45+ /// Graphiques de Stats en barres horizontales (users.stats_layout =
46+ /// 'rows') — par utilisateur, en base, comme la langue et le thème.
47+ pub stats_rows: bool,
45 pub team_id: Uuid,48 pub team_id: Uuid,
46 pub team_name: String,49 pub team_name: String,
47 }50 }
@@ -80,11 +83,12 @@ impl FromRequestParts<AppState> for ActiveTeam {
80 state: &AppState,83 state: &AppState,
81 ) -> Result<Self, TeamRejection> {84 ) -> Result<Self, TeamRejection> {
82 let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;85 let user = authenticate(parts, state).ok_or(TeamRejection::Auth(AuthRedirect))?;
83- let (username, lang_code, theme) = users::profile(&state.pool, user.id)86+ let (username, lang_code, theme, stats_layout) = users::profile(&state.pool, user.id)
84 .await?87 .await?
85 .unwrap_or_default();88 .unwrap_or_default();
86 let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr);89 let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr);
87 let hc = theme == "hc";90 let hc = theme == "hc";
91+ let stats_rows = stats_layout == "rows";
88 let jar = CookieJar::from_headers(&parts.headers);92 let jar = CookieJar::from_headers(&parts.headers);
89 let wanted = jar93 let wanted = jar
90 .get(TEAM_COOKIE)94 .get(TEAM_COOKIE)
@@ -97,6 +101,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
97 username,101 username,
98 lang,102 lang,
99 hc,103 hc,
104+ stats_rows,
100 team_id: id,105 team_id: id,
101 team_name: name,106 team_name: name,
102 });107 });
@@ -108,6 +113,7 @@ impl FromRequestParts<AppState> for ActiveTeam {
108 username,113 username,
109 lang,114 lang,
110 hc,115 hc,
116+ stats_rows,
111 team_id: id,117 team_id: id,
112 team_name: name,118 team_name: name,
113 }),119 }),
modified templates/add_choice.html +4 -1
@@ -5,7 +5,7 @@
55 <form method="post" action="/timer/demarrer" style="margin:0">
66 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
77 <input type="hidden" name="retour" value="{{ shell.start_return }}">
8- <button type="submit" class="choice-card choice-timer">
8+ <button type="submit" class="choice-card choice-timer"{% if busy.is_some() %} disabled aria-describedby="timer-busy"{% endif %}>
99 <svg width="30" height="30" 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>
1010 <span>
1111 <span class="choice-title">{{ shell.tr.start_timer }}</span>
@@ -13,6 +13,9 @@
1313 </span>
1414 </button>
1515 </form>
16+ {% if let Some(why) = busy %}
17+ <p class="choice-busy" id="timer-busy">{{ why }}</p>
18+ {% endif %}
1619 <a class="choice-card choice-manual" href="/temps/nouveau?mode=manuel">
1720 <svg width="30" height="30" 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 8v8M8 12h8"></path></svg>
1821 <span>
@@ -5,7 +5,7 @@
5 <form method="post" action="/timer/demarrer" style="margin:0">5 <form method="post" action="/timer/demarrer" style="margin:0">
6 <input type="hidden" name="csrf" value="{{ shell.csrf }}">6 <input type="hidden" name="csrf" value="{{ shell.csrf }}">
7 <input type="hidden" name="retour" value="{{ shell.start_return }}">7 <input type="hidden" name="retour" value="{{ shell.start_return }}">
8- <button type="submit" class="choice-card choice-timer">8+ <button type="submit" class="choice-card choice-timer"{% if busy.is_some() %} disabled aria-describedby="timer-busy"{% endif %}>
9 <svg width="30" height="30" 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>9 <svg width="30" height="30" 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>
10 <span>10 <span>
11 <span class="choice-title">{{ shell.tr.start_timer }}</span>11 <span class="choice-title">{{ shell.tr.start_timer }}</span>
@@ -13,6 +13,9 @@
13 </span>13 </span>
14 </button>14 </button>
15 </form>15 </form>
16+ {% if let Some(why) = busy %}
17+ <p class="choice-busy" id="timer-busy">{{ why }}</p>
18+ {% endif %}
16 <a class="choice-card choice-manual" href="/temps/nouveau?mode=manuel">19 <a class="choice-card choice-manual" href="/temps/nouveau?mode=manuel">
17 <svg width="30" height="30" 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 8v8M8 12h8"></path></svg>20 <svg width="30" height="30" 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 8v8M8 12h8"></path></svg>
18 <span>21 <span>
modified templates/app.html +26 -28
@@ -22,6 +22,32 @@
2222 </div>
2323 <h1>{{ shell.title }}</h1>
2424 </header>
25+{#- Dock du minuteur : masqué sur l'onglet Ajouter (l'écran y porte déjà le
26+ chrono ou le bandeau « X min mesurées »), sauf quand le timer tourne dans
27+ une AUTRE équipe — là, rien d'autre ne le signale. -#}
28+{% if let Some(t) = shell.timer %}{% if shell.tab != "add" || t.foreign %}
29+<div class="timer-dock">
30+ {% if t.running || t.foreign %}
31+ {#- Arrêter : bascule au passage sur l'équipe du timer si besoin. -#}
32+ <form method="post" action="/timer/arreter" style="margin:0">
33+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
34+ <button type="submit" class="timer-btn" aria-label="{{ t.aria }}">
35+ <span class="t-dot"></span>
36+ <span class="t-elapsed"{% if t.running %} data-started="{{ t.started_ms }}"{% endif %}>{{ t.label }}</span>
37+ <span class="t-note">{{ t.note }}</span>
38+ <span class="t-cta">{{ t.cta }}</span>
39+ </button>
40+ </form>
41+ {% else %}
42+ <a class="timer-btn" href="/temps/nouveau" aria-label="{{ t.aria }}">
43+ <span class="t-dot"></span>
44+ <span class="t-elapsed">{{ t.label }}</span>
45+ <span class="t-note">{{ t.note }}</span>
46+ <span class="t-cta">{{ t.cta }}</span>
47+ </a>
48+ {% endif %}
49+</div>
50+{% endif %}{% endif %}
2551 <main class="app-main">
2652 {% block content %}{% endblock %}
2753 </main>
@@ -29,34 +55,6 @@
2955 -webkit-overflow-scrolling crée un contexte d'empilement composité qui
3056 piégerait la sheet sous la tab bar, quel que soit le z-index. #}
3157 {% block sheets %}{% endblock %}
32-{% if let Some(t) = shell.timer %}
33- {% if t.running %}
34-<form class="timer-slot" method="post" action="/timer/arreter">
35- <input type="hidden" name="csrf" value="{{ shell.csrf }}">
36- <button type="submit" class="timer-banner timer-running" aria-label="{{ shell.tr.stop_timer_aria }}">
37- <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>
38- <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
39- <span class="t-note">{{ t.note }}</span>
40- </button>
41-</form>
42- {% else if t.foreign %}
43-<form class="timer-slot" method="post" action="/timer/arreter">
44- <input type="hidden" name="csrf" value="{{ shell.csrf }}">
45- <button type="submit" class="timer-banner timer-waiting" aria-label="{{ shell.tr.record_measured_switch_aria }}">
46- <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>
47- <span class="t-elapsed">{{ t.label }}</span>
48- <span class="t-note">{{ t.note }}</span>
49- </button>
50-</form>
51- {% else %}
52-<div class="timer-slot">
53- <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="{{ shell.tr.record_measured_aria }}">
54- <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>
55- <span class="t-elapsed">{{ t.label }}</span>
56- </a>
57-</div>
58- {% endif %}
59-{% endif %}
6058 {% if !shell.teams.is_empty() %}
6159 <div class="sheet-wrap" id="equipes" role="dialog" aria-label="{{ shell.tr.change_team }}">
6260 <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a>
@@ -22,6 +22,32 @@
22 </div>22 </div>
23 <h1>{{ shell.title }}</h1>23 <h1>{{ shell.title }}</h1>
24 </header>24 </header>
25+{#- Dock du minuteur : masqué sur l'onglet Ajouter (l'écran y porte déjà le
26+ chrono ou le bandeau « X min mesurées »), sauf quand le timer tourne dans
27+ une AUTRE équipe — là, rien d'autre ne le signale. -#}
28+{% if let Some(t) = shell.timer %}{% if shell.tab != "add" || t.foreign %}
29+<div class="timer-dock">
30+ {% if t.running || t.foreign %}
31+ {#- Arrêter : bascule au passage sur l'équipe du timer si besoin. -#}
32+ <form method="post" action="/timer/arreter" style="margin:0">
33+ <input type="hidden" name="csrf" value="{{ shell.csrf }}">
34+ <button type="submit" class="timer-btn" aria-label="{{ t.aria }}">
35+ <span class="t-dot"></span>
36+ <span class="t-elapsed"{% if t.running %} data-started="{{ t.started_ms }}"{% endif %}>{{ t.label }}</span>
37+ <span class="t-note">{{ t.note }}</span>
38+ <span class="t-cta">{{ t.cta }}</span>
39+ </button>
40+ </form>
41+ {% else %}
42+ <a class="timer-btn" href="/temps/nouveau" aria-label="{{ t.aria }}">
43+ <span class="t-dot"></span>
44+ <span class="t-elapsed">{{ t.label }}</span>
45+ <span class="t-note">{{ t.note }}</span>
46+ <span class="t-cta">{{ t.cta }}</span>
47+ </a>
48+ {% endif %}
49+</div>
50+{% endif %}{% endif %}
25 <main class="app-main">51 <main class="app-main">
26 {% block content %}{% endblock %}52 {% block content %}{% endblock %}
27 </main>53 </main>
@@ -29,34 +55,6 @@
29 -webkit-overflow-scrolling crée un contexte d'empilement composité qui55 -webkit-overflow-scrolling crée un contexte d'empilement composité qui
30 piégerait la sheet sous la tab bar, quel que soit le z-index. #}56 piégerait la sheet sous la tab bar, quel que soit le z-index. #}
31 {% block sheets %}{% endblock %}57 {% block sheets %}{% endblock %}
32-{% if let Some(t) = shell.timer %}
33- {% if t.running %}
34-<form class="timer-slot" method="post" action="/timer/arreter">
35- <input type="hidden" name="csrf" value="{{ shell.csrf }}">
36- <button type="submit" class="timer-banner timer-running" aria-label="{{ shell.tr.stop_timer_aria }}">
37- <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>
38- <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span>
39- <span class="t-note">{{ t.note }}</span>
40- </button>
41-</form>
42- {% else if t.foreign %}
43-<form class="timer-slot" method="post" action="/timer/arreter">
44- <input type="hidden" name="csrf" value="{{ shell.csrf }}">
45- <button type="submit" class="timer-banner timer-waiting" aria-label="{{ shell.tr.record_measured_switch_aria }}">
46- <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>
47- <span class="t-elapsed">{{ t.label }}</span>
48- <span class="t-note">{{ t.note }}</span>
49- </button>
50-</form>
51- {% else %}
52-<div class="timer-slot">
53- <a class="timer-banner timer-waiting" href="/temps/nouveau" aria-label="{{ shell.tr.record_measured_aria }}">
54- <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>
55- <span class="t-elapsed">{{ t.label }}</span>
56- </a>
57-</div>
58- {% endif %}
59-{% endif %}
60 {% if !shell.teams.is_empty() %}58 {% if !shell.teams.is_empty() %}
61 <div class="sheet-wrap" id="equipes" role="dialog" aria-label="{{ shell.tr.change_team }}">59 <div class="sheet-wrap" id="equipes" role="dialog" aria-label="{{ shell.tr.change_team }}">
62 <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a>60 <a class="sheet-backdrop" href="#fermer" aria-label="{{ shell.tr.close }}"></a>
modified templates/settings.html +25 -0
@@ -141,6 +141,31 @@
141141 {% endfor %}
142142 </div>
143143
144+<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.chart_style }}</h2>
145+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.chart_style_hint }}</p>
146+<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-2)">
147+ {% for cs in chart_styles %}
148+ <form method="post" action="/parametres/graphiques" style="margin:0">
149+ <input type="hidden" name="csrf" value="{{ csrf }}">
150+ <input type="hidden" name="format" value="{{ cs.code }}">
151+ <button type="submit" class="theme-choice{% if cs.current %} current{% endif %}" {% if cs.current %}aria-current="true"{% endif %}>
152+ <span class="chart-swatch" aria-hidden="true">
153+ {% if cs.code == "columns" %}
154+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg>
155+ {% else %}
156+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M4 6h14M4 12h8M4 18h11"></path></svg>
157+ {% endif %}
158+ </span>
159+ <span style="flex:1;text-align:left">
160+ <span style="display:block;font-size:16px">{{ cs.name }}</span>
161+ <span style="display:block;font-size:12px;opacity:.75">{{ cs.desc }}</span>
162+ </span>
163+ {% if cs.current %}<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %}
164+ </button>
165+ </form>
166+ {% endfor %}
167+</div>
168+
144169 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
145170 <form method="post" action="/parametres/mot-de-passe">
146171 <input type="hidden" name="csrf" value="{{ csrf }}">
@@ -141,6 +141,31 @@
141 {% endfor %}141 {% endfor %}
142 </div>142 </div>
143 143
144+<h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-2)">{{ shell.tr.chart_style }}</h2>
145+<p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ shell.tr.chart_style_hint }}</p>
146+<div style="display:grid;gap:var(--space-2);margin-bottom:var(--space-2)">
147+ {% for cs in chart_styles %}
148+ <form method="post" action="/parametres/graphiques" style="margin:0">
149+ <input type="hidden" name="csrf" value="{{ csrf }}">
150+ <input type="hidden" name="format" value="{{ cs.code }}">
151+ <button type="submit" class="theme-choice{% if cs.current %} current{% endif %}" {% if cs.current %}aria-current="true"{% endif %}>
152+ <span class="chart-swatch" aria-hidden="true">
153+ {% if cs.code == "columns" %}
154+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg>
155+ {% else %}
156+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M4 6h14M4 12h8M4 18h11"></path></svg>
157+ {% endif %}
158+ </span>
159+ <span style="flex:1;text-align:left">
160+ <span style="display:block;font-size:16px">{{ cs.name }}</span>
161+ <span style="display:block;font-size:12px;opacity:.75">{{ cs.desc }}</span>
162+ </span>
163+ {% if cs.current %}<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>{% endif %}
164+ </button>
165+ </form>
166+ {% endfor %}
167+</div>
168+
144 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>169 <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2>
145 <form method="post" action="/parametres/mot-de-passe">170 <form method="post" action="/parametres/mot-de-passe">
146 <input type="hidden" name="csrf" value="{{ csrf }}">171 <input type="hidden" name="csrf" value="{{ csrf }}">
modified templates/stats.html +2 -2
@@ -34,7 +34,7 @@
3434 {% if p.day_chart.empty %}
3535 <p class="chart-empty">{{ shell.tr.no_time_week }}</p>
3636 {% endif %}
37- <div class="cols cols-7">
37+ <div class="cols cols-7{% if shell.stats_rows %} cols-rows{% endif %}">
3838 {% for col in p.day_chart.cols %}
3939 <div class="col{% if col.muted %} col-muted{% endif %}">
4040 {% if let Some(t) = col.total %}
@@ -75,7 +75,7 @@
7575 {% if chart.empty %}
7676 <p class="chart-empty">{{ shell.tr.no_time_period }}</p>
7777 {% else %}
78- <div class="cols">
78+ <div class="cols{% if shell.stats_rows %} cols-rows{% endif %}">
7979 {% for row in chart.rows %}
8080 <div class="col">
8181 <span class="total">{{ row.total }}</span>
@@ -34,7 +34,7 @@
34 {% if p.day_chart.empty %}34 {% if p.day_chart.empty %}
35 <p class="chart-empty">{{ shell.tr.no_time_week }}</p>35 <p class="chart-empty">{{ shell.tr.no_time_week }}</p>
36 {% endif %}36 {% endif %}
37- <div class="cols cols-7">37+ <div class="cols cols-7{% if shell.stats_rows %} cols-rows{% endif %}">
38 {% for col in p.day_chart.cols %}38 {% for col in p.day_chart.cols %}
39 <div class="col{% if col.muted %} col-muted{% endif %}">39 <div class="col{% if col.muted %} col-muted{% endif %}">
40 {% if let Some(t) = col.total %}40 {% if let Some(t) = col.total %}
@@ -75,7 +75,7 @@
75 {% if chart.empty %}75 {% if chart.empty %}
76 <p class="chart-empty">{{ shell.tr.no_time_period }}</p>76 <p class="chart-empty">{{ shell.tr.no_time_period }}</p>
77 {% else %}77 {% else %}
78- <div class="cols">78+ <div class="cols{% if shell.stats_rows %} cols-rows{% endif %}">
79 {% for row in chart.rows %}79 {% for row in chart.rows %}
80 <div class="col">80 <div class="col">
81 <span class="total">{{ row.total }}</span>81 <span class="total">{{ row.total }}</span>