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é.
e824891 parent: 892cf36 modified
assets/style.css +101 -22 | @@ -27,6 +27,7 @@ | ||
| 27 | 27 | --color-accent-900: #402310; |
| 28 | 28 | |
| 29 | 29 | --color-accent-2-200: #e1eecc; |
| 30 | + --color-accent-2-700: #56633f; | |
| 30 | 31 | --color-accent-2-900: #272e1b; |
| 31 | 32 | --color-neutral-800: #474238; |
| 32 | 33 | |
| @@ -79,6 +80,7 @@ | ||
| 79 | 80 | --color-accent-900: #ffffff; |
| 80 | 81 | |
| 81 | 82 | --color-accent-2-200: #242424; |
| 83 | + --color-accent-2-700: #ffffff; | |
| 82 | 84 | --color-accent-2-900: #ffffff; |
| 83 | 85 | |
| 84 | 86 | --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28); |
| @@ -213,32 +215,49 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 213 | 215 | .app-topline .who { margin-left: auto; font-size: 12px; opacity: .7; } |
| 214 | 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 | 220 | Seul conteneur scrollable de l'app (cf. body overflow hidden). */ |
| 218 | 221 | .app-main { |
| 219 | 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 | 224 | overflow-y: auto; -webkit-overflow-scrolling: touch; |
| 222 | 225 | } |
| 223 | 226 | |
| 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 | +} | |
| 242 | 261 | /* Atterrissage de l'onglet Ajouter : minuteur ou saisie manuelle. */ |
| 243 | 262 | .choice-hint { font-size: 15px; opacity: .7; margin: 0 0 var(--space-6); text-wrap: pretty; } |
| 244 | 263 | .choice-stack { display: grid; gap: var(--space-3); } |
| @@ -263,6 +282,16 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 263 | 282 | .choice-timer:hover { background: var(--color-accent-700); } |
| 264 | 283 | .choice-manual { background: var(--color-surface); color: var(--color-text); border: 1.5px solid var(--color-accent-2); } |
| 265 | 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 | 295 | .choice-manage { justify-self: center; margin-top: var(--space-2); } |
| 267 | 296 | |
| 268 | 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 | 506 | .col-muted .user { font-weight: 400; } |
| 478 | 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 | 544 | /* — groupes de catégories : carrousel de scopes (page Stats) — */ |
| 481 | 545 | .scope-pills { |
| 482 | 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 | 790 | .swatch-organic { background: linear-gradient(135deg, #f5ead8 0 50%, #c67139 50% 100%); } |
| 727 | 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 | 802 | /* Sélecteur de langue (Réglages). */ |
| 730 | 803 | .lang-pill { |
| 731 | 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 | 847 | padding-top: 0; |
| 775 | 848 | } |
| 776 | 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 | 851 | .tabbar { |
| 779 | 852 | top: 0; bottom: 0; right: auto; width: 232px; |
| 780 | 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 | 867 | color: color-mix(in srgb, var(--color-text) 70%, transparent); |
| 795 | 868 | } |
| 796 | 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 | 877 | .sheet-wrap:target, .sheet-wrap.open { align-items: center; padding: var(--space-6); } |
| 799 | 878 | .sheet { |
| 800 | 879 | 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 | ||
| 37 | 37 | Ok(()) |
| 38 | 38 | } |
| 39 | 39 | |
| 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") | |
| 43 | 47 | .bind(id) |
| 44 | 48 | .fetch_optional(pool) |
| 45 | 49 | .await |
| @@ -65,6 +69,16 @@ pub async fn set_theme(pool: &PgPool, id: Uuid, theme: &str) -> sqlx::Result<()> | ||
| 65 | 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 | 82 | /// Cible d'une invitation. |
| 69 | 83 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 70 | 84 | 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 | .await | 49 | .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 { | ||
| 80 | 80 | pub start_timer: &'static str, |
| 81 | 81 | pub stop_timer_aria: &'static str, |
| 82 | 82 | pub timer_running_note: &'static str, |
| 83 | + pub measure_pending_note: &'static str, | |
| 83 | 84 | pub record_measured_aria: &'static str, |
| 84 | - pub record_measured_switch_aria: &'static str, | |
| 85 | 85 | pub measured_pat: &'static str, |
| 86 | + /// Notes du dock quand le timer (ou la mesure) vit dans une autre équipe. | |
| 86 | 87 | pub in_team_pat: &'static str, |
| 87 | 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 | 92 | // ─── login ─── |
| 89 | 93 | pub login_title: &'static str, |
| 90 | 94 | pub tagline: &'static str, |
| @@ -99,6 +103,9 @@ pub struct Tr { | ||
| 99 | 103 | /// Écran de choix (deux boutons) de l'onglet Ajouter. |
| 100 | 104 | pub add_choice_hint: &'static str, |
| 101 | 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 | 109 | pub choice_manual_sub: &'static str, |
| 103 | 110 | pub back: &'static str, |
| 104 | 111 | pub step1_hint: &'static str, |
| @@ -230,6 +237,14 @@ pub struct Tr { | ||
| 230 | 237 | pub theme_hc: &'static str, |
| 231 | 238 | pub theme_hc_desc: &'static str, |
| 232 | 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 | 248 | pub session: &'static str, |
| 234 | 249 | pub current_password: &'static str, |
| 235 | 250 | pub new_password: &'static str, |
| @@ -360,12 +375,14 @@ pub static FR: Tr = Tr { | ||
| 360 | 375 | member_many: "membres", |
| 361 | 376 | start_timer: "Lancer le minuteur", |
| 362 | 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 | 380 | record_measured_aria: "Enregistrer le temps mesuré", |
| 365 | - record_measured_switch_aria: "Enregistrer le temps mesuré (bascule d'équipe)", | |
| 366 | 381 | measured_pat: "{} mesurées", |
| 367 | 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 | 386 | login_title: "Connexion", |
| 370 | 387 | tagline: "Le temps passé sur chaque catégorie, partagé avec ton équipe.", |
| 371 | 388 | username_label: "Identifiant", |
| @@ -377,6 +394,8 @@ pub static FR: Tr = Tr { | ||
| 377 | 394 | title_select_duration: "Combien de temps ?", |
| 378 | 395 | add_choice_hint: "Compte le temps au fur et à mesure, ou saisis une durée que tu as déjà passée.", |
| 379 | 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 | 399 | choice_manual_sub: "Une durée déjà passée, en deux étapes.", |
| 381 | 400 | back: "← Retour", |
| 382 | 401 | step1_hint: "Étape 1 sur 2 — choisis la catégorie", |
| @@ -495,6 +514,13 @@ pub static FR: Tr = Tr { | ||
| 495 | 514 | theme_hc: "Contraste élevé", |
| 496 | 515 | theme_hc_desc: "Noir et blanc, lisible à très faible luminosité ou avec un filtre chaud.", |
| 497 | 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 | 524 | session: "Session", |
| 499 | 525 | current_password: "Mot de passe actuel", |
| 500 | 526 | new_password: "Nouveau mot de passe", |
| @@ -595,12 +621,14 @@ pub static EN: Tr = Tr { | ||
| 595 | 621 | member_many: "members", |
| 596 | 622 | start_timer: "Start the timer", |
| 597 | 623 | stop_timer_aria: "Stop timer", |
| 598 | - timer_running_note: "running", | |
| 624 | + timer_running_note: "timer running", | |
| 625 | + measure_pending_note: "to record", | |
| 599 | 626 | record_measured_aria: "Record the measured time", |
| 600 | - record_measured_switch_aria: "Record the measured time (switches team)", | |
| 601 | 627 | measured_pat: "{} measured", |
| 602 | 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 | 632 | login_title: "Sign in", |
| 605 | 633 | tagline: "Time spent on each category, shared with your team.", |
| 606 | 634 | username_label: "Username", |
| @@ -612,6 +640,8 @@ pub static EN: Tr = Tr { | ||
| 612 | 640 | title_select_duration: "How much time?", |
| 613 | 641 | add_choice_hint: "Track time as it passes, or enter a duration you already spent.", |
| 614 | 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 | 645 | choice_manual_sub: "A duration already spent, in two steps.", |
| 616 | 646 | back: "← Back", |
| 617 | 647 | step1_hint: "Step 1 of 2 — pick the category", |
| @@ -730,6 +760,13 @@ pub static EN: Tr = Tr { | ||
| 730 | 760 | theme_hc: "High contrast", |
| 731 | 761 | theme_hc_desc: "Black and white, readable in very low light or with a warm filter.", |
| 732 | 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 | 770 | session: "Session", |
| 734 | 771 | current_password: "Current password", |
| 735 | 772 | new_password: "New password", |
| @@ -830,12 +867,14 @@ pub static DE: Tr = Tr { | ||
| 830 | 867 | member_many: "Mitglieder", |
| 831 | 868 | start_timer: "Timer starten", |
| 832 | 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 | 872 | record_measured_aria: "Gemessene Zeit erfassen", |
| 835 | - record_measured_switch_aria: "Gemessene Zeit erfassen (wechselt das Team)", | |
| 836 | 873 | measured_pat: "{} gemessen", |
| 837 | 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 | 878 | login_title: "Anmeldung", |
| 840 | 879 | tagline: "Die Zeit für jede Kategorie, geteilt mit deinem Team.", |
| 841 | 880 | username_label: "Benutzername", |
| @@ -847,6 +886,8 @@ pub static DE: Tr = Tr { | ||
| 847 | 886 | title_select_duration: "Wieviel Zeit?", |
| 848 | 887 | add_choice_hint: "Miss die Zeit, während sie läuft, oder erfasse eine bereits verbrachte Dauer.", |
| 849 | 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 | 891 | choice_manual_sub: "Eine bereits verbrachte Dauer, in zwei Schritten.", |
| 851 | 892 | back: "← Zurück", |
| 852 | 893 | step1_hint: "Schritt 1 von 2 — wähle die Kategorie", |
| @@ -965,6 +1006,13 @@ pub static DE: Tr = Tr { | ||
| 965 | 1006 | theme_hc: "Hoher Kontrast", |
| 966 | 1007 | theme_hc_desc: "Schwarz-Weiß, lesbar bei sehr wenig Licht oder mit warmem Filter.", |
| 967 | 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 | 1016 | session: "Sitzung", |
| 969 | 1017 | current_password: "Aktuelles Passwort", |
| 970 | 1018 | new_password: "Neues Passwort", |
| @@ -1065,12 +1113,14 @@ pub static PT_BR: Tr = Tr { | ||
| 1065 | 1113 | member_many: "membros", |
| 1066 | 1114 | start_timer: "Iniciar o cronômetro", |
| 1067 | 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 | 1118 | record_measured_aria: "Registrar o tempo medido", |
| 1070 | - record_measured_switch_aria: "Registrar o tempo medido (troca de equipe)", | |
| 1071 | 1119 | measured_pat: "{} medidos", |
| 1072 | 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 | 1124 | login_title: "Entrar", |
| 1075 | 1125 | tagline: "O tempo dedicado a cada categoria, compartilhado com a sua equipe.", |
| 1076 | 1126 | username_label: "Usuário", |
| @@ -1082,6 +1132,8 @@ pub static PT_BR: Tr = Tr { | ||
| 1082 | 1132 | title_select_duration: "Quanto tempo?", |
| 1083 | 1133 | add_choice_hint: "Meça o tempo enquanto ele passa, ou informe uma duração que você já gastou.", |
| 1084 | 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 | 1137 | choice_manual_sub: "Uma duração já gasta, em duas etapas.", |
| 1086 | 1138 | back: "← Voltar", |
| 1087 | 1139 | step1_hint: "Etapa 1 de 2 — escolha a categoria", |
| @@ -1200,6 +1252,13 @@ pub static PT_BR: Tr = Tr { | ||
| 1200 | 1252 | theme_hc: "Alto contraste", |
| 1201 | 1253 | theme_hc_desc: "Preto e branco, legível com pouquíssima luz ou com filtro quente.", |
| 1202 | 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 | 1262 | session: "Sessão", |
| 1204 | 1263 | current_password: "Senha atual", |
| 1205 | 1264 | 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 { | ||
| 27 | 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 | 31 | pub struct TimerView { |
| 32 | 32 | /// Timer en cours (sinon : mesure figée en attente d'enregistrement). |
| 33 | 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 | 36 | pub foreign: bool, |
| 36 | 37 | /// « 47 min » (en cours) ou « 47 min mesurées » (en attente). |
| 37 | 38 | pub label: String, |
| 38 | - /// « en cours », « timer dans l'équipe X »… — vide = rien à afficher. | |
| 39 | + /// « minuteur en cours », « à enregistrer », « équipe Chez nous ». | |
| 39 | 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 | 45 | /// Départ en epoch ms — rafraîchissement JS toutes les 10 s. |
| 41 | 46 | pub started_ms: i64, |
| 42 | 47 | } |
| @@ -55,6 +60,8 @@ pub struct Shell { | ||
| 55 | 60 | pub team_color: &'static str, |
| 56 | 61 | /// Thème contraste élevé de l'utilisateur (attribut data-theme de <html>). |
| 57 | 62 | pub hc: bool, |
| 63 | + /// Graphiques de Stats en barres horizontales plutôt qu'en colonnes. | |
| 64 | + pub stats_rows: bool, | |
| 58 | 65 | /// "add" | "stats" | "times" | "settings" |
| 59 | 66 | pub tab: &'static str, |
| 60 | 67 | pub toast: Option<String>, |
| @@ -62,7 +69,7 @@ pub struct Shell { | ||
| 62 | 69 | pub invite_count: i64, |
| 63 | 70 | /// Tâches « à faire » actives de l'équipe — badge sur l'onglet Tâches. |
| 64 | 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 | 73 | pub csrf: String, |
| 67 | 74 | /// Où revenir après un démarrage de timer (page courante, déduite du tab). |
| 68 | 75 | 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 { | ||
| 44 | 44 | #[template(path = "add_choice.html")] |
| 45 | 45 | pub struct AddChoicePage { |
| 46 | 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 | 53 | #[derive(Template)] |
| @@ -148,6 +152,17 @@ pub async fn entry_new( | ||
| 148 | 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 | 166 | // Mesure de timer figée dans l'équipe active : elle préremplit l'étape 2. |
| 152 | 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 | 172 | if q.categorie.is_none() && q.mode.is_none() && pending.is_none() { |
| 158 | 173 | return Ok(HtmlTemplate(AddChoicePage { |
| 159 | 174 | shell: shell(&state, &ctx, tr.title_add, "add", toast).await?, |
| 175 | + busy, | |
| 160 | 176 | }) |
| 161 | 177 | .into_response()); |
| 162 | 178 | } |
| @@ -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 { | ||
| 50 | 50 | ) |
| 51 | 51 | .route("/parametres/langue", post(settings::lang_change)) |
| 52 | 52 | .route("/parametres/affichage", post(settings::theme_change)) |
| 53 | + .route("/parametres/graphiques", post(settings::chart_style_change)) | |
| 53 | 54 | .route("/taches", get(todos::todos_page).post(todos::todo_create)) |
| 54 | 55 | .route("/taches/nouvelle", get(todos::todo_new)) |
| 55 | 56 | .route("/taches/{id}/modifier", get(todos::todo_edit)) |
| @@ -169,31 +170,44 @@ pub async fn shell( | ||
| 169 | 170 | let badge = timer_row.is_some(); |
| 170 | 171 | let timer = timer_row.map(|t| { |
| 171 | 172 | 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(), | |
| 195 | 208 | } |
| 196 | 209 | }); |
| 210 | + | |
| 197 | 211 | Ok(Shell { |
| 198 | 212 | title: title.to_string(), |
| 199 | 213 | username: ctx.username.clone(), |
| @@ -202,6 +216,7 @@ pub async fn shell( | ||
| 202 | 216 | team_name: ctx.team_name.clone(), |
| 203 | 217 | team_color: team_color(ctx.team_id, ctx.hc), |
| 204 | 218 | hc: ctx.hc, |
| 219 | + stats_rows: ctx.stats_rows, | |
| 205 | 220 | tab, |
| 206 | 221 | toast, |
| 207 | 222 | 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 { | ||
| 47 | 47 | pub langs: Vec<LangView>, |
| 48 | 48 | /// Sélecteur de thème (par utilisateur, en base — section Affichage). |
| 49 | 49 | pub themes: Vec<ThemeView>, |
| 50 | + /// Sélecteur de format des graphiques (colonnes / barres). | |
| 51 | + pub chart_styles: Vec<ThemeView>, | |
| 50 | 52 | pub members_title: String, |
| 51 | 53 | pub leave_label: String, |
| 52 | 54 | pub sent: Vec<SentView>, |
| @@ -78,6 +80,7 @@ pub async fn settings_page( | ||
| 78 | 80 | Some("quittee") => Some(tr.toast_team_left.to_string()), |
| 79 | 81 | Some("langue") => Some(tr.toast_lang_updated.to_string()), |
| 80 | 82 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), |
| 83 | + Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), | |
| 81 | 84 | _ => match q.err.as_deref() { |
| 82 | 85 | Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), |
| 83 | 86 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), |
| @@ -151,12 +154,27 @@ pub async fn settings_page( | ||
| 151 | 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 | 171 | Ok(HtmlTemplate(SettingsPage { |
| 155 | 172 | csrf: ctx.user.csrf_token(&state), |
| 156 | 173 | shell: shell(&state, &ctx, tr.title_settings, "settings", toast).await?, |
| 157 | 174 | error, |
| 158 | 175 | langs, |
| 159 | 176 | themes, |
| 177 | + chart_styles, | |
| 160 | 178 | members_title: p1(tr.members_of_pat, &ctx.team_name), |
| 161 | 179 | leave_label: p1(tr.leave_team_pat, &ctx.team_name), |
| 162 | 180 | invites, |
| @@ -353,6 +371,12 @@ pub struct ThemeForm { | ||
| 353 | 371 | csrf: String, |
| 354 | 372 | } |
| 355 | 373 | |
| 374 | +#[derive(Deserialize)] | |
| 375 | +pub struct ChartStyleForm { | |
| 376 | + format: String, | |
| 377 | + csrf: String, | |
| 378 | +} | |
| 379 | + | |
| 356 | 380 | /// POST /parametres/langue — choix PAR UTILISATEUR, enregistré en base |
| 357 | 381 | /// (jamais par navigateur : le réglage suit le compte partout). |
| 358 | 382 | pub async fn lang_change( |
| @@ -387,6 +411,23 @@ pub async fn theme_change( | ||
| 387 | 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 | 431 | fn is_unique_violation(e: &sqlx::Error) -> bool { |
| 391 | 432 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 392 | 433 | } |
| @@ -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 base | 380 | /// 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 { | ||
| 42 | 42 | pub lang: Lang, |
| 43 | 43 | /// Thème contraste élevé (users.theme = 'hc') — par utilisateur, en base. |
| 44 | 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 | 48 | pub team_id: Uuid, |
| 46 | 49 | pub team_name: String, |
| 47 | 50 | } |
| @@ -80,11 +83,12 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 80 | 83 | state: &AppState, |
| 81 | 84 | ) -> Result<Self, TeamRejection> { |
| 82 | 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 | 87 | .await? |
| 85 | 88 | .unwrap_or_default(); |
| 86 | 89 | let lang = Lang::from_code(&lang_code).unwrap_or(Lang::Fr); |
| 87 | 90 | let hc = theme == "hc"; |
| 91 | + let stats_rows = stats_layout == "rows"; | |
| 88 | 92 | let jar = CookieJar::from_headers(&parts.headers); |
| 89 | 93 | let wanted = jar |
| 90 | 94 | .get(TEAM_COOKIE) |
| @@ -97,6 +101,7 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 97 | 101 | username, |
| 98 | 102 | lang, |
| 99 | 103 | hc, |
| 104 | + stats_rows, | |
| 100 | 105 | team_id: id, |
| 101 | 106 | team_name: name, |
| 102 | 107 | }); |
| @@ -108,6 +113,7 @@ impl FromRequestParts<AppState> for ActiveTeam { | ||
| 108 | 113 | username, |
| 109 | 114 | lang, |
| 110 | 115 | hc, |
| 116 | + stats_rows, | |
| 111 | 117 | team_id: id, |
| 112 | 118 | team_name: name, |
| 113 | 119 | }), |
| @@ -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 = jar | 93 | 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 @@ | ||
| 5 | 5 | <form method="post" action="/timer/demarrer" style="margin:0"> |
| 6 | 6 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 7 | 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 | 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 | 10 | <span> |
| 11 | 11 | <span class="choice-title">{{ shell.tr.start_timer }}</span> |
| @@ -13,6 +13,9 @@ | ||
| 13 | 13 | </span> |
| 14 | 14 | </button> |
| 15 | 15 | </form> |
| 16 | + {% if let Some(why) = busy %} | |
| 17 | + <p class="choice-busy" id="timer-busy">{{ why }}</p> | |
| 18 | + {% endif %} | |
| 16 | 19 | <a class="choice-card choice-manual" href="/temps/nouveau?mode=manuel"> |
| 17 | 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 | 21 | <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 @@ | ||
| 22 | 22 | </div> |
| 23 | 23 | <h1>{{ shell.title }}</h1> |
| 24 | 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 | 51 | <main class="app-main"> |
| 26 | 52 | {% block content %}{% endblock %} |
| 27 | 53 | </main> |
| @@ -29,34 +55,6 @@ | ||
| 29 | 55 | -webkit-overflow-scrolling crée un contexte d'empilement composité qui |
| 30 | 56 | piégerait la sheet sous la tab bar, quel que soit le z-index. #} |
| 31 | 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 | 58 | {% if !shell.teams.is_empty() %} |
| 61 | 59 | <div class="sheet-wrap" id="equipes" role="dialog" aria-label="{{ shell.tr.change_team }}"> |
| 62 | 60 | <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é qui | 55 | -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 @@ | ||
| 141 | 141 | {% endfor %} |
| 142 | 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 | 169 | <h2 style="font-size:16px;margin:var(--space-8) 0 var(--space-3)">{{ shell.tr.session }}</h2> |
| 145 | 170 | <form method="post" action="/parametres/mot-de-passe"> |
| 146 | 171 | <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 @@ | ||
| 34 | 34 | {% if p.day_chart.empty %} |
| 35 | 35 | <p class="chart-empty">{{ shell.tr.no_time_week }}</p> |
| 36 | 36 | {% endif %} |
| 37 | - <div class="cols cols-7"> | |
| 37 | + <div class="cols cols-7{% if shell.stats_rows %} cols-rows{% endif %}"> | |
| 38 | 38 | {% for col in p.day_chart.cols %} |
| 39 | 39 | <div class="col{% if col.muted %} col-muted{% endif %}"> |
| 40 | 40 | {% if let Some(t) = col.total %} |
| @@ -75,7 +75,7 @@ | ||
| 75 | 75 | {% if chart.empty %} |
| 76 | 76 | <p class="chart-empty">{{ shell.tr.no_time_period }}</p> |
| 77 | 77 | {% else %} |
| 78 | - <div class="cols"> | |
| 78 | + <div class="cols{% if shell.stats_rows %} cols-rows{% endif %}"> | |
| 79 | 79 | {% for row in chart.rows %} |
| 80 | 80 | <div class="col"> |
| 81 | 81 | <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> |