feat: design « Organic » issu du projet Claude DesignUnverified
Shell mobile 430px avec tab bar fixe, login plein écran, ajout en deux étapes (choix de tâche puis durée/date avec steppers), stats en colonnes empilées HTML avec bottom sheets de détail en :target (zéro JS), écran Tâches séparé (renommage à la volée), Réglages avec carte Compte. Palette 7 couleurs par ordre de création des tâches, Caprasimo/Figtree.
bb1fbb6 parent: 7255330 modified
assets/icon-512.png +0 -0 | Binary files a/assets/icon-512.png and b/assets/icon-512.png differ |
| Binary files a/assets/icon-512.png and b/assets/icon-512.png differ | Binary files a/assets/icon-512.png and b/assets/icon-512.png differ |
modified
assets/manifest.json +2 -2 | @@ -4,8 +4,8 @@ | ||
| 4 | 4 | "description": "Comptage de temps passé sur des tâches", |
| 5 | 5 | "start_url": "/", |
| 6 | 6 | "display": "standalone", |
| 7 | - "background_color": "#f7f8fa", | |
| 8 | - "theme_color": "#1d3557", | |
| 7 | + "background_color": "#f5ead8", | |
| 8 | + "theme_color": "#f5ead8", | |
| 9 | 9 | "lang": "fr", |
| 10 | 10 | "icons": [ |
| 11 | 11 | { |
| @@ -4,8 +4,8 @@ | |||
| 4 | "description": "Comptage de temps passé sur des tâches", | 4 | "description": "Comptage de temps passé sur des tâches", |
| 5 | "start_url": "/", | 5 | "start_url": "/", |
| 6 | "display": "standalone", | 6 | "display": "standalone", |
| 7 | - "background_color": "#f7f8fa", | 7 | + "background_color": "#f5ead8", |
| 8 | - "theme_color": "#1d3557", | 8 | + "theme_color": "#f5ead8", |
| 9 | "lang": "fr", | 9 | "lang": "fr", |
| 10 | "icons": [ | 10 | "icons": [ |
| 11 | { | 11 | { |
modified
assets/style.css +273 -166 | @@ -1,177 +1,284 @@ | ||
| 1 | -/* Mobile-first, zéro JS. */ | |
| 1 | +/* ═══ Organic — design-system tokens (source : projet Claude Design) ═══ */ | |
| 2 | +@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap'); | |
| 3 | + | |
| 2 | 4 | :root { |
| 3 | - --bg: #f7f8fa; | |
| 4 | - --card: #ffffff; | |
| 5 | - --ink: #1a2233; | |
| 6 | - --muted: #5b6474; | |
| 7 | - --primary: #1d3557; | |
| 8 | - --primary-ink: #ffffff; | |
| 9 | - --accent: #4e79a7; | |
| 10 | - --danger: #c0392b; | |
| 11 | - --border: #d8dde6; | |
| 12 | - --ok-bg: #e7f6ec; | |
| 13 | - --ok-ink: #1e7a3c; | |
| 14 | - --err-bg: #fdecea; | |
| 15 | - --err-ink: #b23327; | |
| 16 | - --radius: 12px; | |
| 17 | -} | |
| 18 | - | |
| 19 | -* { box-sizing: border-box; } | |
| 5 | + --color-bg: #f5ead8; | |
| 6 | + --color-surface: #ebddc5; | |
| 7 | + --color-text: #201e1d; | |
| 8 | + --color-accent: #c67139; | |
| 9 | + --color-accent-2: #7a8a5e; | |
| 10 | + --color-divider: color-mix(in srgb, #201e1d 16%, transparent); | |
| 11 | + | |
| 12 | + --color-neutral-100: #f9f4ed; | |
| 13 | + --color-neutral-200: #eee7db; | |
| 14 | + --color-neutral-300: #dcd3c4; | |
| 15 | + --color-neutral-500: #a19786; | |
| 16 | + --color-neutral-900: #2e2b25; | |
| 17 | + | |
| 18 | + --color-accent-100: #fff2eb; | |
| 19 | + --color-accent-300: #ffc6a5; | |
| 20 | + --color-accent-600: #b2622d; | |
| 21 | + --color-accent-700: #8c491a; | |
| 22 | + | |
| 23 | + --font-heading: "Caprasimo", system-ui, sans-serif; | |
| 24 | + --font-heading-weight: 400; | |
| 25 | + --font-body: "Figtree", system-ui, sans-serif; | |
| 26 | + | |
| 27 | + --space-1: 4.4px; | |
| 28 | + --space-2: 8.8px; | |
| 29 | + --space-3: 13.2px; | |
| 30 | + --space-4: 17.6px; | |
| 31 | + --space-6: 26.4px; | |
| 32 | + --space-8: 35.2px; | |
| 33 | + | |
| 34 | + --radius-sm: 8px; | |
| 35 | + --radius-md: 16px; | |
| 36 | + --radius-lg: 28px; | |
| 20 | 37 | |
| 38 | + --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); | |
| 39 | + --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); | |
| 40 | + --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); | |
| 41 | +} | |
| 42 | + | |
| 43 | +*, *::before, *::after { box-sizing: border-box; } | |
| 44 | +html, body { margin: 0; height: 100%; } | |
| 21 | 45 | body { |
| 22 | - margin: 0; | |
| 23 | - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| 24 | - background: var(--bg); | |
| 25 | - color: var(--ink); | |
| 26 | - font-size: 17px; | |
| 27 | - line-height: 1.45; | |
| 46 | + background: var(--color-neutral-300); | |
| 47 | + color: var(--color-text); | |
| 48 | + font-family: var(--font-body); | |
| 49 | + font-size: 15px; line-height: 1.55; | |
| 50 | +} | |
| 51 | +h1, h2, h3, h4 { | |
| 52 | + font-family: var(--font-heading); font-weight: var(--font-heading-weight); | |
| 53 | + line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2); | |
| 54 | +} | |
| 55 | +p { margin: 0 0 var(--space-3); } | |
| 56 | +a { color: var(--color-accent); text-underline-offset: 3px; } | |
| 57 | +:focus { outline: none; } | |
| 58 | +:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } | |
| 59 | +::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); } | |
| 60 | +button { font-family: inherit; } | |
| 61 | +input[type=number]::-webkit-outer-spin-button, | |
| 62 | +input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } | |
| 63 | + | |
| 64 | +.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); } | |
| 65 | + | |
| 66 | +/* — boutons — */ | |
| 67 | +.btn { | |
| 68 | + display: inline-flex; align-items: center; justify-content: center; gap: 6px; | |
| 69 | + cursor: pointer; text-decoration: none; | |
| 70 | + font-family: var(--font-heading); font-weight: var(--font-heading-weight); | |
| 71 | + font-size: 14px; line-height: 1.2; color: var(--color-text); | |
| 72 | + background: transparent; border: 1px solid transparent; | |
| 73 | + padding: var(--space-2) calc(var(--space-3) * 1.2); | |
| 74 | + border-radius: 999px; | |
| 28 | 75 | } |
| 76 | +.btn svg { display: block; } | |
| 77 | +.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; pointer-events: none; } | |
| 78 | +.btn-primary { background: var(--color-accent); color: var(--color-bg); } | |
| 79 | +.btn-primary:hover { background: var(--color-accent-600); } | |
| 80 | +.btn-primary:active { background: var(--color-accent-700); } | |
| 81 | +.btn-secondary { border-color: var(--color-divider); } | |
| 82 | +.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } | |
| 83 | +.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); } | |
| 84 | +.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); } | |
| 85 | +.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } | |
| 86 | +.btn-icon { width: 36px; height: 36px; padding: 0; } | |
| 87 | +.btn-icon-sm { width: 32px; height: 32px; padding: 0; } | |
| 88 | +.btn-block { width: 100%; margin-top: var(--space-2); } | |
| 29 | 89 | |
| 30 | -.topbar { | |
| 31 | - background: var(--primary); | |
| 32 | - padding: .7rem 1rem; | |
| 33 | - position: sticky; | |
| 34 | - top: 0; | |
| 90 | +/* — formulaires — */ | |
| 91 | +.field > label { | |
| 92 | + display: block; font-size: 12px; margin-bottom: 5px; | |
| 93 | + color: color-mix(in srgb, var(--color-text) 70%, transparent); | |
| 35 | 94 | } |
| 36 | -.brand { | |
| 37 | - color: var(--primary-ink); | |
| 38 | - text-decoration: none; | |
| 39 | - font-weight: 700; | |
| 40 | - font-size: 1.05rem; | |
| 95 | +.input { | |
| 96 | + width: 100%; min-height: 36px; padding: 6px 14px; font: inherit; | |
| 97 | + font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); | |
| 98 | + background: var(--color-surface); | |
| 99 | + border: 1px solid var(--color-divider); border-radius: 999px; | |
| 41 | 100 | } |
| 101 | +.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } | |
| 102 | +.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; } | |
| 42 | 103 | |
| 43 | -.page { | |
| 44 | - max-width: 560px; | |
| 45 | - margin: 0 auto; | |
| 46 | - padding: 1rem 1rem 3rem; | |
| 104 | +/* — cartes — */ | |
| 105 | +.card { | |
| 106 | + display: flex; flex-direction: column; gap: var(--space-2); | |
| 107 | + padding: var(--space-3); border-radius: calc(var(--radius-lg) * 1.15); | |
| 108 | + background: var(--color-surface); | |
| 47 | 109 | } |
| 110 | +.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); } | |
| 111 | +.card-body { margin: 0; font-size: 13px; opacity: 0.8; } | |
| 48 | 112 | |
| 49 | -h1 { font-size: 1.4rem; margin: .5rem 0 1rem; } | |
| 50 | -h2 { font-size: 1.1rem; margin: 1.6rem 0 .6rem; } | |
| 113 | +/* ═══ Couche applicative time-time ═══ */ | |
| 51 | 114 | |
| 52 | -/* Boutons — cibles tactiles généreuses. */ | |
| 53 | -.btn { | |
| 54 | - display: inline-block; | |
| 55 | - padding: .65rem 1rem; | |
| 56 | - min-height: 44px; | |
| 57 | - border: 1px solid var(--border); | |
| 58 | - border-radius: var(--radius); | |
| 59 | - background: var(--card); | |
| 60 | - color: var(--ink); | |
| 61 | - text-decoration: none; | |
| 62 | - text-align: center; | |
| 63 | - font-size: 1rem; | |
| 64 | - cursor: pointer; | |
| 65 | -} | |
| 66 | -.btn:active { transform: translateY(1px); } | |
| 67 | -.btn-primary { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); } | |
| 68 | -.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; } | |
| 69 | -.btn-big { display: block; width: 100%; padding: 1rem; font-size: 1.15rem; } | |
| 70 | -.btn-small { padding: .4rem .7rem; min-height: 36px; font-size: .9rem; } | |
| 71 | - | |
| 72 | -.menu { margin-top: 2rem; } | |
| 73 | - | |
| 74 | -.stack { display: flex; flex-direction: column; gap: .5rem; } | |
| 75 | -.stack label { font-weight: 600; margin-top: .6rem; } | |
| 76 | -.stack .btn { margin-top: 1rem; } | |
| 77 | - | |
| 78 | -input, select { | |
| 79 | - width: 100%; | |
| 80 | - padding: .65rem .8rem; | |
| 81 | - font-size: 1.05rem; | |
| 82 | - border: 1px solid var(--border); | |
| 83 | - border-radius: var(--radius); | |
| 84 | - background: var(--card); | |
| 85 | - color: var(--ink); | |
| 86 | -} | |
| 87 | -input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| 88 | - | |
| 89 | -.presets { display: flex; gap: .5rem; flex-wrap: wrap; } | |
| 90 | -.btn-preset { flex: 1 1 auto; } | |
| 91 | - | |
| 92 | -.flash { | |
| 93 | - padding: .6rem .9rem; | |
| 94 | - border-radius: var(--radius); | |
| 95 | - margin: .5rem 0; | |
| 96 | -} | |
| 97 | -.flash-ok { background: var(--ok-bg); color: var(--ok-ink); } | |
| 98 | -.flash-error { background: var(--err-bg); color: var(--err-ink); } | |
| 99 | - | |
| 100 | -/* Liste de mes temps */ | |
| 101 | -.entries { list-style: none; margin: 0; padding: 0; } | |
| 102 | -.entries li + li { margin-top: .5rem; } | |
| 103 | -.entry { | |
| 104 | - display: grid; | |
| 105 | - grid-template-columns: auto 1fr auto; | |
| 106 | - gap: .8rem; | |
| 107 | - align-items: center; | |
| 108 | - padding: .8rem 1rem; | |
| 109 | - background: var(--card); | |
| 110 | - border: 1px solid var(--border); | |
| 111 | - border-radius: var(--radius); | |
| 112 | - text-decoration: none; | |
| 113 | - color: var(--ink); | |
| 114 | -} | |
| 115 | -.entry-date { color: var(--muted); font-variant-numeric: tabular-nums; } | |
| 116 | -.entry-task { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 117 | -.entry-duration { font-variant-numeric: tabular-nums; } | |
| 118 | - | |
| 119 | -.pager { display: flex; justify-content: space-between; gap: .5rem; margin: 1rem 0; } | |
| 120 | - | |
| 121 | -/* Stats */ | |
| 122 | -.chart-block { | |
| 123 | - background: var(--card); | |
| 124 | - border: 1px solid var(--border); | |
| 125 | - border-radius: var(--radius); | |
| 126 | - padding: .8rem; | |
| 127 | - margin-bottom: 1.2rem; | |
| 128 | -} | |
| 129 | -.chart-head { | |
| 130 | - display: flex; | |
| 131 | - align-items: center; | |
| 132 | - justify-content: space-between; | |
| 133 | - gap: .5rem; | |
| 134 | - margin-bottom: .4rem; | |
| 135 | -} | |
| 136 | -.chart-head h2 { margin: 0; font-size: 1rem; text-align: center; flex: 1; } | |
| 137 | -.btn-nav { min-width: 44px; } | |
| 138 | -.btn-nav-placeholder { min-width: 44px; display: inline-block; } | |
| 139 | - | |
| 140 | -.chart { display: block; } | |
| 141 | -.gridline { stroke: var(--border); stroke-width: 1; } | |
| 142 | -.axis { stroke: var(--muted); stroke-width: 1; } | |
| 143 | -.tick { font-size: 10px; fill: var(--muted); } | |
| 144 | -.bar-label { font-size: 11px; fill: var(--ink); font-weight: 600; } | |
| 145 | -.total-label { font-size: 10px; fill: var(--muted); } | |
| 146 | -.empty-label { font-size: 12px; fill: var(--muted); } | |
| 147 | - | |
| 148 | -.legend { | |
| 149 | - list-style: none; | |
| 150 | - margin: 0 0 1rem; | |
| 151 | - padding: 0; | |
| 152 | - display: flex; | |
| 153 | - flex-wrap: wrap; | |
| 154 | - gap: .4rem .9rem; | |
| 155 | - font-size: .9rem; | |
| 156 | -} | |
| 157 | -.legend li { display: flex; align-items: center; gap: .35rem; } | |
| 158 | -.swatch { | |
| 159 | - width: 12px; height: 12px; | |
| 160 | - border-radius: 3px; | |
| 161 | - display: inline-block; | |
| 162 | -} | |
| 163 | - | |
| 164 | -/* Paramètres */ | |
| 165 | -.task-list { list-style: none; margin: 0; padding: 0; } | |
| 166 | -.task { | |
| 167 | - display: flex; | |
| 168 | - gap: .5rem; | |
| 169 | - align-items: center; | |
| 170 | - padding: .5rem 0; | |
| 171 | - border-bottom: 1px solid var(--border); | |
| 172 | -} | |
| 173 | -.task-rename { display: flex; gap: .5rem; flex: 1; } | |
| 174 | -.task-archived input { color: var(--muted); } | |
| 175 | -.task-add { display: flex; gap: .5rem; margin-top: .8rem; } | |
| 176 | -.hint { color: var(--muted); font-size: .85rem; } | |
| 177 | -.danger-zone { margin-top: 2rem; } | |
| 115 | +.viewport { min-height: 100dvh; display: flex; justify-content: center; background: var(--color-neutral-300); } | |
| 116 | +.shell { | |
| 117 | + width: 100%; max-width: 430px; min-height: 100dvh; background: var(--color-bg); | |
| 118 | + display: flex; flex-direction: column; position: relative; | |
| 119 | +} | |
| 120 | + | |
| 121 | +.app-header { | |
| 122 | + padding: var(--space-6) var(--space-4) var(--space-3); | |
| 123 | + display: flex; align-items: baseline; gap: var(--space-2); | |
| 124 | +} | |
| 125 | +.app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } | |
| 126 | +.app-header .who { margin-left: auto; font-size: 12px; opacity: .55; } | |
| 127 | + | |
| 128 | +.app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; } | |
| 129 | + | |
| 130 | +/* — tab bar — */ | |
| 131 | +.tabbar { | |
| 132 | + position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); | |
| 133 | + width: 100%; max-width: 430px; | |
| 134 | + display: flex; padding: var(--space-2) var(--space-2) calc(var(--space-3) + 6px); | |
| 135 | + background: var(--color-surface); | |
| 136 | + border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg); | |
| 137 | + box-shadow: var(--shadow-md); | |
| 138 | +} | |
| 139 | +.tab { | |
| 140 | + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; | |
| 141 | + padding: 8px 2px; border-radius: 999px; text-decoration: none; | |
| 142 | + color: color-mix(in srgb, var(--color-text) 50%, transparent); | |
| 143 | + font-size: 11px; | |
| 144 | +} | |
| 145 | +.tab[aria-current="page"] { color: var(--color-accent-700); font-weight: 700; } | |
| 146 | +.tab svg { display: block; } | |
| 147 | + | |
| 148 | +/* — login — */ | |
| 149 | +.login { | |
| 150 | + flex: 1; display: flex; flex-direction: column; justify-content: center; | |
| 151 | + padding: var(--space-8) var(--space-6) 64px; | |
| 152 | +} | |
| 153 | +.login-icon { | |
| 154 | + width: 72px; height: 72px; border-radius: 999px; background: var(--color-accent); | |
| 155 | + display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-6); | |
| 156 | +} | |
| 157 | +.login h1 { font-size: 44px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; } | |
| 158 | +.login .tagline { font-size: 15px; opacity: .65; margin: 0 0 var(--space-8); max-width: 15em; } | |
| 159 | +.form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); } | |
| 160 | + | |
| 161 | +/* — ajout étape 1 : liste de tâches — */ | |
| 162 | +.step-hint { font-size: 13px; opacity: .6; margin: 0 0 var(--space-4); } | |
| 163 | +.task-pick { display: grid; gap: var(--space-2); } | |
| 164 | +.task-pick a { | |
| 165 | + display: flex; align-items: center; gap: var(--space-3); | |
| 166 | + padding: var(--space-3) var(--space-4); background: var(--color-surface); | |
| 167 | + border: 1px solid transparent; border-radius: 999px; | |
| 168 | + font-size: 16px; color: var(--color-text); text-decoration: none; | |
| 169 | +} | |
| 170 | +.task-pick a:hover { border-color: var(--color-accent); } | |
| 171 | +.task-pick .tname { flex: 1; } | |
| 172 | +.task-pick .tweek { font-size: 12px; opacity: .5; } | |
| 173 | +.dot { border-radius: 999px; flex: none; display: inline-block; } | |
| 174 | +.dot-14 { width: 14px; height: 14px; } | |
| 175 | +.dot-12 { width: 12px; height: 12px; } | |
| 176 | +.dot-16 { width: 16px; height: 16px; } | |
| 177 | + | |
| 178 | +/* — ajout étape 2 — */ | |
| 179 | +.picked { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-6); } | |
| 180 | +.picked .pname { font-size: 20px; font-family: var(--font-heading); } | |
| 181 | +.mini-label { font-size: 12px; opacity: .7; margin: 0 0 var(--space-2); } | |
| 182 | +.duration-row { display: flex; align-items: baseline; gap: 6px; margin-bottom: var(--space-3); } | |
| 183 | +.duration-row .minutes-input { | |
| 184 | + width: 130px; font-size: 34px; font-family: var(--font-heading); | |
| 185 | + min-height: 64px; text-align: center; padding: 0 10px; | |
| 186 | +} | |
| 187 | +.duration-row .unit { font-size: 16px; opacity: .6; } | |
| 188 | +.duration-row .pretty { margin-left: auto; font-size: 14px; opacity: .55; } | |
| 189 | +.presets { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-6); } | |
| 190 | +.preset { | |
| 191 | + padding: 9px 16px; border-radius: 999px; font-size: 14px; | |
| 192 | + font-family: var(--font-heading); text-decoration: none; | |
| 193 | + border: 1px solid var(--color-divider); color: var(--color-text); | |
| 194 | +} | |
| 195 | +.preset[aria-current="true"] { background: var(--color-accent); color: var(--color-bg); border-color: transparent; } | |
| 196 | +.date-row { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-3); } | |
| 197 | +.date-row .input { flex: 1; min-height: 44px; text-align: center; } | |
| 198 | +.date-label { font-size: 13px; opacity: .6; margin: 0 0 var(--space-6); } | |
| 199 | + | |
| 200 | +/* — stats — */ | |
| 201 | +.legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-6); padding: 0; list-style: none; } | |
| 202 | +.legend li { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } | |
| 203 | +.legend .dot { width: 10px; height: 10px; } | |
| 204 | +.chart-section { margin-bottom: var(--space-8); } | |
| 205 | +.chart-head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-1); } | |
| 206 | +.chart-head h2 { margin: 0; font-size: 21px; } | |
| 207 | +.chart-nav { margin-left: auto; display: flex; gap: 6px; } | |
| 208 | +.chart-range { font-size: 12px; opacity: .55; margin: 0 0 var(--space-4); } | |
| 209 | +.chart-empty { font-size: 14px; opacity: .55; margin: 0; padding: var(--space-6) 0; } | |
| 210 | +.cols { display: flex; align-items: flex-end; gap: var(--space-4); height: 190px; padding-top: var(--space-2); } | |
| 211 | +.col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } | |
| 212 | +.col .total { font-size: 12px; opacity: .6; } | |
| 213 | +.col .user { font-size: 13px; font-weight: 600; } | |
| 214 | +.col-bar { | |
| 215 | + width: 100%; max-width: 64px; display: flex; flex-direction: column; | |
| 216 | + border-radius: var(--radius-md); overflow: hidden; background: var(--color-neutral-200); | |
| 217 | +} | |
| 218 | +.col-bar a { display: block; min-height: 3px; width: 100%; } | |
| 219 | + | |
| 220 | +/* — bottom sheet (zéro JS : :target) — */ | |
| 221 | +.sheet-wrap { position: fixed; inset: 0; display: none; z-index: 10; } | |
| 222 | +.sheet-wrap:target { display: flex; align-items: flex-end; justify-content: center; } | |
| 223 | +.sheet-backdrop { | |
| 224 | + position: absolute; inset: 0; | |
| 225 | + background: color-mix(in srgb, var(--color-neutral-900) 45%, transparent); | |
| 226 | +} | |
| 227 | +.sheet { | |
| 228 | + position: relative; width: 100%; max-width: 430px; background: var(--color-bg); | |
| 229 | + border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg); | |
| 230 | + padding: var(--space-6) var(--space-4) var(--space-8); box-shadow: var(--shadow-lg); | |
| 231 | + animation: tt-rise .18s ease-out; | |
| 232 | +} | |
| 233 | +.sheet-head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-4); } | |
| 234 | +.sheet-head .pname { font-family: var(--font-heading); font-size: 20px; } | |
| 235 | +.sheet-head .close { margin-left: auto; } | |
| 236 | +.sheet-figures { display: flex; gap: var(--space-6); } | |
| 237 | +.sheet-figures .k { font-size: 11px; opacity: .55; text-transform: uppercase; letter-spacing: .08em; } | |
| 238 | +.sheet-figures .v { font-size: 30px; font-family: var(--font-heading); } | |
| 239 | +.sheet-note { font-size: 13px; opacity: .6; margin: var(--space-4) 0 0; } | |
| 240 | + | |
| 241 | +/* — mes temps — */ | |
| 242 | +.times { list-style: none; margin: 0; padding: 0; } | |
| 243 | +.times li { | |
| 244 | + display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; | |
| 245 | + border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | |
| 246 | +} | |
| 247 | +.times .tinfo { flex: 1; min-width: 0; } | |
| 248 | +.times .ttask { font-size: 15px; } | |
| 249 | +.times .tdate { font-size: 12px; opacity: .55; } | |
| 250 | +.times .tdur { font-size: 16px; font-family: var(--font-heading); } | |
| 251 | + | |
| 252 | +/* — tâches — */ | |
| 253 | +.task-row { | |
| 254 | + display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; | |
| 255 | + border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | |
| 256 | +} | |
| 257 | +.task-row form.rename { flex: 1; } | |
| 258 | +.task-row .rename .input { | |
| 259 | + border-color: transparent; background: transparent; padding-left: 0; | |
| 260 | + font-size: 15px; border-radius: 0; | |
| 261 | +} | |
| 262 | +.task-row .rename .input:hover { border-color: var(--color-divider); } | |
| 263 | +.task-row.archived .rename .input { opacity: .45; } | |
| 264 | +.task-add-row { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); } | |
| 265 | +.task-add-row .input { flex: 1; min-height: 44px; } | |
| 266 | +.visually-hidden { | |
| 267 | + position: absolute; width: 1px; height: 1px; overflow: hidden; | |
| 268 | + clip: rect(0 0 0 0); white-space: nowrap; | |
| 269 | +} | |
| 270 | + | |
| 271 | +/* — toast — */ | |
| 272 | +.toast { | |
| 273 | + position: fixed; bottom: 96px; left: 50%; transform: translateX(-50%); | |
| 274 | + width: calc(100% - 2 * var(--space-4)); max-width: calc(430px - 2 * var(--space-4)); | |
| 275 | + background: var(--color-neutral-900); color: var(--color-neutral-100); | |
| 276 | + padding: var(--space-3) var(--space-4); border-radius: 999px; | |
| 277 | + font-size: 14px; text-align: center; box-shadow: var(--shadow-md); | |
| 278 | + animation: tt-rise .18s ease-out; z-index: 5; | |
| 279 | +} | |
| 280 | + | |
| 281 | +@keyframes tt-rise { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } } | |
| 282 | +.sheet { animation-name: tt-rise-sheet; } | |
| 283 | +@keyframes tt-rise-sheet { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } | |
| 284 | +@media (prefers-reduced-motion: reduce) { .sheet, .toast { animation: none; } } | |
| @@ -1,177 +1,284 @@ | |||
| 1 | -/* Mobile-first, zéro JS. */ | 1 | +/* ═══ Organic — design-system tokens (source : projet Claude Design) ═══ */ |
| 2 | +@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap'); | ||
| 3 | + | ||
| 2 | :root { | 4 | :root { |
| 3 | - --bg: #f7f8fa; | 5 | + --color-bg: #f5ead8; |
| 4 | - --card: #ffffff; | 6 | + --color-surface: #ebddc5; |
| 5 | - --ink: #1a2233; | 7 | + --color-text: #201e1d; |
| 6 | - --muted: #5b6474; | 8 | + --color-accent: #c67139; |
| 7 | - --primary: #1d3557; | 9 | + --color-accent-2: #7a8a5e; |
| 8 | - --primary-ink: #ffffff; | 10 | + --color-divider: color-mix(in srgb, #201e1d 16%, transparent); |
| 9 | - --accent: #4e79a7; | 11 | + |
| 10 | - --danger: #c0392b; | 12 | + --color-neutral-100: #f9f4ed; |
| 11 | - --border: #d8dde6; | 13 | + --color-neutral-200: #eee7db; |
| 12 | - --ok-bg: #e7f6ec; | 14 | + --color-neutral-300: #dcd3c4; |
| 13 | - --ok-ink: #1e7a3c; | 15 | + --color-neutral-500: #a19786; |
| 14 | - --err-bg: #fdecea; | 16 | + --color-neutral-900: #2e2b25; |
| 15 | - --err-ink: #b23327; | 17 | + |
| 16 | - --radius: 12px; | 18 | + --color-accent-100: #fff2eb; |
| 17 | -} | 19 | + --color-accent-300: #ffc6a5; |
| 18 | - | 20 | + --color-accent-600: #b2622d; |
| 19 | -* { box-sizing: border-box; } | 21 | + --color-accent-700: #8c491a; |
| 22 | + | ||
| 23 | + --font-heading: "Caprasimo", system-ui, sans-serif; | ||
| 24 | + --font-heading-weight: 400; | ||
| 25 | + --font-body: "Figtree", system-ui, sans-serif; | ||
| 26 | + | ||
| 27 | + --space-1: 4.4px; | ||
| 28 | + --space-2: 8.8px; | ||
| 29 | + --space-3: 13.2px; | ||
| 30 | + --space-4: 17.6px; | ||
| 31 | + --space-6: 26.4px; | ||
| 32 | + --space-8: 35.2px; | ||
| 33 | + | ||
| 34 | + --radius-sm: 8px; | ||
| 35 | + --radius-md: 16px; | ||
| 36 | + --radius-lg: 28px; | ||
| 20 | 37 | ||
| 38 | + --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); | ||
| 39 | + --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); | ||
| 40 | + --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +*, *::before, *::after { box-sizing: border-box; } | ||
| 44 | +html, body { margin: 0; height: 100%; } | ||
| 21 | body { | 45 | body { |
| 22 | - margin: 0; | 46 | + background: var(--color-neutral-300); |
| 23 | - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | 47 | + color: var(--color-text); |
| 24 | - background: var(--bg); | 48 | + font-family: var(--font-body); |
| 25 | - color: var(--ink); | 49 | + font-size: 15px; line-height: 1.55; |
| 26 | - font-size: 17px; | 50 | +} |
| 27 | - line-height: 1.45; | 51 | +h1, h2, h3, h4 { |
| 52 | + font-family: var(--font-heading); font-weight: var(--font-heading-weight); | ||
| 53 | + line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2); | ||
| 54 | +} | ||
| 55 | +p { margin: 0 0 var(--space-3); } | ||
| 56 | +a { color: var(--color-accent); text-underline-offset: 3px; } | ||
| 57 | +:focus { outline: none; } | ||
| 58 | +:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } | ||
| 59 | +::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); } | ||
| 60 | +button { font-family: inherit; } | ||
| 61 | +input[type=number]::-webkit-outer-spin-button, | ||
| 62 | +input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } | ||
| 63 | + | ||
| 64 | +.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); } | ||
| 65 | + | ||
| 66 | +/* — boutons — */ | ||
| 67 | +.btn { | ||
| 68 | + display: inline-flex; align-items: center; justify-content: center; gap: 6px; | ||
| 69 | + cursor: pointer; text-decoration: none; | ||
| 70 | + font-family: var(--font-heading); font-weight: var(--font-heading-weight); | ||
| 71 | + font-size: 14px; line-height: 1.2; color: var(--color-text); | ||
| 72 | + background: transparent; border: 1px solid transparent; | ||
| 73 | + padding: var(--space-2) calc(var(--space-3) * 1.2); | ||
| 74 | + border-radius: 999px; | ||
| 28 | } | 75 | } |
| 76 | +.btn svg { display: block; } | ||
| 77 | +.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; pointer-events: none; } | ||
| 78 | +.btn-primary { background: var(--color-accent); color: var(--color-bg); } | ||
| 79 | +.btn-primary:hover { background: var(--color-accent-600); } | ||
| 80 | +.btn-primary:active { background: var(--color-accent-700); } | ||
| 81 | +.btn-secondary { border-color: var(--color-divider); } | ||
| 82 | +.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } | ||
| 83 | +.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); } | ||
| 84 | +.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); } | ||
| 85 | +.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } | ||
| 86 | +.btn-icon { width: 36px; height: 36px; padding: 0; } | ||
| 87 | +.btn-icon-sm { width: 32px; height: 32px; padding: 0; } | ||
| 88 | +.btn-block { width: 100%; margin-top: var(--space-2); } | ||
| 29 | 89 | ||
| 30 | -.topbar { | 90 | +/* — formulaires — */ |
| 31 | - background: var(--primary); | 91 | +.field > label { |
| 32 | - padding: .7rem 1rem; | 92 | + display: block; font-size: 12px; margin-bottom: 5px; |
| 33 | - position: sticky; | 93 | + color: color-mix(in srgb, var(--color-text) 70%, transparent); |
| 34 | - top: 0; | ||
| 35 | } | 94 | } |
| 36 | -.brand { | 95 | +.input { |
| 37 | - color: var(--primary-ink); | 96 | + width: 100%; min-height: 36px; padding: 6px 14px; font: inherit; |
| 38 | - text-decoration: none; | 97 | + font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); |
| 39 | - font-weight: 700; | 98 | + background: var(--color-surface); |
| 40 | - font-size: 1.05rem; | 99 | + border: 1px solid var(--color-divider); border-radius: 999px; |
| 41 | } | 100 | } |
| 101 | +.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } | ||
| 102 | +.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; } | ||
| 42 | 103 | ||
| 43 | -.page { | 104 | +/* — cartes — */ |
| 44 | - max-width: 560px; | 105 | +.card { |
| 45 | - margin: 0 auto; | 106 | + display: flex; flex-direction: column; gap: var(--space-2); |
| 46 | - padding: 1rem 1rem 3rem; | 107 | + padding: var(--space-3); border-radius: calc(var(--radius-lg) * 1.15); |
| 108 | + background: var(--color-surface); | ||
| 47 | } | 109 | } |
| 110 | +.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); } | ||
| 111 | +.card-body { margin: 0; font-size: 13px; opacity: 0.8; } | ||
| 48 | 112 | ||
| 49 | -h1 { font-size: 1.4rem; margin: .5rem 0 1rem; } | 113 | +/* ═══ Couche applicative time-time ═══ */ |
| 50 | -h2 { font-size: 1.1rem; margin: 1.6rem 0 .6rem; } | ||
| 51 | 114 | ||
| 52 | -/* Boutons — cibles tactiles généreuses. */ | 115 | +.viewport { min-height: 100dvh; display: flex; justify-content: center; background: var(--color-neutral-300); } |
| 53 | -.btn { | 116 | +.shell { |
| 54 | - display: inline-block; | 117 | + width: 100%; max-width: 430px; min-height: 100dvh; background: var(--color-bg); |
| 55 | - padding: .65rem 1rem; | 118 | + display: flex; flex-direction: column; position: relative; |
| 56 | - min-height: 44px; | 119 | +} |
| 57 | - border: 1px solid var(--border); | 120 | + |
| 58 | - border-radius: var(--radius); | 121 | +.app-header { |
| 59 | - background: var(--card); | 122 | + padding: var(--space-6) var(--space-4) var(--space-3); |
| 60 | - color: var(--ink); | 123 | + display: flex; align-items: baseline; gap: var(--space-2); |
| 61 | - text-decoration: none; | 124 | +} |
| 62 | - text-align: center; | 125 | +.app-header h1 { margin: 0; font-size: 29px; letter-spacing: -0.02em; } |
| 63 | - font-size: 1rem; | 126 | +.app-header .who { margin-left: auto; font-size: 12px; opacity: .55; } |
| 64 | - cursor: pointer; | 127 | + |
| 65 | -} | 128 | +.app-main { flex: 1; min-height: 0; padding: 0 var(--space-4) 120px; } |
| 66 | -.btn:active { transform: translateY(1px); } | 129 | + |
| 67 | -.btn-primary { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); } | 130 | +/* — tab bar — */ |
| 68 | -.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; } | 131 | +.tabbar { |
| 69 | -.btn-big { display: block; width: 100%; padding: 1rem; font-size: 1.15rem; } | 132 | + position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); |
| 70 | -.btn-small { padding: .4rem .7rem; min-height: 36px; font-size: .9rem; } | 133 | + width: 100%; max-width: 430px; |
| 71 | - | 134 | + display: flex; padding: var(--space-2) var(--space-2) calc(var(--space-3) + 6px); |
| 72 | -.menu { margin-top: 2rem; } | 135 | + background: var(--color-surface); |
| 73 | - | 136 | + border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg); |
| 74 | -.stack { display: flex; flex-direction: column; gap: .5rem; } | 137 | + box-shadow: var(--shadow-md); |
| 75 | -.stack label { font-weight: 600; margin-top: .6rem; } | 138 | +} |
| 76 | -.stack .btn { margin-top: 1rem; } | 139 | +.tab { |
| 77 | - | 140 | + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; |
| 78 | -input, select { | 141 | + padding: 8px 2px; border-radius: 999px; text-decoration: none; |
| 79 | - width: 100%; | 142 | + color: color-mix(in srgb, var(--color-text) 50%, transparent); |
| 80 | - padding: .65rem .8rem; | 143 | + font-size: 11px; |
| 81 | - font-size: 1.05rem; | 144 | +} |
| 82 | - border: 1px solid var(--border); | 145 | +.tab[aria-current="page"] { color: var(--color-accent-700); font-weight: 700; } |
| 83 | - border-radius: var(--radius); | 146 | +.tab svg { display: block; } |
| 84 | - background: var(--card); | 147 | + |
| 85 | - color: var(--ink); | 148 | +/* — login — */ |
| 86 | -} | 149 | +.login { |
| 87 | -input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; } | 150 | + flex: 1; display: flex; flex-direction: column; justify-content: center; |
| 88 | - | 151 | + padding: var(--space-8) var(--space-6) 64px; |
| 89 | -.presets { display: flex; gap: .5rem; flex-wrap: wrap; } | 152 | +} |
| 90 | -.btn-preset { flex: 1 1 auto; } | 153 | +.login-icon { |
| 91 | - | 154 | + width: 72px; height: 72px; border-radius: 999px; background: var(--color-accent); |
| 92 | -.flash { | 155 | + display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-6); |
| 93 | - padding: .6rem .9rem; | 156 | +} |
| 94 | - border-radius: var(--radius); | 157 | +.login h1 { font-size: 44px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; } |
| 95 | - margin: .5rem 0; | 158 | +.login .tagline { font-size: 15px; opacity: .65; margin: 0 0 var(--space-8); max-width: 15em; } |
| 96 | -} | 159 | +.form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); } |
| 97 | -.flash-ok { background: var(--ok-bg); color: var(--ok-ink); } | 160 | + |
| 98 | -.flash-error { background: var(--err-bg); color: var(--err-ink); } | 161 | +/* — ajout étape 1 : liste de tâches — */ |
| 99 | - | 162 | +.step-hint { font-size: 13px; opacity: .6; margin: 0 0 var(--space-4); } |
| 100 | -/* Liste de mes temps */ | 163 | +.task-pick { display: grid; gap: var(--space-2); } |
| 101 | -.entries { list-style: none; margin: 0; padding: 0; } | 164 | +.task-pick a { |
| 102 | -.entries li + li { margin-top: .5rem; } | 165 | + display: flex; align-items: center; gap: var(--space-3); |
| 103 | -.entry { | 166 | + padding: var(--space-3) var(--space-4); background: var(--color-surface); |
| 104 | - display: grid; | 167 | + border: 1px solid transparent; border-radius: 999px; |
| 105 | - grid-template-columns: auto 1fr auto; | 168 | + font-size: 16px; color: var(--color-text); text-decoration: none; |
| 106 | - gap: .8rem; | 169 | +} |
| 107 | - align-items: center; | 170 | +.task-pick a:hover { border-color: var(--color-accent); } |
| 108 | - padding: .8rem 1rem; | 171 | +.task-pick .tname { flex: 1; } |
| 109 | - background: var(--card); | 172 | +.task-pick .tweek { font-size: 12px; opacity: .5; } |
| 110 | - border: 1px solid var(--border); | 173 | +.dot { border-radius: 999px; flex: none; display: inline-block; } |
| 111 | - border-radius: var(--radius); | 174 | +.dot-14 { width: 14px; height: 14px; } |
| 112 | - text-decoration: none; | 175 | +.dot-12 { width: 12px; height: 12px; } |
| 113 | - color: var(--ink); | 176 | +.dot-16 { width: 16px; height: 16px; } |
| 114 | -} | 177 | + |
| 115 | -.entry-date { color: var(--muted); font-variant-numeric: tabular-nums; } | 178 | +/* — ajout étape 2 — */ |
| 116 | -.entry-task { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 179 | +.picked { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-6); } |
| 117 | -.entry-duration { font-variant-numeric: tabular-nums; } | 180 | +.picked .pname { font-size: 20px; font-family: var(--font-heading); } |
| 118 | - | 181 | +.mini-label { font-size: 12px; opacity: .7; margin: 0 0 var(--space-2); } |
| 119 | -.pager { display: flex; justify-content: space-between; gap: .5rem; margin: 1rem 0; } | 182 | +.duration-row { display: flex; align-items: baseline; gap: 6px; margin-bottom: var(--space-3); } |
| 120 | - | 183 | +.duration-row .minutes-input { |
| 121 | -/* Stats */ | 184 | + width: 130px; font-size: 34px; font-family: var(--font-heading); |
| 122 | -.chart-block { | 185 | + min-height: 64px; text-align: center; padding: 0 10px; |
| 123 | - background: var(--card); | 186 | +} |
| 124 | - border: 1px solid var(--border); | 187 | +.duration-row .unit { font-size: 16px; opacity: .6; } |
| 125 | - border-radius: var(--radius); | 188 | +.duration-row .pretty { margin-left: auto; font-size: 14px; opacity: .55; } |
| 126 | - padding: .8rem; | 189 | +.presets { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-6); } |
| 127 | - margin-bottom: 1.2rem; | 190 | +.preset { |
| 128 | -} | 191 | + padding: 9px 16px; border-radius: 999px; font-size: 14px; |
| 129 | -.chart-head { | 192 | + font-family: var(--font-heading); text-decoration: none; |
| 130 | - display: flex; | 193 | + border: 1px solid var(--color-divider); color: var(--color-text); |
| 131 | - align-items: center; | 194 | +} |
| 132 | - justify-content: space-between; | 195 | +.preset[aria-current="true"] { background: var(--color-accent); color: var(--color-bg); border-color: transparent; } |
| 133 | - gap: .5rem; | 196 | +.date-row { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-3); } |
| 134 | - margin-bottom: .4rem; | 197 | +.date-row .input { flex: 1; min-height: 44px; text-align: center; } |
| 135 | -} | 198 | +.date-label { font-size: 13px; opacity: .6; margin: 0 0 var(--space-6); } |
| 136 | -.chart-head h2 { margin: 0; font-size: 1rem; text-align: center; flex: 1; } | 199 | + |
| 137 | -.btn-nav { min-width: 44px; } | 200 | +/* — stats — */ |
| 138 | -.btn-nav-placeholder { min-width: 44px; display: inline-block; } | 201 | +.legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-6); padding: 0; list-style: none; } |
| 139 | - | 202 | +.legend li { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } |
| 140 | -.chart { display: block; } | 203 | +.legend .dot { width: 10px; height: 10px; } |
| 141 | -.gridline { stroke: var(--border); stroke-width: 1; } | 204 | +.chart-section { margin-bottom: var(--space-8); } |
| 142 | -.axis { stroke: var(--muted); stroke-width: 1; } | 205 | +.chart-head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-1); } |
| 143 | -.tick { font-size: 10px; fill: var(--muted); } | 206 | +.chart-head h2 { margin: 0; font-size: 21px; } |
| 144 | -.bar-label { font-size: 11px; fill: var(--ink); font-weight: 600; } | 207 | +.chart-nav { margin-left: auto; display: flex; gap: 6px; } |
| 145 | -.total-label { font-size: 10px; fill: var(--muted); } | 208 | +.chart-range { font-size: 12px; opacity: .55; margin: 0 0 var(--space-4); } |
| 146 | -.empty-label { font-size: 12px; fill: var(--muted); } | 209 | +.chart-empty { font-size: 14px; opacity: .55; margin: 0; padding: var(--space-6) 0; } |
| 147 | - | 210 | +.cols { display: flex; align-items: flex-end; gap: var(--space-4); height: 190px; padding-top: var(--space-2); } |
| 148 | -.legend { | 211 | +.col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; } |
| 149 | - list-style: none; | 212 | +.col .total { font-size: 12px; opacity: .6; } |
| 150 | - margin: 0 0 1rem; | 213 | +.col .user { font-size: 13px; font-weight: 600; } |
| 151 | - padding: 0; | 214 | +.col-bar { |
| 152 | - display: flex; | 215 | + width: 100%; max-width: 64px; display: flex; flex-direction: column; |
| 153 | - flex-wrap: wrap; | 216 | + border-radius: var(--radius-md); overflow: hidden; background: var(--color-neutral-200); |
| 154 | - gap: .4rem .9rem; | 217 | +} |
| 155 | - font-size: .9rem; | 218 | +.col-bar a { display: block; min-height: 3px; width: 100%; } |
| 156 | -} | 219 | + |
| 157 | -.legend li { display: flex; align-items: center; gap: .35rem; } | 220 | +/* — bottom sheet (zéro JS : :target) — */ |
| 158 | -.swatch { | 221 | +.sheet-wrap { position: fixed; inset: 0; display: none; z-index: 10; } |
| 159 | - width: 12px; height: 12px; | 222 | +.sheet-wrap:target { display: flex; align-items: flex-end; justify-content: center; } |
| 160 | - border-radius: 3px; | 223 | +.sheet-backdrop { |
| 161 | - display: inline-block; | 224 | + position: absolute; inset: 0; |
| 162 | -} | 225 | + background: color-mix(in srgb, var(--color-neutral-900) 45%, transparent); |
| 163 | - | 226 | +} |
| 164 | -/* Paramètres */ | 227 | +.sheet { |
| 165 | -.task-list { list-style: none; margin: 0; padding: 0; } | 228 | + position: relative; width: 100%; max-width: 430px; background: var(--color-bg); |
| 166 | -.task { | 229 | + border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg); |
| 167 | - display: flex; | 230 | + padding: var(--space-6) var(--space-4) var(--space-8); box-shadow: var(--shadow-lg); |
| 168 | - gap: .5rem; | 231 | + animation: tt-rise .18s ease-out; |
| 169 | - align-items: center; | 232 | +} |
| 170 | - padding: .5rem 0; | 233 | +.sheet-head { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-4); } |
| 171 | - border-bottom: 1px solid var(--border); | 234 | +.sheet-head .pname { font-family: var(--font-heading); font-size: 20px; } |
| 172 | -} | 235 | +.sheet-head .close { margin-left: auto; } |
| 173 | -.task-rename { display: flex; gap: .5rem; flex: 1; } | 236 | +.sheet-figures { display: flex; gap: var(--space-6); } |
| 174 | -.task-archived input { color: var(--muted); } | 237 | +.sheet-figures .k { font-size: 11px; opacity: .55; text-transform: uppercase; letter-spacing: .08em; } |
| 175 | -.task-add { display: flex; gap: .5rem; margin-top: .8rem; } | 238 | +.sheet-figures .v { font-size: 30px; font-family: var(--font-heading); } |
| 176 | -.hint { color: var(--muted); font-size: .85rem; } | 239 | +.sheet-note { font-size: 13px; opacity: .6; margin: var(--space-4) 0 0; } |
| 177 | -.danger-zone { margin-top: 2rem; } | 240 | + |
| 241 | +/* — mes temps — */ | ||
| 242 | +.times { list-style: none; margin: 0; padding: 0; } | ||
| 243 | +.times li { | ||
| 244 | + display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; | ||
| 245 | + border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | ||
| 246 | +} | ||
| 247 | +.times .tinfo { flex: 1; min-width: 0; } | ||
| 248 | +.times .ttask { font-size: 15px; } | ||
| 249 | +.times .tdate { font-size: 12px; opacity: .55; } | ||
| 250 | +.times .tdur { font-size: 16px; font-family: var(--font-heading); } | ||
| 251 | + | ||
| 252 | +/* — tâches — */ | ||
| 253 | +.task-row { | ||
| 254 | + display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; | ||
| 255 | + border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent); | ||
| 256 | +} | ||
| 257 | +.task-row form.rename { flex: 1; } | ||
| 258 | +.task-row .rename .input { | ||
| 259 | + border-color: transparent; background: transparent; padding-left: 0; | ||
| 260 | + font-size: 15px; border-radius: 0; | ||
| 261 | +} | ||
| 262 | +.task-row .rename .input:hover { border-color: var(--color-divider); } | ||
| 263 | +.task-row.archived .rename .input { opacity: .45; } | ||
| 264 | +.task-add-row { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); } | ||
| 265 | +.task-add-row .input { flex: 1; min-height: 44px; } | ||
| 266 | +.visually-hidden { | ||
| 267 | + position: absolute; width: 1px; height: 1px; overflow: hidden; | ||
| 268 | + clip: rect(0 0 0 0); white-space: nowrap; | ||
| 269 | +} | ||
| 270 | + | ||
| 271 | +/* — toast — */ | ||
| 272 | +.toast { | ||
| 273 | + position: fixed; bottom: 96px; left: 50%; transform: translateX(-50%); | ||
| 274 | + width: calc(100% - 2 * var(--space-4)); max-width: calc(430px - 2 * var(--space-4)); | ||
| 275 | + background: var(--color-neutral-900); color: var(--color-neutral-100); | ||
| 276 | + padding: var(--space-3) var(--space-4); border-radius: 999px; | ||
| 277 | + font-size: 14px; text-align: center; box-shadow: var(--shadow-md); | ||
| 278 | + animation: tt-rise .18s ease-out; z-index: 5; | ||
| 279 | +} | ||
| 280 | + | ||
| 281 | +@keyframes tt-rise { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } } | ||
| 282 | +.sheet { animation-name: tt-rise-sheet; } | ||
| 283 | +@keyframes tt-rise-sheet { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } | ||
| 284 | +@media (prefers-reduced-motion: reduce) { .sheet, .toast { animation: none; } } | ||
modified
src/charts.rs +163 -172 | @@ -1,23 +1,23 @@ | ||
| 1 | -use std::collections::BTreeMap; | |
| 1 | +use std::collections::{BTreeMap, HashMap}; | |
| 2 | 2 | |
| 3 | 3 | use uuid::Uuid; |
| 4 | 4 | |
| 5 | 5 | use crate::db::stats::StatRow; |
| 6 | 6 | use crate::db::users::UserRow; |
| 7 | 7 | |
| 8 | -/// Palette fixe, contrastée, lisible sur fond clair. | |
| 9 | -pub const PALETTE: [&str; 12] = [ | |
| 10 | - "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7", | |
| 11 | - "#9c755f", "#bab0ac", "#3b6ea5", "#8cd17d", | |
| 8 | +/// Palette du design system « Organic » — couleur par ordre de création de la | |
| 9 | +/// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. | |
| 10 | +pub const PALETTE: [&str; 7] = [ | |
| 11 | + "#d67f48", "#8fa073", "#f6a06b", "#56633f", "#a19786", "#b2622d", "#ccdbb2", | |
| 12 | 12 | ]; |
| 13 | 13 | |
| 14 | -/// Couleur stable dans le temps : hash de l'UUID de la tâche → index de palette. | |
| 15 | -pub fn task_color(task_id: Uuid) -> &'static str { | |
| 16 | - let h = task_id | |
| 17 | - .as_bytes() | |
| 14 | +/// task_id → couleur, à partir de la liste des tâches triée par id (création). | |
| 15 | +pub fn color_map(task_ids_in_creation_order: &[Uuid]) -> HashMap<Uuid, &'static str> { | |
| 16 | + task_ids_in_creation_order | |
| 18 | 17 | .iter() |
| 19 | - .fold(0usize, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as usize)); | |
| 20 | - PALETTE[h % PALETTE.len()] | |
| 18 | + .enumerate() | |
| 19 | + .map(|(i, id)| (*id, PALETTE[i % PALETTE.len()])) | |
| 20 | + .collect() | |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | 23 | /// « 45 min », « 2 h », « 12 h 30 » |
| @@ -30,41 +30,43 @@ pub fn fmt_minutes(total: i64) -> String { | ||
| 30 | 30 | } |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | -// Géométrie du SVG (viewBox 0 0 400 260, en dur dans le template) — le template est muet. | |
| 34 | -const LEFT: f32 = 44.0; | |
| 35 | -const RIGHT: f32 = 392.0; | |
| 36 | -const TOP: f32 = 24.0; | |
| 37 | -const BOTTOM: f32 = 232.0; | |
| 38 | -const N_TICKS: i64 = 4; | |
| 39 | - | |
| 40 | -pub struct ChartView { | |
| 41 | - pub title: String, | |
| 42 | - pub empty: bool, | |
| 43 | - pub bars: Vec<BarView>, | |
| 44 | - pub y_ticks: Vec<Tick>, | |
| 45 | - pub baseline: f32, | |
| 33 | +pub struct Seg { | |
| 34 | + /// flex-grow ∝ minutes. | |
| 35 | + pub flex: i64, | |
| 36 | + pub color: &'static str, | |
| 37 | + pub aria: String, | |
| 38 | + pub sheet_id: String, | |
| 46 | 39 | } |
| 47 | 40 | |
| 48 | -pub struct BarView { | |
| 49 | - pub user_label: String, | |
| 50 | - pub total_label: String, | |
| 51 | - pub label_x: f32, | |
| 52 | - pub total_y: f32, | |
| 53 | - pub segments: Vec<SegView>, | |
| 41 | +pub struct Row { | |
| 42 | + pub user: String, | |
| 43 | + pub total: String, | |
| 44 | + /// Hauteur de la colonne en % du max (≥ 2 pour rester visible). | |
| 45 | + pub height_pct: u32, | |
| 46 | + pub segs: Vec<Seg>, | |
| 54 | 47 | } |
| 55 | 48 | |
| 56 | -pub struct SegView { | |
| 57 | - pub x: f32, | |
| 58 | - pub y: f32, | |
| 59 | - pub w: f32, | |
| 60 | - pub h: f32, | |
| 61 | - pub color: &'static str, | |
| 49 | +pub struct ChartView { | |
| 62 | 50 | pub title: String, |
| 51 | + pub range: String, | |
| 52 | + pub empty: bool, | |
| 53 | + pub nav: bool, | |
| 54 | + pub prev: String, | |
| 55 | + /// None = flèche désactivée (période courante). | |
| 56 | + pub next: Option<String>, | |
| 57 | + pub rows: Vec<Row>, | |
| 63 | 58 | } |
| 64 | 59 | |
| 65 | -pub struct Tick { | |
| 66 | - pub y: f32, | |
| 67 | - pub label: String, | |
| 60 | +/// Bottom sheet de détail d'un segment (ouverte via :target, zéro JS). | |
| 61 | +pub struct Sheet { | |
| 62 | + pub id: String, | |
| 63 | + pub color: &'static str, | |
| 64 | + pub task: String, | |
| 65 | + pub who: String, | |
| 66 | + pub duration: String, | |
| 67 | + pub share: String, | |
| 68 | + pub period: String, | |
| 69 | + pub count: String, | |
| 68 | 70 | } |
| 69 | 71 | |
| 70 | 72 | pub struct LegendItem { |
| @@ -72,118 +74,110 @@ pub struct LegendItem { | ||
| 72 | 74 | pub color: &'static str, |
| 73 | 75 | } |
| 74 | 76 | |
| 75 | -/// Échelle Y : plus petit pas « rond » (en minutes) tel que max ≤ 4 pas. | |
| 76 | -fn nice_step(max: i64) -> i64 { | |
| 77 | - const STEPS: [i64; 10] = [15, 30, 60, 120, 240, 480, 960, 1920, 3840, 7680]; | |
| 78 | - for s in STEPS { | |
| 79 | - if max <= N_TICKS * s { | |
| 80 | - return s; | |
| 81 | - } | |
| 82 | - } | |
| 83 | - (max / N_TICKS).max(1) | |
| 77 | +pub struct ChartNav { | |
| 78 | + pub prev: String, | |
| 79 | + pub next: Option<String>, | |
| 84 | 80 | } |
| 85 | 81 | |
| 86 | -impl ChartView { | |
| 87 | - /// `users` : tous les utilisateurs (une barre chacun, même à zéro), | |
| 88 | - /// `rows` : totaux user × tâche de la période. | |
| 89 | - pub fn build(title: String, users: &[UserRow], rows: &[StatRow]) -> Self { | |
| 90 | - // user_id -> [(task_id, task_name, total)] trié par task_id (UUIDv7 ≈ ordre | |
| 91 | - // de création) : ordre d'empilement stable entre les trois graphiques. | |
| 92 | - let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); | |
| 93 | - for r in rows { | |
| 94 | - by_user.entry(r.user_id).or_default().push(r); | |
| 95 | - } | |
| 96 | - for segs in by_user.values_mut() { | |
| 97 | - segs.sort_by_key(|r| r.task_id); | |
| 98 | - } | |
| 99 | - | |
| 100 | - let max_total = users | |
| 101 | - .iter() | |
| 102 | - .map(|u| { | |
| 103 | - by_user | |
| 104 | - .get(&u.id) | |
| 105 | - .map(|segs| segs.iter().map(|r| r.total).sum::<i64>()) | |
| 106 | - .unwrap_or(0) | |
| 107 | - }) | |
| 108 | - .max() | |
| 109 | - .unwrap_or(0); | |
| 110 | - let empty = max_total == 0; | |
| 111 | - let step = nice_step(max_total.max(1)); | |
| 112 | - let y_max = (N_TICKS * step) as f32; | |
| 113 | - let scale = (BOTTOM - TOP) / y_max; | |
| 114 | - | |
| 115 | - let y_ticks = (0..=N_TICKS) | |
| 116 | - .map(|i| Tick { | |
| 117 | - y: BOTTOM - (i * step) as f32 * scale, | |
| 118 | - label: fmt_minutes(i * step), | |
| 119 | - }) | |
| 120 | - .collect(); | |
| 82 | +/// Construit un graphique en colonnes empilées + ses sheets de détail. | |
| 83 | +/// `key` distingue les ancres entre les trois graphiques de la page. | |
| 84 | +pub fn build_chart( | |
| 85 | + key: &str, | |
| 86 | + title: &str, | |
| 87 | + range: &str, | |
| 88 | + users: &[UserRow], | |
| 89 | + rows: &[StatRow], | |
| 90 | + colors: &HashMap<Uuid, &'static str>, | |
| 91 | + nav: Option<ChartNav>, | |
| 92 | +) -> (ChartView, Vec<Sheet>) { | |
| 93 | + let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); | |
| 94 | + for r in rows { | |
| 95 | + by_user.entry(r.user_id).or_default().push(r); | |
| 96 | + } | |
| 97 | + for segs in by_user.values_mut() { | |
| 98 | + // Comme dans la maquette : plus grosse part en premier (en haut). | |
| 99 | + segs.sort_by_key(|r| -r.total); | |
| 100 | + } | |
| 121 | 101 | |
| 122 | - let n = users.len().max(1) as f32; | |
| 123 | - let slot = (RIGHT - LEFT) / n; | |
| 124 | - let bar_w = (slot * 0.6).min(80.0); | |
| 102 | + let totals: Vec<i64> = users | |
| 103 | + .iter() | |
| 104 | + .map(|u| { | |
| 105 | + by_user | |
| 106 | + .get(&u.id) | |
| 107 | + .map(|s| s.iter().map(|r| r.total).sum()) | |
| 108 | + .unwrap_or(0) | |
| 109 | + }) | |
| 110 | + .collect(); | |
| 111 | + let max = totals.iter().copied().max().unwrap_or(0).max(1); | |
| 125 | 112 | |
| 126 | - let bars = users | |
| 127 | - .iter() | |
| 128 | - .enumerate() | |
| 129 | - .map(|(i, u)| { | |
| 130 | - let center = LEFT + slot * (i as f32 + 0.5); | |
| 131 | - let x = center - bar_w / 2.0; | |
| 132 | - let mut cumul = 0i64; | |
| 133 | - let mut segments = Vec::new(); | |
| 134 | - if let Some(segs) = by_user.get(&u.id) { | |
| 135 | - for r in segs { | |
| 136 | - let y_bottom = BOTTOM - cumul as f32 * scale; | |
| 137 | - cumul += r.total; | |
| 138 | - let y_top = BOTTOM - cumul as f32 * scale; | |
| 139 | - segments.push(SegView { | |
| 140 | - x, | |
| 141 | - y: y_top, | |
| 142 | - w: bar_w, | |
| 143 | - h: y_bottom - y_top, | |
| 144 | - color: task_color(r.task_id), | |
| 145 | - title: format!("{} — {}", r.task_name, fmt_minutes(r.total)), | |
| 146 | - }); | |
| 147 | - } | |
| 148 | - } | |
| 149 | - BarView { | |
| 150 | - user_label: u.username.clone(), | |
| 151 | - total_label: if cumul > 0 { | |
| 152 | - fmt_minutes(cumul) | |
| 153 | - } else { | |
| 154 | - String::new() | |
| 155 | - }, | |
| 156 | - label_x: center, | |
| 157 | - total_y: BOTTOM - cumul as f32 * scale - 5.0, | |
| 158 | - segments, | |
| 113 | + let mut sheets = Vec::new(); | |
| 114 | + let view_rows = users | |
| 115 | + .iter() | |
| 116 | + .zip(&totals) | |
| 117 | + .enumerate() | |
| 118 | + .map(|(ui, (u, &total))| { | |
| 119 | + let mut segs = Vec::new(); | |
| 120 | + if let Some(rs) = by_user.get(&u.id) { | |
| 121 | + for (ti, r) in rs.iter().enumerate() { | |
| 122 | + let color = colors.get(&r.task_id).copied().unwrap_or(PALETTE[0]); | |
| 123 | + let sheet_id = format!("d-{key}-{ui}-{ti}"); | |
| 124 | + let share = (r.total * 100) / total.max(1); | |
| 125 | + sheets.push(Sheet { | |
| 126 | + id: sheet_id.clone(), | |
| 127 | + color, | |
| 128 | + task: r.task_name.clone(), | |
| 129 | + who: u.username.clone(), | |
| 130 | + duration: fmt_minutes(r.total), | |
| 131 | + share: format!("{share} %"), | |
| 132 | + period: range.to_string(), | |
| 133 | + count: format!( | |
| 134 | + "{} saisie{}", | |
| 135 | + r.entries, | |
| 136 | + if r.entries > 1 { "s" } else { "" } | |
| 137 | + ), | |
| 138 | + }); | |
| 139 | + segs.push(Seg { | |
| 140 | + flex: r.total, | |
| 141 | + color, | |
| 142 | + aria: format!("{} — {}", r.task_name, fmt_minutes(r.total)), | |
| 143 | + sheet_id, | |
| 144 | + }); | |
| 159 | 145 | } |
| 160 | - }) | |
| 161 | - .collect(); | |
| 146 | + } | |
| 147 | + Row { | |
| 148 | + user: u.username.clone(), | |
| 149 | + total: fmt_minutes(total), | |
| 150 | + height_pct: ((total * 100 + max - 1) / max).max(2) as u32, | |
| 151 | + segs, | |
| 152 | + } | |
| 153 | + }) | |
| 154 | + .collect(); | |
| 162 | 155 | |
| 163 | - Self { | |
| 164 | - title, | |
| 165 | - empty, | |
| 166 | - bars, | |
| 167 | - y_ticks, | |
| 168 | - baseline: BOTTOM, | |
| 169 | - } | |
| 170 | - } | |
| 171 | - | |
| 172 | - pub fn left(&self) -> f32 { | |
| 173 | - LEFT | |
| 174 | - } | |
| 175 | - pub fn right(&self) -> f32 { | |
| 176 | - RIGHT | |
| 177 | - } | |
| 156 | + let (prev, next) = match &nav { | |
| 157 | + Some(n) => (n.prev.clone(), n.next.clone()), | |
| 158 | + None => (String::new(), None), | |
| 159 | + }; | |
| 160 | + ( | |
| 161 | + ChartView { | |
| 162 | + title: title.to_string(), | |
| 163 | + range: range.to_string(), | |
| 164 | + empty: totals.iter().all(|&t| t == 0), | |
| 165 | + nav: nav.is_some(), | |
| 166 | + prev, | |
| 167 | + next, | |
| 168 | + rows: view_rows, | |
| 169 | + }, | |
| 170 | + sheets, | |
| 171 | + ) | |
| 178 | 172 | } |
| 179 | 173 | |
| 180 | 174 | /// Légende commune : union des tâches présentes, triée par nom. |
| 181 | -pub fn legend(row_sets: &[&[StatRow]]) -> Vec<LegendItem> { | |
| 175 | +pub fn legend(row_sets: &[&[StatRow]], colors: &HashMap<Uuid, &'static str>) -> Vec<LegendItem> { | |
| 182 | 176 | let mut seen: BTreeMap<String, &'static str> = BTreeMap::new(); |
| 183 | 177 | for rows in row_sets { |
| 184 | 178 | for r in *rows { |
| 185 | 179 | seen.entry(r.task_name.clone()) |
| 186 | - .or_insert_with(|| task_color(r.task_id)); | |
| 180 | + .or_insert_with(|| colors.get(&r.task_id).copied().unwrap_or(PALETTE[0])); | |
| 187 | 181 | } |
| 188 | 182 | } |
| 189 | 183 | seen.into_iter() |
| @@ -202,15 +196,20 @@ mod tests { | ||
| 202 | 196 | } |
| 203 | 197 | } |
| 204 | 198 | |
| 205 | - fn row(uid: u128, tid: u128, task: &str, total: i64) -> StatRow { | |
| 199 | + fn row(uid: u128, tid: u128, task: &str, total: i64, entries: i64) -> StatRow { | |
| 206 | 200 | StatRow { |
| 207 | 201 | user_id: Uuid::from_u128(uid), |
| 208 | 202 | task_id: Uuid::from_u128(tid), |
| 209 | 203 | task_name: task.into(), |
| 210 | 204 | total, |
| 205 | + entries, | |
| 211 | 206 | } |
| 212 | 207 | } |
| 213 | 208 | |
| 209 | + fn colors() -> HashMap<Uuid, &'static str> { | |
| 210 | + color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)]) | |
| 211 | + } | |
| 212 | + | |
| 214 | 213 | #[test] |
| 215 | 214 | fn fmt() { |
| 216 | 215 | assert_eq!(fmt_minutes(45), "45 min"); |
| @@ -219,49 +218,41 @@ mod tests { | ||
| 219 | 218 | } |
| 220 | 219 | |
| 221 | 220 | #[test] |
| 222 | - fn geometry_sums_to_totals() { | |
| 221 | + fn columns_scale_to_max() { | |
| 223 | 222 | let users = [user(1, "alice"), user(2, "bob")]; |
| 224 | 223 | let rows = [ |
| 225 | - row(1, 10, "dev", 90), | |
| 226 | - row(1, 11, "réunions", 30), | |
| 227 | - row(2, 10, "dev", 240), | |
| 224 | + row(1, 10, "dev", 90, 2), | |
| 225 | + row(1, 11, "réunions", 30, 1), | |
| 226 | + row(2, 10, "dev", 240, 3), | |
| 228 | 227 | ]; |
| 229 | - let chart = ChartView::build("t".into(), &users, &rows); | |
| 230 | - assert_eq!(chart.bars.len(), 2); | |
| 231 | - | |
| 232 | - // La hauteur cumulée des segments correspond au total, à l'échelle près. | |
| 233 | - let alice = &chart.bars[0]; | |
| 234 | - let bob = &chart.bars[1]; | |
| 235 | - let h_alice: f32 = alice.segments.iter().map(|s| s.h).sum(); | |
| 236 | - let h_bob: f32 = bob.segments.iter().map(|s| s.h).sum(); | |
| 237 | - assert!((h_alice / h_bob - 120.0 / 240.0).abs() < 1e-3); | |
| 238 | - assert_eq!(alice.total_label, "2 h"); | |
| 239 | - assert_eq!(bob.total_label, "4 h"); | |
| 240 | - | |
| 241 | - // Empilement : segments contigus, du bas vers le haut. | |
| 242 | - assert!((alice.segments[0].y - (alice.segments[1].y + alice.segments[1].h)).abs() < 1e-3); | |
| 228 | + let (chart, sheets) = build_chart("w", "t", "p", &users, &rows, &colors(), None); | |
| 229 | + assert_eq!(chart.rows.len(), 2); | |
| 230 | + assert_eq!(chart.rows[1].height_pct, 100); // bob = max | |
| 231 | + assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240 | |
| 232 | + // Segments d'alice triés par part décroissante. | |
| 233 | + assert_eq!(chart.rows[0].segs[0].flex, 90); | |
| 234 | + assert_eq!(chart.rows[0].segs[1].flex, 30); | |
| 235 | + // 3 sheets, ids uniques, part correcte. | |
| 236 | + assert_eq!(sheets.len(), 3); | |
| 237 | + assert_eq!(sheets[0].share, "75 %"); | |
| 238 | + assert_eq!(sheets[0].count, "2 saisies"); | |
| 243 | 239 | } |
| 244 | 240 | |
| 245 | 241 | #[test] |
| 246 | - fn zero_user_has_no_segments() { | |
| 242 | + fn zero_user_min_height() { | |
| 247 | 243 | let users = [user(1, "alice"), user(2, "bob")]; |
| 248 | - let rows = [row(1, 10, "dev", 60)]; | |
| 249 | - let chart = ChartView::build("t".into(), &users, &rows); | |
| 250 | - assert!(chart.bars[1].segments.is_empty()); | |
| 251 | - assert_eq!(chart.bars[1].total_label, ""); | |
| 244 | + let rows = [row(1, 10, "dev", 60, 1)]; | |
| 245 | + let (chart, _) = build_chart("w", "t", "p", &users, &rows, &colors(), None); | |
| 246 | + assert!(chart.rows[1].segs.is_empty()); | |
| 247 | + assert_eq!(chart.rows[1].height_pct, 2); | |
| 252 | 248 | assert!(!chart.empty); |
| 253 | 249 | } |
| 254 | 250 | |
| 255 | 251 | #[test] |
| 256 | - fn stable_color() { | |
| 257 | - let id = Uuid::from_u128(42); | |
| 258 | - assert_eq!(task_color(id), task_color(id)); | |
| 259 | - } | |
| 260 | - | |
| 261 | - #[test] | |
| 262 | - fn nice_steps() { | |
| 263 | - assert_eq!(nice_step(1), 15); // ≤ 1 h → pas de 15 min | |
| 264 | - assert_eq!(nice_step(240), 60); | |
| 265 | - assert_eq!(nice_step(241), 120); | |
| 252 | + fn stable_colors_by_creation_order() { | |
| 253 | + let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; | |
| 254 | + let m = color_map(&ids); | |
| 255 | + assert_eq!(m[&ids[0]], PALETTE[0]); | |
| 256 | + assert_eq!(m[&ids[1]], PALETTE[1]); | |
| 266 | 257 | } |
| 267 | 258 | } |
| @@ -1,23 +1,23 @@ | |||
| 1 | -use std::collections::BTreeMap; | 1 | +use std::collections::{BTreeMap, HashMap}; |
| 2 | 2 | ||
| 3 | use uuid::Uuid; | 3 | use uuid::Uuid; |
| 4 | 4 | ||
| 5 | use crate::db::stats::StatRow; | 5 | use crate::db::stats::StatRow; |
| 6 | use crate::db::users::UserRow; | 6 | use crate::db::users::UserRow; |
| 7 | 7 | ||
| 8 | -/// Palette fixe, contrastée, lisible sur fond clair. | 8 | +/// Palette du design system « Organic » — couleur par ordre de création de la |
| 9 | -pub const PALETTE: [&str; 12] = [ | 9 | +/// tâche (les UUIDv7 trient dans l'ordre de création), stable dans le temps. |
| 10 | - "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7", | 10 | +pub const PALETTE: [&str; 7] = [ |
| 11 | - "#9c755f", "#bab0ac", "#3b6ea5", "#8cd17d", | 11 | + "#d67f48", "#8fa073", "#f6a06b", "#56633f", "#a19786", "#b2622d", "#ccdbb2", |
| 12 | ]; | 12 | ]; |
| 13 | 13 | ||
| 14 | -/// Couleur stable dans le temps : hash de l'UUID de la tâche → index de palette. | 14 | +/// task_id → couleur, à partir de la liste des tâches triée par id (création). |
| 15 | -pub fn task_color(task_id: Uuid) -> &'static str { | 15 | +pub fn color_map(task_ids_in_creation_order: &[Uuid]) -> HashMap<Uuid, &'static str> { |
| 16 | - let h = task_id | 16 | + task_ids_in_creation_order |
| 17 | - .as_bytes() | ||
| 18 | .iter() | 17 | .iter() |
| 19 | - .fold(0usize, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as usize)); | 18 | + .enumerate() |
| 20 | - PALETTE[h % PALETTE.len()] | 19 | + .map(|(i, id)| (*id, PALETTE[i % PALETTE.len()])) |
| 20 | + .collect() | ||
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | /// « 45 min », « 2 h », « 12 h 30 » | 23 | /// « 45 min », « 2 h », « 12 h 30 » |
| @@ -30,41 +30,43 @@ pub fn fmt_minutes(total: i64) -> String { | |||
| 30 | } | 30 | } |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | -// Géométrie du SVG (viewBox 0 0 400 260, en dur dans le template) — le template est muet. | 33 | +pub struct Seg { |
| 34 | -const LEFT: f32 = 44.0; | 34 | + /// flex-grow ∝ minutes. |
| 35 | -const RIGHT: f32 = 392.0; | 35 | + pub flex: i64, |
| 36 | -const TOP: f32 = 24.0; | 36 | + pub color: &'static str, |
| 37 | -const BOTTOM: f32 = 232.0; | 37 | + pub aria: String, |
| 38 | -const N_TICKS: i64 = 4; | 38 | + pub sheet_id: String, |
| 39 | - | ||
| 40 | -pub struct ChartView { | ||
| 41 | - pub title: String, | ||
| 42 | - pub empty: bool, | ||
| 43 | - pub bars: Vec<BarView>, | ||
| 44 | - pub y_ticks: Vec<Tick>, | ||
| 45 | - pub baseline: f32, | ||
| 46 | } | 39 | } |
| 47 | 40 | ||
| 48 | -pub struct BarView { | 41 | +pub struct Row { |
| 49 | - pub user_label: String, | 42 | + pub user: String, |
| 50 | - pub total_label: String, | 43 | + pub total: String, |
| 51 | - pub label_x: f32, | 44 | + /// Hauteur de la colonne en % du max (≥ 2 pour rester visible). |
| 52 | - pub total_y: f32, | 45 | + pub height_pct: u32, |
| 53 | - pub segments: Vec<SegView>, | 46 | + pub segs: Vec<Seg>, |
| 54 | } | 47 | } |
| 55 | 48 | ||
| 56 | -pub struct SegView { | 49 | +pub struct ChartView { |
| 57 | - pub x: f32, | ||
| 58 | - pub y: f32, | ||
| 59 | - pub w: f32, | ||
| 60 | - pub h: f32, | ||
| 61 | - pub color: &'static str, | ||
| 62 | pub title: String, | 50 | pub title: String, |
| 51 | + pub range: String, | ||
| 52 | + pub empty: bool, | ||
| 53 | + pub nav: bool, | ||
| 54 | + pub prev: String, | ||
| 55 | + /// None = flèche désactivée (période courante). | ||
| 56 | + pub next: Option<String>, | ||
| 57 | + pub rows: Vec<Row>, | ||
| 63 | } | 58 | } |
| 64 | 59 | ||
| 65 | -pub struct Tick { | 60 | +/// Bottom sheet de détail d'un segment (ouverte via :target, zéro JS). |
| 66 | - pub y: f32, | 61 | +pub struct Sheet { |
| 67 | - pub label: String, | 62 | + pub id: String, |
| 63 | + pub color: &'static str, | ||
| 64 | + pub task: String, | ||
| 65 | + pub who: String, | ||
| 66 | + pub duration: String, | ||
| 67 | + pub share: String, | ||
| 68 | + pub period: String, | ||
| 69 | + pub count: String, | ||
| 68 | } | 70 | } |
| 69 | 71 | ||
| 70 | pub struct LegendItem { | 72 | pub struct LegendItem { |
| @@ -72,118 +74,110 @@ pub struct LegendItem { | |||
| 72 | pub color: &'static str, | 74 | pub color: &'static str, |
| 73 | } | 75 | } |
| 74 | 76 | ||
| 75 | -/// Échelle Y : plus petit pas « rond » (en minutes) tel que max ≤ 4 pas. | 77 | +pub struct ChartNav { |
| 76 | -fn nice_step(max: i64) -> i64 { | 78 | + pub prev: String, |
| 77 | - const STEPS: [i64; 10] = [15, 30, 60, 120, 240, 480, 960, 1920, 3840, 7680]; | 79 | + pub next: Option<String>, |
| 78 | - for s in STEPS { | ||
| 79 | - if max <= N_TICKS * s { | ||
| 80 | - return s; | ||
| 81 | - } | ||
| 82 | - } | ||
| 83 | - (max / N_TICKS).max(1) | ||
| 84 | } | 80 | } |
| 85 | 81 | ||
| 86 | -impl ChartView { | 82 | +/// Construit un graphique en colonnes empilées + ses sheets de détail. |
| 87 | - /// `users` : tous les utilisateurs (une barre chacun, même à zéro), | 83 | +/// `key` distingue les ancres entre les trois graphiques de la page. |
| 88 | - /// `rows` : totaux user × tâche de la période. | 84 | +pub fn build_chart( |
| 89 | - pub fn build(title: String, users: &[UserRow], rows: &[StatRow]) -> Self { | 85 | + key: &str, |
| 90 | - // user_id -> [(task_id, task_name, total)] trié par task_id (UUIDv7 ≈ ordre | 86 | + title: &str, |
| 91 | - // de création) : ordre d'empilement stable entre les trois graphiques. | 87 | + range: &str, |
| 92 | - let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); | 88 | + users: &[UserRow], |
| 93 | - for r in rows { | 89 | + rows: &[StatRow], |
| 94 | - by_user.entry(r.user_id).or_default().push(r); | 90 | + colors: &HashMap<Uuid, &'static str>, |
| 95 | - } | 91 | + nav: Option<ChartNav>, |
| 96 | - for segs in by_user.values_mut() { | 92 | +) -> (ChartView, Vec<Sheet>) { |
| 97 | - segs.sort_by_key(|r| r.task_id); | 93 | + let mut by_user: BTreeMap<Uuid, Vec<&StatRow>> = BTreeMap::new(); |
| 98 | - } | 94 | + for r in rows { |
| 99 | - | 95 | + by_user.entry(r.user_id).or_default().push(r); |
| 100 | - let max_total = users | 96 | + } |
| 101 | - .iter() | 97 | + for segs in by_user.values_mut() { |
| 102 | - .map(|u| { | 98 | + // Comme dans la maquette : plus grosse part en premier (en haut). |
| 103 | - by_user | 99 | + segs.sort_by_key(|r| -r.total); |
| 104 | - .get(&u.id) | 100 | + } |
| 105 | - .map(|segs| segs.iter().map(|r| r.total).sum::<i64>()) | ||
| 106 | - .unwrap_or(0) | ||
| 107 | - }) | ||
| 108 | - .max() | ||
| 109 | - .unwrap_or(0); | ||
| 110 | - let empty = max_total == 0; | ||
| 111 | - let step = nice_step(max_total.max(1)); | ||
| 112 | - let y_max = (N_TICKS * step) as f32; | ||
| 113 | - let scale = (BOTTOM - TOP) / y_max; | ||
| 114 | - | ||
| 115 | - let y_ticks = (0..=N_TICKS) | ||
| 116 | - .map(|i| Tick { | ||
| 117 | - y: BOTTOM - (i * step) as f32 * scale, | ||
| 118 | - label: fmt_minutes(i * step), | ||
| 119 | - }) | ||
| 120 | - .collect(); | ||
| 121 | 101 | ||
| 122 | - let n = users.len().max(1) as f32; | 102 | + let totals: Vec<i64> = users |
| 123 | - let slot = (RIGHT - LEFT) / n; | 103 | + .iter() |
| 124 | - let bar_w = (slot * 0.6).min(80.0); | 104 | + .map(|u| { |
| 105 | + by_user | ||
| 106 | + .get(&u.id) | ||
| 107 | + .map(|s| s.iter().map(|r| r.total).sum()) | ||
| 108 | + .unwrap_or(0) | ||
| 109 | + }) | ||
| 110 | + .collect(); | ||
| 111 | + let max = totals.iter().copied().max().unwrap_or(0).max(1); | ||
| 125 | 112 | ||
| 126 | - let bars = users | 113 | + let mut sheets = Vec::new(); |
| 127 | - .iter() | 114 | + let view_rows = users |
| 128 | - .enumerate() | 115 | + .iter() |
| 129 | - .map(|(i, u)| { | 116 | + .zip(&totals) |
| 130 | - let center = LEFT + slot * (i as f32 + 0.5); | 117 | + .enumerate() |
| 131 | - let x = center - bar_w / 2.0; | 118 | + .map(|(ui, (u, &total))| { |
| 132 | - let mut cumul = 0i64; | 119 | + let mut segs = Vec::new(); |
| 133 | - let mut segments = Vec::new(); | 120 | + if let Some(rs) = by_user.get(&u.id) { |
| 134 | - if let Some(segs) = by_user.get(&u.id) { | 121 | + for (ti, r) in rs.iter().enumerate() { |
| 135 | - for r in segs { | 122 | + let color = colors.get(&r.task_id).copied().unwrap_or(PALETTE[0]); |
| 136 | - let y_bottom = BOTTOM - cumul as f32 * scale; | 123 | + let sheet_id = format!("d-{key}-{ui}-{ti}"); |
| 137 | - cumul += r.total; | 124 | + let share = (r.total * 100) / total.max(1); |
| 138 | - let y_top = BOTTOM - cumul as f32 * scale; | 125 | + sheets.push(Sheet { |
| 139 | - segments.push(SegView { | 126 | + id: sheet_id.clone(), |
| 140 | - x, | 127 | + color, |
| 141 | - y: y_top, | 128 | + task: r.task_name.clone(), |
| 142 | - w: bar_w, | 129 | + who: u.username.clone(), |
| 143 | - h: y_bottom - y_top, | 130 | + duration: fmt_minutes(r.total), |
| 144 | - color: task_color(r.task_id), | 131 | + share: format!("{share} %"), |
| 145 | - title: format!("{} — {}", r.task_name, fmt_minutes(r.total)), | 132 | + period: range.to_string(), |
| 146 | - }); | 133 | + count: format!( |
| 147 | - } | 134 | + "{} saisie{}", |
| 148 | - } | 135 | + r.entries, |
| 149 | - BarView { | 136 | + if r.entries > 1 { "s" } else { "" } |
| 150 | - user_label: u.username.clone(), | 137 | + ), |
| 151 | - total_label: if cumul > 0 { | 138 | + }); |
| 152 | - fmt_minutes(cumul) | 139 | + segs.push(Seg { |
| 153 | - } else { | 140 | + flex: r.total, |
| 154 | - String::new() | 141 | + color, |
| 155 | - }, | 142 | + aria: format!("{} — {}", r.task_name, fmt_minutes(r.total)), |
| 156 | - label_x: center, | 143 | + sheet_id, |
| 157 | - total_y: BOTTOM - cumul as f32 * scale - 5.0, | 144 | + }); |
| 158 | - segments, | ||
| 159 | } | 145 | } |
| 160 | - }) | 146 | + } |
| 161 | - .collect(); | 147 | + Row { |
| 148 | + user: u.username.clone(), | ||
| 149 | + total: fmt_minutes(total), | ||
| 150 | + height_pct: ((total * 100 + max - 1) / max).max(2) as u32, | ||
| 151 | + segs, | ||
| 152 | + } | ||
| 153 | + }) | ||
| 154 | + .collect(); | ||
| 162 | 155 | ||
| 163 | - Self { | 156 | + let (prev, next) = match &nav { |
| 164 | - title, | 157 | + Some(n) => (n.prev.clone(), n.next.clone()), |
| 165 | - empty, | 158 | + None => (String::new(), None), |
| 166 | - bars, | 159 | + }; |
| 167 | - y_ticks, | 160 | + ( |
| 168 | - baseline: BOTTOM, | 161 | + ChartView { |
| 169 | - } | 162 | + title: title.to_string(), |
| 170 | - } | 163 | + range: range.to_string(), |
| 171 | - | 164 | + empty: totals.iter().all(|&t| t == 0), |
| 172 | - pub fn left(&self) -> f32 { | 165 | + nav: nav.is_some(), |
| 173 | - LEFT | 166 | + prev, |
| 174 | - } | 167 | + next, |
| 175 | - pub fn right(&self) -> f32 { | 168 | + rows: view_rows, |
| 176 | - RIGHT | 169 | + }, |
| 177 | - } | 170 | + sheets, |
| 171 | + ) | ||
| 178 | } | 172 | } |
| 179 | 173 | ||
| 180 | /// Légende commune : union des tâches présentes, triée par nom. | 174 | /// Légende commune : union des tâches présentes, triée par nom. |
| 181 | -pub fn legend(row_sets: &[&[StatRow]]) -> Vec<LegendItem> { | 175 | +pub fn legend(row_sets: &[&[StatRow]], colors: &HashMap<Uuid, &'static str>) -> Vec<LegendItem> { |
| 182 | let mut seen: BTreeMap<String, &'static str> = BTreeMap::new(); | 176 | let mut seen: BTreeMap<String, &'static str> = BTreeMap::new(); |
| 183 | for rows in row_sets { | 177 | for rows in row_sets { |
| 184 | for r in *rows { | 178 | for r in *rows { |
| 185 | seen.entry(r.task_name.clone()) | 179 | seen.entry(r.task_name.clone()) |
| 186 | - .or_insert_with(|| task_color(r.task_id)); | 180 | + .or_insert_with(|| colors.get(&r.task_id).copied().unwrap_or(PALETTE[0])); |
| 187 | } | 181 | } |
| 188 | } | 182 | } |
| 189 | seen.into_iter() | 183 | seen.into_iter() |
| @@ -202,15 +196,20 @@ mod tests { | |||
| 202 | } | 196 | } |
| 203 | } | 197 | } |
| 204 | 198 | ||
| 205 | - fn row(uid: u128, tid: u128, task: &str, total: i64) -> StatRow { | 199 | + fn row(uid: u128, tid: u128, task: &str, total: i64, entries: i64) -> StatRow { |
| 206 | StatRow { | 200 | StatRow { |
| 207 | user_id: Uuid::from_u128(uid), | 201 | user_id: Uuid::from_u128(uid), |
| 208 | task_id: Uuid::from_u128(tid), | 202 | task_id: Uuid::from_u128(tid), |
| 209 | task_name: task.into(), | 203 | task_name: task.into(), |
| 210 | total, | 204 | total, |
| 205 | + entries, | ||
| 211 | } | 206 | } |
| 212 | } | 207 | } |
| 213 | 208 | ||
| 209 | + fn colors() -> HashMap<Uuid, &'static str> { | ||
| 210 | + color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)]) | ||
| 211 | + } | ||
| 212 | + | ||
| 214 | #[test] | 213 | #[test] |
| 215 | fn fmt() { | 214 | fn fmt() { |
| 216 | assert_eq!(fmt_minutes(45), "45 min"); | 215 | assert_eq!(fmt_minutes(45), "45 min"); |
| @@ -219,49 +218,41 @@ mod tests { | |||
| 219 | } | 218 | } |
| 220 | 219 | ||
| 221 | #[test] | 220 | #[test] |
| 222 | - fn geometry_sums_to_totals() { | 221 | + fn columns_scale_to_max() { |
| 223 | let users = [user(1, "alice"), user(2, "bob")]; | 222 | let users = [user(1, "alice"), user(2, "bob")]; |
| 224 | let rows = [ | 223 | let rows = [ |
| 225 | - row(1, 10, "dev", 90), | 224 | + row(1, 10, "dev", 90, 2), |
| 226 | - row(1, 11, "réunions", 30), | 225 | + row(1, 11, "réunions", 30, 1), |
| 227 | - row(2, 10, "dev", 240), | 226 | + row(2, 10, "dev", 240, 3), |
| 228 | ]; | 227 | ]; |
| 229 | - let chart = ChartView::build("t".into(), &users, &rows); | 228 | + let (chart, sheets) = build_chart("w", "t", "p", &users, &rows, &colors(), None); |
| 230 | - assert_eq!(chart.bars.len(), 2); | 229 | + assert_eq!(chart.rows.len(), 2); |
| 231 | - | 230 | + assert_eq!(chart.rows[1].height_pct, 100); // bob = max |
| 232 | - // La hauteur cumulée des segments correspond au total, à l'échelle près. | 231 | + assert_eq!(chart.rows[0].height_pct, 50); // alice = 120/240 |
| 233 | - let alice = &chart.bars[0]; | 232 | + // Segments d'alice triés par part décroissante. |
| 234 | - let bob = &chart.bars[1]; | 233 | + assert_eq!(chart.rows[0].segs[0].flex, 90); |
| 235 | - let h_alice: f32 = alice.segments.iter().map(|s| s.h).sum(); | 234 | + assert_eq!(chart.rows[0].segs[1].flex, 30); |
| 236 | - let h_bob: f32 = bob.segments.iter().map(|s| s.h).sum(); | 235 | + // 3 sheets, ids uniques, part correcte. |
| 237 | - assert!((h_alice / h_bob - 120.0 / 240.0).abs() < 1e-3); | 236 | + assert_eq!(sheets.len(), 3); |
| 238 | - assert_eq!(alice.total_label, "2 h"); | 237 | + assert_eq!(sheets[0].share, "75 %"); |
| 239 | - assert_eq!(bob.total_label, "4 h"); | 238 | + assert_eq!(sheets[0].count, "2 saisies"); |
| 240 | - | ||
| 241 | - // Empilement : segments contigus, du bas vers le haut. | ||
| 242 | - assert!((alice.segments[0].y - (alice.segments[1].y + alice.segments[1].h)).abs() < 1e-3); | ||
| 243 | } | 239 | } |
| 244 | 240 | ||
| 245 | #[test] | 241 | #[test] |
| 246 | - fn zero_user_has_no_segments() { | 242 | + fn zero_user_min_height() { |
| 247 | let users = [user(1, "alice"), user(2, "bob")]; | 243 | let users = [user(1, "alice"), user(2, "bob")]; |
| 248 | - let rows = [row(1, 10, "dev", 60)]; | 244 | + let rows = [row(1, 10, "dev", 60, 1)]; |
| 249 | - let chart = ChartView::build("t".into(), &users, &rows); | 245 | + let (chart, _) = build_chart("w", "t", "p", &users, &rows, &colors(), None); |
| 250 | - assert!(chart.bars[1].segments.is_empty()); | 246 | + assert!(chart.rows[1].segs.is_empty()); |
| 251 | - assert_eq!(chart.bars[1].total_label, ""); | 247 | + assert_eq!(chart.rows[1].height_pct, 2); |
| 252 | assert!(!chart.empty); | 248 | assert!(!chart.empty); |
| 253 | } | 249 | } |
| 254 | 250 | ||
| 255 | #[test] | 251 | #[test] |
| 256 | - fn stable_color() { | 252 | + fn stable_colors_by_creation_order() { |
| 257 | - let id = Uuid::from_u128(42); | 253 | + let ids = [Uuid::from_u128(5), Uuid::from_u128(3)]; |
| 258 | - assert_eq!(task_color(id), task_color(id)); | 254 | + let m = color_map(&ids); |
| 259 | - } | 255 | + assert_eq!(m[&ids[0]], PALETTE[0]); |
| 260 | - | 256 | + assert_eq!(m[&ids[1]], PALETTE[1]); |
| 261 | - #[test] | ||
| 262 | - fn nice_steps() { | ||
| 263 | - assert_eq!(nice_step(1), 15); // ≤ 1 h → pas de 15 min | ||
| 264 | - assert_eq!(nice_step(240), 60); | ||
| 265 | - assert_eq!(nice_step(241), 120); | ||
| 266 | } | 257 | } |
| 267 | } | 258 | } |
modified
src/db/entries.rs +29 -13 | @@ -2,8 +2,6 @@ use chrono::NaiveDate; | ||
| 2 | 2 | use sqlx::PgPool; |
| 3 | 3 | use uuid::Uuid; |
| 4 | 4 | |
| 5 | -pub const PAGE_SIZE: i64 = 20; | |
| 6 | - | |
| 7 | 5 | #[derive(sqlx::FromRow)] |
| 8 | 6 | pub struct EntryRow { |
| 9 | 7 | pub id: Uuid, |
| @@ -35,29 +33,47 @@ pub async fn insert( | ||
| 35 | 33 | Ok(id) |
| 36 | 34 | } |
| 37 | 35 | |
| 38 | -/// Page de MES temps, du plus récent au plus ancien. | |
| 39 | -/// Retourne (entrées, y a-t-il une page suivante) — on lit PAGE_SIZE + 1 lignes. | |
| 40 | -pub async fn list_page( | |
| 36 | +/// MES temps, du plus récent au plus ancien, les `limit` premiers | |
| 37 | +/// (« Voir plus » recharge la page avec une limite plus grande). | |
| 38 | +/// Retourne (entrées, y a-t-il encore plus ancien) — on lit limit + 1 lignes. | |
| 39 | +pub async fn list_first( | |
| 41 | 40 | pool: &PgPool, |
| 42 | 41 | user_id: Uuid, |
| 43 | - page: i64, | |
| 42 | + limit: i64, | |
| 44 | 43 | ) -> sqlx::Result<(Vec<EntryRow>, bool)> { |
| 45 | - let offset = (page - 1) * PAGE_SIZE; | |
| 46 | 44 | let mut rows: Vec<EntryRow> = sqlx::query_as( |
| 47 | 45 | "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date |
| 48 | 46 | FROM time_entries e JOIN tasks t ON t.id = e.task_id |
| 49 | 47 | WHERE e.user_id = $1 |
| 50 | 48 | ORDER BY e.entry_date DESC, e.id DESC |
| 51 | - LIMIT $2 OFFSET $3", | |
| 49 | + LIMIT $2", | |
| 52 | 50 | ) |
| 53 | 51 | .bind(user_id) |
| 54 | - .bind(PAGE_SIZE + 1) | |
| 55 | - .bind(offset) | |
| 52 | + .bind(limit + 1) | |
| 56 | 53 | .fetch_all(pool) |
| 57 | 54 | .await?; |
| 58 | - let has_next = rows.len() as i64 > PAGE_SIZE; | |
| 59 | - rows.truncate(PAGE_SIZE as usize); | |
| 60 | - Ok((rows, has_next)) | |
| 55 | + let has_more = rows.len() as i64 > limit; | |
| 56 | + rows.truncate(limit as usize); | |
| 57 | + Ok((rows, has_more)) | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Mes minutes de la semaine en cours, par tâche (étape 1 de l'ajout). | |
| 61 | +pub async fn week_totals_for_user( | |
| 62 | + pool: &PgPool, | |
| 63 | + user_id: Uuid, | |
| 64 | + from: NaiveDate, | |
| 65 | + to: NaiveDate, | |
| 66 | +) -> sqlx::Result<Vec<(Uuid, i64)>> { | |
| 67 | + sqlx::query_as( | |
| 68 | + "SELECT task_id, SUM(minutes)::BIGINT FROM time_entries | |
| 69 | + WHERE user_id = $1 AND entry_date >= $2 AND entry_date < $3 | |
| 70 | + GROUP BY task_id", | |
| 71 | + ) | |
| 72 | + .bind(user_id) | |
| 73 | + .bind(from) | |
| 74 | + .bind(to) | |
| 75 | + .fetch_all(pool) | |
| 76 | + .await | |
| 61 | 77 | } |
| 62 | 78 | |
| 63 | 79 | /// Une entrée m'appartenant (édition) — None si absente ou pas à moi. |
| @@ -2,8 +2,6 @@ use chrono::NaiveDate; | |||
| 2 | use sqlx::PgPool; | 2 | use sqlx::PgPool; |
| 3 | use uuid::Uuid; | 3 | use uuid::Uuid; |
| 4 | 4 | ||
| 5 | -pub const PAGE_SIZE: i64 = 20; | ||
| 6 | - | ||
| 7 | #[derive(sqlx::FromRow)] | 5 | #[derive(sqlx::FromRow)] |
| 8 | pub struct EntryRow { | 6 | pub struct EntryRow { |
| 9 | pub id: Uuid, | 7 | pub id: Uuid, |
| @@ -35,29 +33,47 @@ pub async fn insert( | |||
| 35 | Ok(id) | 33 | Ok(id) |
| 36 | } | 34 | } |
| 37 | 35 | ||
| 38 | -/// Page de MES temps, du plus récent au plus ancien. | 36 | +/// MES temps, du plus récent au plus ancien, les `limit` premiers |
| 39 | -/// Retourne (entrées, y a-t-il une page suivante) — on lit PAGE_SIZE + 1 lignes. | 37 | +/// (« Voir plus » recharge la page avec une limite plus grande). |
| 40 | -pub async fn list_page( | 38 | +/// Retourne (entrées, y a-t-il encore plus ancien) — on lit limit + 1 lignes. |
| 39 | +pub async fn list_first( | ||
| 41 | pool: &PgPool, | 40 | pool: &PgPool, |
| 42 | user_id: Uuid, | 41 | user_id: Uuid, |
| 43 | - page: i64, | 42 | + limit: i64, |
| 44 | ) -> sqlx::Result<(Vec<EntryRow>, bool)> { | 43 | ) -> sqlx::Result<(Vec<EntryRow>, bool)> { |
| 45 | - let offset = (page - 1) * PAGE_SIZE; | ||
| 46 | let mut rows: Vec<EntryRow> = sqlx::query_as( | 44 | let mut rows: Vec<EntryRow> = sqlx::query_as( |
| 47 | "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date | 45 | "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date |
| 48 | FROM time_entries e JOIN tasks t ON t.id = e.task_id | 46 | FROM time_entries e JOIN tasks t ON t.id = e.task_id |
| 49 | WHERE e.user_id = $1 | 47 | WHERE e.user_id = $1 |
| 50 | ORDER BY e.entry_date DESC, e.id DESC | 48 | ORDER BY e.entry_date DESC, e.id DESC |
| 51 | - LIMIT $2 OFFSET $3", | 49 | + LIMIT $2", |
| 52 | ) | 50 | ) |
| 53 | .bind(user_id) | 51 | .bind(user_id) |
| 54 | - .bind(PAGE_SIZE + 1) | 52 | + .bind(limit + 1) |
| 55 | - .bind(offset) | ||
| 56 | .fetch_all(pool) | 53 | .fetch_all(pool) |
| 57 | .await?; | 54 | .await?; |
| 58 | - let has_next = rows.len() as i64 > PAGE_SIZE; | 55 | + let has_more = rows.len() as i64 > limit; |
| 59 | - rows.truncate(PAGE_SIZE as usize); | 56 | + rows.truncate(limit as usize); |
| 60 | - Ok((rows, has_next)) | 57 | + Ok((rows, has_more)) |
| 58 | +} | ||
| 59 | + | ||
| 60 | +/// Mes minutes de la semaine en cours, par tâche (étape 1 de l'ajout). | ||
| 61 | +pub async fn week_totals_for_user( | ||
| 62 | + pool: &PgPool, | ||
| 63 | + user_id: Uuid, | ||
| 64 | + from: NaiveDate, | ||
| 65 | + to: NaiveDate, | ||
| 66 | +) -> sqlx::Result<Vec<(Uuid, i64)>> { | ||
| 67 | + sqlx::query_as( | ||
| 68 | + "SELECT task_id, SUM(minutes)::BIGINT FROM time_entries | ||
| 69 | + WHERE user_id = $1 AND entry_date >= $2 AND entry_date < $3 | ||
| 70 | + GROUP BY task_id", | ||
| 71 | + ) | ||
| 72 | + .bind(user_id) | ||
| 73 | + .bind(from) | ||
| 74 | + .bind(to) | ||
| 75 | + .fetch_all(pool) | ||
| 76 | + .await | ||
| 61 | } | 77 | } |
| 62 | 78 | ||
| 63 | /// Une entrée m'appartenant (édition) — None si absente ou pas à moi. | 79 | /// Une entrée m'appartenant (édition) — None si absente ou pas à moi. |
modified
src/db/stats.rs +3 -1 | @@ -8,6 +8,8 @@ pub struct StatRow { | ||
| 8 | 8 | pub task_id: Uuid, |
| 9 | 9 | pub task_name: String, |
| 10 | 10 | pub total: i64, |
| 11 | + /// Nombre de saisies (pour la sheet de détail). | |
| 12 | + pub entries: i64, | |
| 11 | 13 | } |
| 12 | 14 | |
| 13 | 15 | /// Totaux par utilisateur × tâche sur [from, to) ; bornes NULL = depuis le début. |
| @@ -19,7 +21,7 @@ pub async fn totals( | ||
| 19 | 21 | ) -> sqlx::Result<Vec<StatRow>> { |
| 20 | 22 | sqlx::query_as( |
| 21 | 23 | "SELECT e.user_id, e.task_id, t.name AS task_name, |
| 22 | - SUM(e.minutes)::BIGINT AS total | |
| 24 | + SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries | |
| 23 | 25 | FROM time_entries e |
| 24 | 26 | JOIN tasks t ON t.id = e.task_id |
| 25 | 27 | WHERE ($1::date IS NULL OR e.entry_date >= $1) |
| @@ -8,6 +8,8 @@ pub struct StatRow { | |||
| 8 | pub task_id: Uuid, | 8 | pub task_id: Uuid, |
| 9 | pub task_name: String, | 9 | pub task_name: String, |
| 10 | pub total: i64, | 10 | pub total: i64, |
| 11 | + /// Nombre de saisies (pour la sheet de détail). | ||
| 12 | + pub entries: i64, | ||
| 11 | } | 13 | } |
| 12 | 14 | ||
| 13 | /// Totaux par utilisateur × tâche sur [from, to) ; bornes NULL = depuis le début. | 15 | /// Totaux par utilisateur × tâche sur [from, to) ; bornes NULL = depuis le début. |
| @@ -19,7 +21,7 @@ pub async fn totals( | |||
| 19 | ) -> sqlx::Result<Vec<StatRow>> { | 21 | ) -> sqlx::Result<Vec<StatRow>> { |
| 20 | sqlx::query_as( | 22 | sqlx::query_as( |
| 21 | "SELECT e.user_id, e.task_id, t.name AS task_name, | 23 | "SELECT e.user_id, e.task_id, t.name AS task_name, |
| 22 | - SUM(e.minutes)::BIGINT AS total | 24 | + SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries |
| 23 | FROM time_entries e | 25 | FROM time_entries e |
| 24 | JOIN tasks t ON t.id = e.task_id | 26 | JOIN tasks t ON t.id = e.task_id |
| 25 | WHERE ($1::date IS NULL OR e.entry_date >= $1) | 27 | WHERE ($1::date IS NULL OR e.entry_date >= $1) |
modified
src/db/tasks.rs +7 -6 | @@ -8,16 +8,17 @@ pub struct TaskRow { | ||
| 8 | 8 | pub archived: bool, |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | -/// Tâches proposées dans le formulaire d'ajout (non archivées). | |
| 12 | -pub async fn list_active(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { | |
| 13 | - sqlx::query_as("SELECT id, name, archived FROM tasks WHERE NOT archived ORDER BY name") | |
| 11 | +/// Toutes les tâches (écran Tâches). | |
| 12 | +pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { | |
| 13 | + sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY archived, name") | |
| 14 | 14 | .fetch_all(pool) |
| 15 | 15 | .await |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | -/// Toutes les tâches (écran Paramètres). | |
| 19 | -pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { | |
| 20 | - sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY archived, name") | |
| 18 | +/// Toutes les tâches par ordre de création (UUIDv7 ≈ chronologique) — | |
| 19 | +/// c'est l'ordre qui fixe la couleur de chaque tâche. | |
| 20 | +pub async fn list_by_creation(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { | |
| 21 | + sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY id") | |
| 21 | 22 | .fetch_all(pool) |
| 22 | 23 | .await |
| 23 | 24 | } |
| @@ -8,16 +8,17 @@ pub struct TaskRow { | |||
| 8 | pub archived: bool, | 8 | pub archived: bool, |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | -/// Tâches proposées dans le formulaire d'ajout (non archivées). | 11 | +/// Toutes les tâches (écran Tâches). |
| 12 | -pub async fn list_active(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { | 12 | +pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { |
| 13 | - sqlx::query_as("SELECT id, name, archived FROM tasks WHERE NOT archived ORDER BY name") | 13 | + sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY archived, name") |
| 14 | .fetch_all(pool) | 14 | .fetch_all(pool) |
| 15 | .await | 15 | .await |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | -/// Toutes les tâches (écran Paramètres). | 18 | +/// Toutes les tâches par ordre de création (UUIDv7 ≈ chronologique) — |
| 19 | -pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { | 19 | +/// c'est l'ordre qui fixe la couleur de chaque tâche. |
| 20 | - sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY archived, name") | 20 | +pub async fn list_by_creation(pool: &PgPool) -> sqlx::Result<Vec<TaskRow>> { |
| 21 | + sqlx::query_as("SELECT id, name, archived FROM tasks ORDER BY id") | ||
| 21 | .fetch_all(pool) | 22 | .fetch_all(pool) |
| 22 | .await | 23 | .await |
| 23 | } | 24 | } |
modified
src/db/users.rs +8 -0 | @@ -48,6 +48,14 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq | ||
| 48 | 48 | Ok(()) |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | +pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> { | |
| 52 | + let row: Option<(String,)> = sqlx::query_as("SELECT username FROM users WHERE id = $1") | |
| 53 | + .bind(id) | |
| 54 | + .fetch_optional(pool) | |
| 55 | + .await?; | |
| 56 | + Ok(row.map(|(u,)| u)) | |
| 57 | +} | |
| 58 | + | |
| 51 | 59 | pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<UserRow>> { |
| 52 | 60 | sqlx::query_as("SELECT id, username FROM users ORDER BY username") |
| 53 | 61 | .fetch_all(pool) |
| @@ -48,6 +48,14 @@ pub async fn update_password(pool: &PgPool, id: Uuid, password_hash: &str) -> sq | |||
| 48 | Ok(()) | 48 | Ok(()) |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | +pub async fn username(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<String>> { | ||
| 52 | + let row: Option<(String,)> = sqlx::query_as("SELECT username FROM users WHERE id = $1") | ||
| 53 | + .bind(id) | ||
| 54 | + .fetch_optional(pool) | ||
| 55 | + .await?; | ||
| 56 | + Ok(row.map(|(u,)| u)) | ||
| 57 | +} | ||
| 58 | + | ||
| 51 | pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<UserRow>> { | 59 | pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<UserRow>> { |
| 52 | sqlx::query_as("SELECT id, username FROM users ORDER BY username") | 60 | sqlx::query_as("SELECT id, username FROM users ORDER BY username") |
| 53 | .fetch_all(pool) | 61 | .fetch_all(pool) |
modified
src/period.rs +46 -18 | @@ -9,6 +9,10 @@ pub fn paris_today() -> NaiveDate { | ||
| 9 | 9 | Utc::now().with_timezone(&Paris).date_naive() |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | +const WEEKDAY_NAMES: [&str; 7] = [ | |
| 13 | + "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche", | |
| 14 | +]; | |
| 15 | + | |
| 12 | 16 | const MONTH_NAMES: [&str; 12] = [ |
| 13 | 17 | "janvier", |
| 14 | 18 | "février", |
| @@ -24,6 +28,34 @@ const MONTH_NAMES: [&str; 12] = [ | ||
| 24 | 28 | "décembre", |
| 25 | 29 | ]; |
| 26 | 30 | |
| 31 | +fn capitalize(s: &str) -> String { | |
| 32 | + let mut c = s.chars(); | |
| 33 | + match c.next() { | |
| 34 | + Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), | |
| 35 | + None => String::new(), | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +/// « 3 août » | |
| 40 | +pub fn day_month(date: NaiveDate) -> String { | |
| 41 | + format!( | |
| 42 | + "{} {}", | |
| 43 | + date.day(), | |
| 44 | + MONTH_NAMES[(date.month() - 1) as usize] | |
| 45 | + ) | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// « Mercredi 6 août — aujourd'hui » | |
| 49 | +pub fn day_label(date: NaiveDate) -> String { | |
| 50 | + let weekday = WEEKDAY_NAMES[date.weekday().num_days_from_monday() as usize]; | |
| 51 | + let base = capitalize(&format!("{} {}", weekday, day_month(date))); | |
| 52 | + if date == paris_today() { | |
| 53 | + format!("{base} — aujourd'hui") | |
| 54 | + } else { | |
| 55 | + base | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 27 | 59 | /// Semaine ISO (lundi → dimanche), format query `2026-W12`. |
| 28 | 60 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 29 | 61 | pub struct Week { |
| @@ -70,18 +102,11 @@ impl Week { | ||
| 70 | 102 | Self::of(self.bounds().0 + Duration::days(7)) |
| 71 | 103 | } |
| 72 | 104 | |
| 73 | - /// « Semaine 12 · 16/03 → 22/03 » | |
| 74 | - pub fn label(&self) -> String { | |
| 105 | + /// « Semaine du 16 mars au 22 mars » | |
| 106 | + pub fn range_label(&self) -> String { | |
| 75 | 107 | let (start, end) = self.bounds(); |
| 76 | 108 | let end = end - Duration::days(1); |
| 77 | - format!( | |
| 78 | - "Semaine {} · {:02}/{:02} → {:02}/{:02}", | |
| 79 | - self.week, | |
| 80 | - start.day(), | |
| 81 | - start.month(), | |
| 82 | - end.day(), | |
| 83 | - end.month() | |
| 84 | - ) | |
| 109 | + format!("Semaine du {} au {}", day_month(start), day_month(end)) | |
| 85 | 110 | } |
| 86 | 111 | } |
| 87 | 112 | |
| @@ -160,9 +185,13 @@ impl Month { | ||
| 160 | 185 | } |
| 161 | 186 | } |
| 162 | 187 | |
| 163 | - /// « mars 2026 » | |
| 188 | + /// « Mars 2026 » | |
| 164 | 189 | pub fn label(&self) -> String { |
| 165 | - format!("{} {}", MONTH_NAMES[(self.month - 1) as usize], self.year) | |
| 190 | + capitalize(&format!( | |
| 191 | + "{} {}", | |
| 192 | + MONTH_NAMES[(self.month - 1) as usize], | |
| 193 | + self.year | |
| 194 | + )) | |
| 166 | 195 | } |
| 167 | 196 | } |
| 168 | 197 | |
| @@ -228,12 +257,11 @@ mod tests { | ||
| 228 | 257 | |
| 229 | 258 | #[test] |
| 230 | 259 | fn labels() { |
| 231 | - assert_eq!(Month::parse("2026-03").unwrap().label(), "mars 2026"); | |
| 232 | - assert!( | |
| 233 | - Week::parse("2026-W12") | |
| 234 | - .unwrap() | |
| 235 | - .label() | |
| 236 | - .starts_with("Semaine 12") | |
| 260 | + assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); | |
| 261 | + assert_eq!( | |
| 262 | + Week::parse("2026-W12").unwrap().range_label(), | |
| 263 | + "Semaine du 16 mars au 22 mars" | |
| 237 | 264 | ); |
| 265 | + assert_eq!(day_month(d(2026, 8, 3)), "3 août"); | |
| 238 | 266 | } |
| 239 | 267 | } |
| @@ -9,6 +9,10 @@ pub fn paris_today() -> NaiveDate { | |||
| 9 | Utc::now().with_timezone(&Paris).date_naive() | 9 | Utc::now().with_timezone(&Paris).date_naive() |
| 10 | } | 10 | } |
| 11 | 11 | ||
| 12 | +const WEEKDAY_NAMES: [&str; 7] = [ | ||
| 13 | + "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche", | ||
| 14 | +]; | ||
| 15 | + | ||
| 12 | const MONTH_NAMES: [&str; 12] = [ | 16 | const MONTH_NAMES: [&str; 12] = [ |
| 13 | "janvier", | 17 | "janvier", |
| 14 | "février", | 18 | "février", |
| @@ -24,6 +28,34 @@ const MONTH_NAMES: [&str; 12] = [ | |||
| 24 | "décembre", | 28 | "décembre", |
| 25 | ]; | 29 | ]; |
| 26 | 30 | ||
| 31 | +fn capitalize(s: &str) -> String { | ||
| 32 | + let mut c = s.chars(); | ||
| 33 | + match c.next() { | ||
| 34 | + Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), | ||
| 35 | + None => String::new(), | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +/// « 3 août » | ||
| 40 | +pub fn day_month(date: NaiveDate) -> String { | ||
| 41 | + format!( | ||
| 42 | + "{} {}", | ||
| 43 | + date.day(), | ||
| 44 | + MONTH_NAMES[(date.month() - 1) as usize] | ||
| 45 | + ) | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +/// « Mercredi 6 août — aujourd'hui » | ||
| 49 | +pub fn day_label(date: NaiveDate) -> String { | ||
| 50 | + let weekday = WEEKDAY_NAMES[date.weekday().num_days_from_monday() as usize]; | ||
| 51 | + let base = capitalize(&format!("{} {}", weekday, day_month(date))); | ||
| 52 | + if date == paris_today() { | ||
| 53 | + format!("{base} — aujourd'hui") | ||
| 54 | + } else { | ||
| 55 | + base | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 27 | /// Semaine ISO (lundi → dimanche), format query `2026-W12`. | 59 | /// Semaine ISO (lundi → dimanche), format query `2026-W12`. |
| 28 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] | 60 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 29 | pub struct Week { | 61 | pub struct Week { |
| @@ -70,18 +102,11 @@ impl Week { | |||
| 70 | Self::of(self.bounds().0 + Duration::days(7)) | 102 | Self::of(self.bounds().0 + Duration::days(7)) |
| 71 | } | 103 | } |
| 72 | 104 | ||
| 73 | - /// « Semaine 12 · 16/03 → 22/03 » | 105 | + /// « Semaine du 16 mars au 22 mars » |
| 74 | - pub fn label(&self) -> String { | 106 | + pub fn range_label(&self) -> String { |
| 75 | let (start, end) = self.bounds(); | 107 | let (start, end) = self.bounds(); |
| 76 | let end = end - Duration::days(1); | 108 | let end = end - Duration::days(1); |
| 77 | - format!( | 109 | + format!("Semaine du {} au {}", day_month(start), day_month(end)) |
| 78 | - "Semaine {} · {:02}/{:02} → {:02}/{:02}", | ||
| 79 | - self.week, | ||
| 80 | - start.day(), | ||
| 81 | - start.month(), | ||
| 82 | - end.day(), | ||
| 83 | - end.month() | ||
| 84 | - ) | ||
| 85 | } | 110 | } |
| 86 | } | 111 | } |
| 87 | 112 | ||
| @@ -160,9 +185,13 @@ impl Month { | |||
| 160 | } | 185 | } |
| 161 | } | 186 | } |
| 162 | 187 | ||
| 163 | - /// « mars 2026 » | 188 | + /// « Mars 2026 » |
| 164 | pub fn label(&self) -> String { | 189 | pub fn label(&self) -> String { |
| 165 | - format!("{} {}", MONTH_NAMES[(self.month - 1) as usize], self.year) | 190 | + capitalize(&format!( |
| 191 | + "{} {}", | ||
| 192 | + MONTH_NAMES[(self.month - 1) as usize], | ||
| 193 | + self.year | ||
| 194 | + )) | ||
| 166 | } | 195 | } |
| 167 | } | 196 | } |
| 168 | 197 | ||
| @@ -228,12 +257,11 @@ mod tests { | |||
| 228 | 257 | ||
| 229 | #[test] | 258 | #[test] |
| 230 | fn labels() { | 259 | fn labels() { |
| 231 | - assert_eq!(Month::parse("2026-03").unwrap().label(), "mars 2026"); | 260 | + assert_eq!(Month::parse("2026-03").unwrap().label(), "Mars 2026"); |
| 232 | - assert!( | 261 | + assert_eq!( |
| 233 | - Week::parse("2026-W12") | 262 | + Week::parse("2026-W12").unwrap().range_label(), |
| 234 | - .unwrap() | 263 | + "Semaine du 16 mars au 22 mars" |
| 235 | - .label() | ||
| 236 | - .starts_with("Semaine 12") | ||
| 237 | ); | 264 | ); |
| 265 | + assert_eq!(day_month(d(2026, 8, 3)), "3 août"); | ||
| 238 | } | 266 | } |
| 239 | } | 267 | } |
modified
src/render.rs +9 -0 | @@ -15,3 +15,12 @@ impl<T: Template> IntoResponse for HtmlTemplate<T> { | ||
| 15 | 15 | } |
| 16 | 16 | } |
| 17 | 17 | } |
| 18 | + | |
| 19 | +/// Données communes du shell applicatif (en-tête + tab bar + toast). | |
| 20 | +pub struct Shell { | |
| 21 | + pub title: String, | |
| 22 | + pub username: String, | |
| 23 | + /// "add" | "stats" | "times" | "settings" | |
| 24 | + pub tab: &'static str, | |
| 25 | + pub toast: Option<String>, | |
| 26 | +} | |
| @@ -15,3 +15,12 @@ impl<T: Template> IntoResponse for HtmlTemplate<T> { | |||
| 15 | } | 15 | } |
| 16 | } | 16 | } |
| 17 | } | 17 | } |
| 18 | + | ||
| 19 | +/// Données communes du shell applicatif (en-tête + tab bar + toast). | ||
| 20 | +pub struct Shell { | ||
| 21 | + pub title: String, | ||
| 22 | + pub username: String, | ||
| 23 | + /// "add" | "stats" | "times" | "settings" | ||
| 24 | + pub tab: &'static str, | ||
| 25 | + pub toast: Option<String>, | ||
| 26 | +} | ||
modified
src/routes/entries.rs +192 -113 | @@ -1,107 +1,148 @@ | ||
| 1 | +use std::collections::HashMap; | |
| 2 | + | |
| 1 | 3 | use askama::Template; |
| 2 | 4 | use axum::Form; |
| 3 | 5 | use axum::extract::{Path, Query, State}; |
| 4 | -use axum::response::Redirect; | |
| 5 | -use chrono::NaiveDate; | |
| 6 | +use axum::response::{IntoResponse, Redirect, Response}; | |
| 7 | +use chrono::{Duration, NaiveDate}; | |
| 6 | 8 | use serde::Deserialize; |
| 7 | 9 | use uuid::Uuid; |
| 8 | 10 | |
| 9 | 11 | use crate::auth::extractor::AuthUser; |
| 10 | -use crate::charts::fmt_minutes; | |
| 12 | +use crate::charts::{color_map, fmt_minutes}; | |
| 11 | 13 | use crate::db::entries::{self, EntryRow}; |
| 12 | 14 | use crate::db::tasks::{self, TaskRow}; |
| 13 | 15 | use crate::error::AppError; |
| 14 | -use crate::period::paris_today; | |
| 15 | -use crate::render::HtmlTemplate; | |
| 16 | +use crate::period::{Week, day_label, paris_today}; | |
| 17 | +use crate::render::{HtmlTemplate, Shell}; | |
| 18 | +use crate::routes::shell; | |
| 16 | 19 | use crate::state::AppState; |
| 17 | 20 | |
| 18 | -pub struct Preset { | |
| 19 | - pub label: &'static str, | |
| 21 | +const PAGE_SIZE: i64 = 20; | |
| 22 | + | |
| 23 | +/// ─── Étape 1 : choisir la tâche ─── | |
| 24 | + | |
| 25 | +pub struct PickTask { | |
| 20 | 26 | pub href: String, |
| 27 | + pub color: &'static str, | |
| 28 | + pub name: String, | |
| 29 | + /// « 1 h 30 cette semaine » (mon temps, semaine en cours) — vide sinon. | |
| 30 | + pub week_label: String, | |
| 21 | 31 | } |
| 22 | 32 | |
| 23 | -/// Option du select des tâches, avec sélection précalculée côté Rust. | |
| 24 | -pub struct TaskOption { | |
| 25 | - pub id: Uuid, | |
| 26 | - pub name: String, | |
| 27 | - pub archived: bool, | |
| 28 | - pub selected: bool, | |
| 33 | +#[derive(Template)] | |
| 34 | +#[template(path = "add_step1.html")] | |
| 35 | +pub struct AddStep1Page { | |
| 36 | + pub shell: Shell, | |
| 37 | + pub tasks: Vec<PickTask>, | |
| 29 | 38 | } |
| 30 | 39 | |
| 31 | -fn task_options(tasks: Vec<TaskRow>, selected: Option<Uuid>) -> Vec<TaskOption> { | |
| 32 | - tasks | |
| 33 | - .into_iter() | |
| 34 | - .map(|t| TaskOption { | |
| 35 | - selected: selected == Some(t.id), | |
| 36 | - id: t.id, | |
| 37 | - name: t.name, | |
| 38 | - archived: t.archived, | |
| 39 | - }) | |
| 40 | - .collect() | |
| 40 | +/// ─── Étape 2 : durée + date ─── | |
| 41 | + | |
| 42 | +pub struct Preset { | |
| 43 | + pub label: &'static str, | |
| 44 | + pub href: String, | |
| 45 | + pub selected: bool, | |
| 41 | 46 | } |
| 42 | 47 | |
| 43 | 48 | #[derive(Template)] |
| 44 | -#[template(path = "entry_form.html")] | |
| 45 | -pub struct EntryFormPage { | |
| 46 | - /// Titre + URL cible : partagé entre ajout et édition. | |
| 47 | - pub title: &'static str, | |
| 48 | - pub action: String, | |
| 49 | +#[template(path = "add_step2.html")] | |
| 50 | +pub struct AddStep2Page { | |
| 51 | + pub shell: Shell, | |
| 49 | 52 | pub csrf: String, |
| 50 | - pub tasks: Vec<TaskOption>, | |
| 51 | - pub minutes: Option<i32>, | |
| 52 | - pub date: NaiveDate, | |
| 53 | + pub tache: Uuid, | |
| 54 | + pub picked_color: &'static str, | |
| 55 | + pub picked_name: String, | |
| 56 | + pub minutes: i32, | |
| 57 | + pub minutes_pretty: String, | |
| 53 | 58 | pub presets: Vec<Preset>, |
| 54 | - pub saved: bool, | |
| 55 | - /// Some(id) en édition : affiche le bouton Supprimer. | |
| 56 | - pub delete_action: Option<String>, | |
| 59 | + pub date: NaiveDate, | |
| 60 | + pub date_label: String, | |
| 61 | + pub day_prev: String, | |
| 62 | + /// None = aujourd'hui, flèche désactivée. | |
| 63 | + pub day_next: Option<String>, | |
| 57 | 64 | } |
| 58 | 65 | |
| 59 | 66 | #[derive(Deserialize)] |
| 60 | 67 | pub struct NewQuery { |
| 61 | - minutes: Option<i32>, | |
| 62 | 68 | tache: Option<Uuid>, |
| 69 | + minutes: Option<i32>, | |
| 63 | 70 | date: Option<NaiveDate>, |
| 64 | - ok: Option<u8>, | |
| 71 | + /// minutes de la saisie qui vient d'aboutir (toast). | |
| 72 | + ok: Option<i64>, | |
| 65 | 73 | } |
| 66 | 74 | |
| 67 | -fn presets(date: NaiveDate, tache: Option<Uuid>) -> Vec<Preset> { | |
| 68 | - [15, 30, 45, 60] | |
| 69 | - .into_iter() | |
| 70 | - .map(|m| { | |
| 71 | - let mut href = format!("/temps/nouveau?minutes={m}&date={date}"); | |
| 72 | - if let Some(t) = tache { | |
| 73 | - href.push_str(&format!("&tache={t}")); | |
| 74 | - } | |
| 75 | - Preset { | |
| 76 | - label: match m { | |
| 77 | - 15 => "15 min", | |
| 78 | - 30 => "30 min", | |
| 79 | - 45 => "45 min", | |
| 80 | - _ => "1 h", | |
| 81 | - }, | |
| 82 | - href, | |
| 83 | - } | |
| 84 | - }) | |
| 85 | - .collect() | |
| 75 | +fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String { | |
| 76 | + format!("/temps/nouveau?tache={tache}&minutes={minutes}&date={date}") | |
| 86 | 77 | } |
| 87 | 78 | |
| 88 | 79 | pub async fn entry_new( |
| 89 | 80 | State(state): State<AppState>, |
| 90 | 81 | auth: AuthUser, |
| 91 | 82 | Query(q): Query<NewQuery>, |
| 92 | -) -> Result<HtmlTemplate<EntryFormPage>, AppError> { | |
| 93 | - let date = q.date.unwrap_or_else(paris_today); | |
| 94 | - Ok(HtmlTemplate(EntryFormPage { | |
| 95 | - title: "Ajouter un temps", | |
| 96 | - action: "/temps".to_string(), | |
| 97 | - csrf: auth.csrf_token(&state), | |
| 98 | - tasks: task_options(tasks::list_active(&state.pool).await?, q.tache), | |
| 99 | - minutes: q.minutes, | |
| 100 | - date, | |
| 101 | - presets: presets(date, q.tache), | |
| 102 | - saved: q.ok.is_some(), | |
| 103 | - delete_action: None, | |
| 104 | - })) | |
| 83 | +) -> Result<Response, AppError> { | |
| 84 | + let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))); | |
| 85 | + let all_tasks = tasks::list_by_creation(&state.pool).await?; | |
| 86 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 87 | + | |
| 88 | + // Étape 2 si une tâche (active) est choisie. | |
| 89 | + if let Some(picked) = q | |
| 90 | + .tache | |
| 91 | + .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) | |
| 92 | + { | |
| 93 | + let minutes = q.minutes.unwrap_or(30).clamp(1, 1440); | |
| 94 | + let date = q.date.unwrap_or_else(paris_today).min(paris_today()); | |
| 95 | + let presets = [(15, "15 min"), (30, "30 min"), (45, "45 min"), (60, "1 h")] | |
| 96 | + .into_iter() | |
| 97 | + .map(|(m, label)| Preset { | |
| 98 | + label, | |
| 99 | + href: step2_href(picked.id, m, date), | |
| 100 | + selected: minutes == m, | |
| 101 | + }) | |
| 102 | + .collect(); | |
| 103 | + return Ok(HtmlTemplate(AddStep2Page { | |
| 104 | + shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?, | |
| 105 | + csrf: auth.csrf_token(&state), | |
| 106 | + tache: picked.id, | |
| 107 | + picked_color: colors[&picked.id], | |
| 108 | + picked_name: picked.name.clone(), | |
| 109 | + minutes, | |
| 110 | + minutes_pretty: fmt_minutes(minutes as i64), | |
| 111 | + presets, | |
| 112 | + date, | |
| 113 | + date_label: day_label(date), | |
| 114 | + day_prev: step2_href(picked.id, minutes, date - Duration::days(1)), | |
| 115 | + day_next: (date < paris_today()) | |
| 116 | + .then(|| step2_href(picked.id, minutes, date + Duration::days(1))), | |
| 117 | + }) | |
| 118 | + .into_response()); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // Étape 1 : mes totaux de la semaine par tâche, puis liste des actives. | |
| 122 | + let (wfrom, wto) = Week::current().bounds(); | |
| 123 | + let week: HashMap<Uuid, i64> = entries::week_totals_for_user(&state.pool, auth.id, wfrom, wto) | |
| 124 | + .await? | |
| 125 | + .into_iter() | |
| 126 | + .collect(); | |
| 127 | + let mut active: Vec<&TaskRow> = all_tasks.iter().filter(|t| !t.archived).collect(); | |
| 128 | + active.sort_by(|a, b| a.name.cmp(&b.name)); | |
| 129 | + let tasks = active | |
| 130 | + .into_iter() | |
| 131 | + .map(|t| PickTask { | |
| 132 | + href: format!("/temps/nouveau?tache={}", t.id), | |
| 133 | + color: colors[&t.id], | |
| 134 | + name: t.name.clone(), | |
| 135 | + week_label: week | |
| 136 | + .get(&t.id) | |
| 137 | + .map(|m| format!("{} cette semaine", fmt_minutes(*m))) | |
| 138 | + .unwrap_or_default(), | |
| 139 | + }) | |
| 140 | + .collect(); | |
| 141 | + Ok(HtmlTemplate(AddStep1Page { | |
| 142 | + shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?, | |
| 143 | + tasks, | |
| 144 | + }) | |
| 145 | + .into_response()) | |
| 105 | 146 | } |
| 106 | 147 | |
| 107 | 148 | #[derive(Deserialize)] |
| @@ -131,80 +172,118 @@ pub async fn entry_create( | ||
| 131 | 172 | } |
| 132 | 173 | validate(&form)?; |
| 133 | 174 | entries::insert(&state.pool, auth.id, form.tache, form.minutes, form.date).await?; |
| 134 | - Ok(Redirect::to("/temps/nouveau?ok=1")) | |
| 175 | + Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) | |
| 135 | 176 | } |
| 136 | 177 | |
| 137 | -#[derive(Template)] | |
| 138 | -#[template(path = "entries_list.html")] | |
| 139 | -pub struct EntriesListPage { | |
| 140 | - pub entries: Vec<EntryView>, | |
| 141 | - pub page: i64, | |
| 142 | - pub has_next: bool, | |
| 143 | -} | |
| 178 | +/// ─── Mes temps ─── | |
| 144 | 179 | |
| 145 | -pub struct EntryView { | |
| 146 | - pub id: Uuid, | |
| 147 | - pub task_name: String, | |
| 148 | - pub duration: String, | |
| 180 | +pub struct TimeView { | |
| 181 | + pub color: &'static str, | |
| 182 | + pub task: String, | |
| 149 | 183 | pub date: String, |
| 184 | + pub duration: String, | |
| 185 | + pub edit_href: String, | |
| 186 | +} | |
| 187 | + | |
| 188 | +#[derive(Template)] | |
| 189 | +#[template(path = "times.html")] | |
| 190 | +pub struct TimesPage { | |
| 191 | + pub shell: Shell, | |
| 192 | + pub entries: Vec<TimeView>, | |
| 193 | + pub more_href: Option<String>, | |
| 150 | 194 | } |
| 151 | 195 | |
| 152 | 196 | #[derive(Deserialize)] |
| 153 | 197 | pub struct PageQuery { |
| 154 | 198 | page: Option<i64>, |
| 199 | + ok: Option<String>, | |
| 155 | 200 | } |
| 156 | 201 | |
| 157 | 202 | pub async fn entries_list( |
| 158 | 203 | State(state): State<AppState>, |
| 159 | 204 | auth: AuthUser, |
| 160 | 205 | Query(q): Query<PageQuery>, |
| 161 | -) -> Result<HtmlTemplate<EntriesListPage>, AppError> { | |
| 206 | +) -> Result<HtmlTemplate<TimesPage>, AppError> { | |
| 207 | + let toast = match q.ok.as_deref() { | |
| 208 | + Some("modif") => Some("Temps modifié".to_string()), | |
| 209 | + Some("suppr") => Some("Temps supprimé".to_string()), | |
| 210 | + _ => None, | |
| 211 | + }; | |
| 162 | 212 | let page = q.page.unwrap_or(1).max(1); |
| 163 | - let (rows, has_next) = entries::list_page(&state.pool, auth.id, page).await?; | |
| 164 | - let entries = rows.into_iter().map(entry_view).collect(); | |
| 165 | - Ok(HtmlTemplate(EntriesListPage { | |
| 213 | + let (rows, has_more) = entries::list_first(&state.pool, auth.id, page * PAGE_SIZE).await?; | |
| 214 | + | |
| 215 | + let all_tasks = tasks::list_by_creation(&state.pool).await?; | |
| 216 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 217 | + | |
| 218 | + let entries = rows | |
| 219 | + .into_iter() | |
| 220 | + .map(|e: EntryRow| TimeView { | |
| 221 | + color: colors.get(&e.task_id).copied().unwrap_or("#a19786"), | |
| 222 | + task: e.task_name, | |
| 223 | + date: day_label(e.entry_date), | |
| 224 | + duration: fmt_minutes(e.minutes as i64), | |
| 225 | + edit_href: format!("/temps/{}/modifier", e.id), | |
| 226 | + }) | |
| 227 | + .collect(); | |
| 228 | + Ok(HtmlTemplate(TimesPage { | |
| 229 | + shell: shell(&state, &auth, "Mes temps", "times", toast).await?, | |
| 166 | 230 | entries, |
| 167 | - page, | |
| 168 | - has_next, | |
| 231 | + more_href: has_more.then(|| format!("/temps?page={}", page + 1)), | |
| 169 | 232 | })) |
| 170 | 233 | } |
| 171 | 234 | |
| 172 | -fn entry_view(e: EntryRow) -> EntryView { | |
| 173 | - EntryView { | |
| 174 | - id: e.id, | |
| 175 | - task_name: e.task_name, | |
| 176 | - duration: fmt_minutes(e.minutes as i64), | |
| 177 | - date: e.entry_date.format("%d/%m/%Y").to_string(), | |
| 178 | - } | |
| 235 | +/// ─── Édition ─── | |
| 236 | + | |
| 237 | +pub struct TaskOption { | |
| 238 | + pub id: Uuid, | |
| 239 | + pub name: String, | |
| 240 | + pub selected: bool, | |
| 241 | +} | |
| 242 | + | |
| 243 | +#[derive(Template)] | |
| 244 | +#[template(path = "edit.html")] | |
| 245 | +pub struct EditPage { | |
| 246 | + pub shell: Shell, | |
| 247 | + pub csrf: String, | |
| 248 | + pub action: String, | |
| 249 | + pub delete_action: String, | |
| 250 | + pub tasks: Vec<TaskOption>, | |
| 251 | + pub minutes: i32, | |
| 252 | + pub date: NaiveDate, | |
| 179 | 253 | } |
| 180 | 254 | |
| 181 | 255 | pub async fn entry_edit( |
| 182 | 256 | State(state): State<AppState>, |
| 183 | 257 | auth: AuthUser, |
| 184 | 258 | Path(id): Path<Uuid>, |
| 185 | -) -> Result<HtmlTemplate<EntryFormPage>, AppError> { | |
| 259 | +) -> Result<HtmlTemplate<EditPage>, AppError> { | |
| 186 | 260 | let entry = entries::get_owned(&state.pool, id, auth.id) |
| 187 | 261 | .await? |
| 188 | 262 | .ok_or(AppError::NotFound)?; |
| 189 | - // En édition on liste aussi la tâche de l'entrée même si archivée depuis. | |
| 190 | - let mut task_list = tasks::list_active(&state.pool).await?; | |
| 191 | - if !task_list.iter().any(|t| t.id == entry.task_id) { | |
| 192 | - task_list.push(TaskRow { | |
| 193 | - id: entry.task_id, | |
| 194 | - name: entry.task_name.clone(), | |
| 195 | - archived: true, | |
| 196 | - }); | |
| 197 | - } | |
| 198 | - Ok(HtmlTemplate(EntryFormPage { | |
| 199 | - title: "Modifier un temps", | |
| 200 | - action: format!("/temps/{id}"), | |
| 263 | + // La tâche de l'entrée reste proposée même archivée depuis. | |
| 264 | + let mut options: Vec<TaskOption> = tasks::list_all(&state.pool) | |
| 265 | + .await? | |
| 266 | + .into_iter() | |
| 267 | + .filter(|t| !t.archived || t.id == entry.task_id) | |
| 268 | + .map(|t| TaskOption { | |
| 269 | + selected: t.id == entry.task_id, | |
| 270 | + name: if t.archived { | |
| 271 | + format!("{} (archivée)", t.name) | |
| 272 | + } else { | |
| 273 | + t.name | |
| 274 | + }, | |
| 275 | + id: t.id, | |
| 276 | + }) | |
| 277 | + .collect(); | |
| 278 | + options.sort_by(|a, b| a.name.cmp(&b.name)); | |
| 279 | + Ok(HtmlTemplate(EditPage { | |
| 280 | + shell: shell(&state, &auth, "Modifier le temps", "times", None).await?, | |
| 201 | 281 | csrf: auth.csrf_token(&state), |
| 202 | - tasks: task_options(task_list, Some(entry.task_id)), | |
| 203 | - minutes: Some(entry.minutes), | |
| 282 | + action: format!("/temps/{id}"), | |
| 283 | + delete_action: format!("/temps/{id}/supprimer"), | |
| 284 | + tasks: options, | |
| 285 | + minutes: entry.minutes, | |
| 204 | 286 | date: entry.entry_date, |
| 205 | - presets: Vec::new(), | |
| 206 | - saved: false, | |
| 207 | - delete_action: Some(format!("/temps/{id}/supprimer")), | |
| 208 | 287 | })) |
| 209 | 288 | } |
| 210 | 289 | |
| @@ -230,7 +309,7 @@ pub async fn entry_update( | ||
| 230 | 309 | if n == 0 { |
| 231 | 310 | return Err(AppError::NotFound); |
| 232 | 311 | } |
| 233 | - Ok(Redirect::to("/temps")) | |
| 312 | + Ok(Redirect::to("/temps?ok=modif")) | |
| 234 | 313 | } |
| 235 | 314 | |
| 236 | 315 | #[derive(Deserialize)] |
| @@ -250,5 +329,5 @@ pub async fn entry_delete( | ||
| 250 | 329 | if entries::delete_owned(&state.pool, id, auth.id).await? == 0 { |
| 251 | 330 | return Err(AppError::NotFound); |
| 252 | 331 | } |
| 253 | - Ok(Redirect::to("/temps")) | |
| 332 | + Ok(Redirect::to("/temps?ok=suppr")) | |
| 254 | 333 | } |
| @@ -1,107 +1,148 @@ | |||
| 1 | +use std::collections::HashMap; | ||
| 2 | + | ||
| 1 | use askama::Template; | 3 | use askama::Template; |
| 2 | use axum::Form; | 4 | use axum::Form; |
| 3 | use axum::extract::{Path, Query, State}; | 5 | use axum::extract::{Path, Query, State}; |
| 4 | -use axum::response::Redirect; | 6 | +use axum::response::{IntoResponse, Redirect, Response}; |
| 5 | -use chrono::NaiveDate; | 7 | +use chrono::{Duration, NaiveDate}; |
| 6 | use serde::Deserialize; | 8 | use serde::Deserialize; |
| 7 | use uuid::Uuid; | 9 | use uuid::Uuid; |
| 8 | 10 | ||
| 9 | use crate::auth::extractor::AuthUser; | 11 | use crate::auth::extractor::AuthUser; |
| 10 | -use crate::charts::fmt_minutes; | 12 | +use crate::charts::{color_map, fmt_minutes}; |
| 11 | use crate::db::entries::{self, EntryRow}; | 13 | use crate::db::entries::{self, EntryRow}; |
| 12 | use crate::db::tasks::{self, TaskRow}; | 14 | use crate::db::tasks::{self, TaskRow}; |
| 13 | use crate::error::AppError; | 15 | use crate::error::AppError; |
| 14 | -use crate::period::paris_today; | 16 | +use crate::period::{Week, day_label, paris_today}; |
| 15 | -use crate::render::HtmlTemplate; | 17 | +use crate::render::{HtmlTemplate, Shell}; |
| 18 | +use crate::routes::shell; | ||
| 16 | use crate::state::AppState; | 19 | use crate::state::AppState; |
| 17 | 20 | ||
| 18 | -pub struct Preset { | 21 | +const PAGE_SIZE: i64 = 20; |
| 19 | - pub label: &'static str, | 22 | + |
| 23 | +/// ─── Étape 1 : choisir la tâche ─── | ||
| 24 | + | ||
| 25 | +pub struct PickTask { | ||
| 20 | pub href: String, | 26 | pub href: String, |
| 27 | + pub color: &'static str, | ||
| 28 | + pub name: String, | ||
| 29 | + /// « 1 h 30 cette semaine » (mon temps, semaine en cours) — vide sinon. | ||
| 30 | + pub week_label: String, | ||
| 21 | } | 31 | } |
| 22 | 32 | ||
| 23 | -/// Option du select des tâches, avec sélection précalculée côté Rust. | 33 | +#[derive(Template)] |
| 24 | -pub struct TaskOption { | 34 | +#[template(path = "add_step1.html")] |
| 25 | - pub id: Uuid, | 35 | +pub struct AddStep1Page { |
| 26 | - pub name: String, | 36 | + pub shell: Shell, |
| 27 | - pub archived: bool, | 37 | + pub tasks: Vec<PickTask>, |
| 28 | - pub selected: bool, | ||
| 29 | } | 38 | } |
| 30 | 39 | ||
| 31 | -fn task_options(tasks: Vec<TaskRow>, selected: Option<Uuid>) -> Vec<TaskOption> { | 40 | +/// ─── Étape 2 : durée + date ─── |
| 32 | - tasks | 41 | + |
| 33 | - .into_iter() | 42 | +pub struct Preset { |
| 34 | - .map(|t| TaskOption { | 43 | + pub label: &'static str, |
| 35 | - selected: selected == Some(t.id), | 44 | + pub href: String, |
| 36 | - id: t.id, | 45 | + pub selected: bool, |
| 37 | - name: t.name, | ||
| 38 | - archived: t.archived, | ||
| 39 | - }) | ||
| 40 | - .collect() | ||
| 41 | } | 46 | } |
| 42 | 47 | ||
| 43 | #[derive(Template)] | 48 | #[derive(Template)] |
| 44 | -#[template(path = "entry_form.html")] | 49 | +#[template(path = "add_step2.html")] |
| 45 | -pub struct EntryFormPage { | 50 | +pub struct AddStep2Page { |
| 46 | - /// Titre + URL cible : partagé entre ajout et édition. | 51 | + pub shell: Shell, |
| 47 | - pub title: &'static str, | ||
| 48 | - pub action: String, | ||
| 49 | pub csrf: String, | 52 | pub csrf: String, |
| 50 | - pub tasks: Vec<TaskOption>, | 53 | + pub tache: Uuid, |
| 51 | - pub minutes: Option<i32>, | 54 | + pub picked_color: &'static str, |
| 52 | - pub date: NaiveDate, | 55 | + pub picked_name: String, |
| 56 | + pub minutes: i32, | ||
| 57 | + pub minutes_pretty: String, | ||
| 53 | pub presets: Vec<Preset>, | 58 | pub presets: Vec<Preset>, |
| 54 | - pub saved: bool, | 59 | + pub date: NaiveDate, |
| 55 | - /// Some(id) en édition : affiche le bouton Supprimer. | 60 | + pub date_label: String, |
| 56 | - pub delete_action: Option<String>, | 61 | + pub day_prev: String, |
| 62 | + /// None = aujourd'hui, flèche désactivée. | ||
| 63 | + pub day_next: Option<String>, | ||
| 57 | } | 64 | } |
| 58 | 65 | ||
| 59 | #[derive(Deserialize)] | 66 | #[derive(Deserialize)] |
| 60 | pub struct NewQuery { | 67 | pub struct NewQuery { |
| 61 | - minutes: Option<i32>, | ||
| 62 | tache: Option<Uuid>, | 68 | tache: Option<Uuid>, |
| 69 | + minutes: Option<i32>, | ||
| 63 | date: Option<NaiveDate>, | 70 | date: Option<NaiveDate>, |
| 64 | - ok: Option<u8>, | 71 | + /// minutes de la saisie qui vient d'aboutir (toast). |
| 72 | + ok: Option<i64>, | ||
| 65 | } | 73 | } |
| 66 | 74 | ||
| 67 | -fn presets(date: NaiveDate, tache: Option<Uuid>) -> Vec<Preset> { | 75 | +fn step2_href(tache: Uuid, minutes: i32, date: NaiveDate) -> String { |
| 68 | - [15, 30, 45, 60] | 76 | + format!("/temps/nouveau?tache={tache}&minutes={minutes}&date={date}") |
| 69 | - .into_iter() | ||
| 70 | - .map(|m| { | ||
| 71 | - let mut href = format!("/temps/nouveau?minutes={m}&date={date}"); | ||
| 72 | - if let Some(t) = tache { | ||
| 73 | - href.push_str(&format!("&tache={t}")); | ||
| 74 | - } | ||
| 75 | - Preset { | ||
| 76 | - label: match m { | ||
| 77 | - 15 => "15 min", | ||
| 78 | - 30 => "30 min", | ||
| 79 | - 45 => "45 min", | ||
| 80 | - _ => "1 h", | ||
| 81 | - }, | ||
| 82 | - href, | ||
| 83 | - } | ||
| 84 | - }) | ||
| 85 | - .collect() | ||
| 86 | } | 77 | } |
| 87 | 78 | ||
| 88 | pub async fn entry_new( | 79 | pub async fn entry_new( |
| 89 | State(state): State<AppState>, | 80 | State(state): State<AppState>, |
| 90 | auth: AuthUser, | 81 | auth: AuthUser, |
| 91 | Query(q): Query<NewQuery>, | 82 | Query(q): Query<NewQuery>, |
| 92 | -) -> Result<HtmlTemplate<EntryFormPage>, AppError> { | 83 | +) -> Result<Response, AppError> { |
| 93 | - let date = q.date.unwrap_or_else(paris_today); | 84 | + let toast = q.ok.map(|m| format!("{} ajouté ✓", fmt_minutes(m))); |
| 94 | - Ok(HtmlTemplate(EntryFormPage { | 85 | + let all_tasks = tasks::list_by_creation(&state.pool).await?; |
| 95 | - title: "Ajouter un temps", | 86 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); |
| 96 | - action: "/temps".to_string(), | 87 | + |
| 97 | - csrf: auth.csrf_token(&state), | 88 | + // Étape 2 si une tâche (active) est choisie. |
| 98 | - tasks: task_options(tasks::list_active(&state.pool).await?, q.tache), | 89 | + if let Some(picked) = q |
| 99 | - minutes: q.minutes, | 90 | + .tache |
| 100 | - date, | 91 | + .and_then(|id| all_tasks.iter().find(|t| t.id == id && !t.archived)) |
| 101 | - presets: presets(date, q.tache), | 92 | + { |
| 102 | - saved: q.ok.is_some(), | 93 | + let minutes = q.minutes.unwrap_or(30).clamp(1, 1440); |
| 103 | - delete_action: None, | 94 | + let date = q.date.unwrap_or_else(paris_today).min(paris_today()); |
| 104 | - })) | 95 | + let presets = [(15, "15 min"), (30, "30 min"), (45, "45 min"), (60, "1 h")] |
| 96 | + .into_iter() | ||
| 97 | + .map(|(m, label)| Preset { | ||
| 98 | + label, | ||
| 99 | + href: step2_href(picked.id, m, date), | ||
| 100 | + selected: minutes == m, | ||
| 101 | + }) | ||
| 102 | + .collect(); | ||
| 103 | + return Ok(HtmlTemplate(AddStep2Page { | ||
| 104 | + shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?, | ||
| 105 | + csrf: auth.csrf_token(&state), | ||
| 106 | + tache: picked.id, | ||
| 107 | + picked_color: colors[&picked.id], | ||
| 108 | + picked_name: picked.name.clone(), | ||
| 109 | + minutes, | ||
| 110 | + minutes_pretty: fmt_minutes(minutes as i64), | ||
| 111 | + presets, | ||
| 112 | + date, | ||
| 113 | + date_label: day_label(date), | ||
| 114 | + day_prev: step2_href(picked.id, minutes, date - Duration::days(1)), | ||
| 115 | + day_next: (date < paris_today()) | ||
| 116 | + .then(|| step2_href(picked.id, minutes, date + Duration::days(1))), | ||
| 117 | + }) | ||
| 118 | + .into_response()); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + // Étape 1 : mes totaux de la semaine par tâche, puis liste des actives. | ||
| 122 | + let (wfrom, wto) = Week::current().bounds(); | ||
| 123 | + let week: HashMap<Uuid, i64> = entries::week_totals_for_user(&state.pool, auth.id, wfrom, wto) | ||
| 124 | + .await? | ||
| 125 | + .into_iter() | ||
| 126 | + .collect(); | ||
| 127 | + let mut active: Vec<&TaskRow> = all_tasks.iter().filter(|t| !t.archived).collect(); | ||
| 128 | + active.sort_by(|a, b| a.name.cmp(&b.name)); | ||
| 129 | + let tasks = active | ||
| 130 | + .into_iter() | ||
| 131 | + .map(|t| PickTask { | ||
| 132 | + href: format!("/temps/nouveau?tache={}", t.id), | ||
| 133 | + color: colors[&t.id], | ||
| 134 | + name: t.name.clone(), | ||
| 135 | + week_label: week | ||
| 136 | + .get(&t.id) | ||
| 137 | + .map(|m| format!("{} cette semaine", fmt_minutes(*m))) | ||
| 138 | + .unwrap_or_default(), | ||
| 139 | + }) | ||
| 140 | + .collect(); | ||
| 141 | + Ok(HtmlTemplate(AddStep1Page { | ||
| 142 | + shell: shell(&state, &auth, "Ajouter du temps", "add", toast).await?, | ||
| 143 | + tasks, | ||
| 144 | + }) | ||
| 145 | + .into_response()) | ||
| 105 | } | 146 | } |
| 106 | 147 | ||
| 107 | #[derive(Deserialize)] | 148 | #[derive(Deserialize)] |
| @@ -131,80 +172,118 @@ pub async fn entry_create( | |||
| 131 | } | 172 | } |
| 132 | validate(&form)?; | 173 | validate(&form)?; |
| 133 | entries::insert(&state.pool, auth.id, form.tache, form.minutes, form.date).await?; | 174 | entries::insert(&state.pool, auth.id, form.tache, form.minutes, form.date).await?; |
| 134 | - Ok(Redirect::to("/temps/nouveau?ok=1")) | 175 | + Ok(Redirect::to(&format!("/temps/nouveau?ok={}", form.minutes))) |
| 135 | } | 176 | } |
| 136 | 177 | ||
| 137 | -#[derive(Template)] | 178 | +/// ─── Mes temps ─── |
| 138 | -#[template(path = "entries_list.html")] | ||
| 139 | -pub struct EntriesListPage { | ||
| 140 | - pub entries: Vec<EntryView>, | ||
| 141 | - pub page: i64, | ||
| 142 | - pub has_next: bool, | ||
| 143 | -} | ||
| 144 | 179 | ||
| 145 | -pub struct EntryView { | 180 | +pub struct TimeView { |
| 146 | - pub id: Uuid, | 181 | + pub color: &'static str, |
| 147 | - pub task_name: String, | 182 | + pub task: String, |
| 148 | - pub duration: String, | ||
| 149 | pub date: String, | 183 | pub date: String, |
| 184 | + pub duration: String, | ||
| 185 | + pub edit_href: String, | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +#[derive(Template)] | ||
| 189 | +#[template(path = "times.html")] | ||
| 190 | +pub struct TimesPage { | ||
| 191 | + pub shell: Shell, | ||
| 192 | + pub entries: Vec<TimeView>, | ||
| 193 | + pub more_href: Option<String>, | ||
| 150 | } | 194 | } |
| 151 | 195 | ||
| 152 | #[derive(Deserialize)] | 196 | #[derive(Deserialize)] |
| 153 | pub struct PageQuery { | 197 | pub struct PageQuery { |
| 154 | page: Option<i64>, | 198 | page: Option<i64>, |
| 199 | + ok: Option<String>, | ||
| 155 | } | 200 | } |
| 156 | 201 | ||
| 157 | pub async fn entries_list( | 202 | pub async fn entries_list( |
| 158 | State(state): State<AppState>, | 203 | State(state): State<AppState>, |
| 159 | auth: AuthUser, | 204 | auth: AuthUser, |
| 160 | Query(q): Query<PageQuery>, | 205 | Query(q): Query<PageQuery>, |
| 161 | -) -> Result<HtmlTemplate<EntriesListPage>, AppError> { | 206 | +) -> Result<HtmlTemplate<TimesPage>, AppError> { |
| 207 | + let toast = match q.ok.as_deref() { | ||
| 208 | + Some("modif") => Some("Temps modifié".to_string()), | ||
| 209 | + Some("suppr") => Some("Temps supprimé".to_string()), | ||
| 210 | + _ => None, | ||
| 211 | + }; | ||
| 162 | let page = q.page.unwrap_or(1).max(1); | 212 | let page = q.page.unwrap_or(1).max(1); |
| 163 | - let (rows, has_next) = entries::list_page(&state.pool, auth.id, page).await?; | 213 | + let (rows, has_more) = entries::list_first(&state.pool, auth.id, page * PAGE_SIZE).await?; |
| 164 | - let entries = rows.into_iter().map(entry_view).collect(); | 214 | + |
| 165 | - Ok(HtmlTemplate(EntriesListPage { | 215 | + let all_tasks = tasks::list_by_creation(&state.pool).await?; |
| 216 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | ||
| 217 | + | ||
| 218 | + let entries = rows | ||
| 219 | + .into_iter() | ||
| 220 | + .map(|e: EntryRow| TimeView { | ||
| 221 | + color: colors.get(&e.task_id).copied().unwrap_or("#a19786"), | ||
| 222 | + task: e.task_name, | ||
| 223 | + date: day_label(e.entry_date), | ||
| 224 | + duration: fmt_minutes(e.minutes as i64), | ||
| 225 | + edit_href: format!("/temps/{}/modifier", e.id), | ||
| 226 | + }) | ||
| 227 | + .collect(); | ||
| 228 | + Ok(HtmlTemplate(TimesPage { | ||
| 229 | + shell: shell(&state, &auth, "Mes temps", "times", toast).await?, | ||
| 166 | entries, | 230 | entries, |
| 167 | - page, | 231 | + more_href: has_more.then(|| format!("/temps?page={}", page + 1)), |
| 168 | - has_next, | ||
| 169 | })) | 232 | })) |
| 170 | } | 233 | } |
| 171 | 234 | ||
| 172 | -fn entry_view(e: EntryRow) -> EntryView { | 235 | +/// ─── Édition ─── |
| 173 | - EntryView { | 236 | + |
| 174 | - id: e.id, | 237 | +pub struct TaskOption { |
| 175 | - task_name: e.task_name, | 238 | + pub id: Uuid, |
| 176 | - duration: fmt_minutes(e.minutes as i64), | 239 | + pub name: String, |
| 177 | - date: e.entry_date.format("%d/%m/%Y").to_string(), | 240 | + pub selected: bool, |
| 178 | - } | 241 | +} |
| 242 | + | ||
| 243 | +#[derive(Template)] | ||
| 244 | +#[template(path = "edit.html")] | ||
| 245 | +pub struct EditPage { | ||
| 246 | + pub shell: Shell, | ||
| 247 | + pub csrf: String, | ||
| 248 | + pub action: String, | ||
| 249 | + pub delete_action: String, | ||
| 250 | + pub tasks: Vec<TaskOption>, | ||
| 251 | + pub minutes: i32, | ||
| 252 | + pub date: NaiveDate, | ||
| 179 | } | 253 | } |
| 180 | 254 | ||
| 181 | pub async fn entry_edit( | 255 | pub async fn entry_edit( |
| 182 | State(state): State<AppState>, | 256 | State(state): State<AppState>, |
| 183 | auth: AuthUser, | 257 | auth: AuthUser, |
| 184 | Path(id): Path<Uuid>, | 258 | Path(id): Path<Uuid>, |
| 185 | -) -> Result<HtmlTemplate<EntryFormPage>, AppError> { | 259 | +) -> Result<HtmlTemplate<EditPage>, AppError> { |
| 186 | let entry = entries::get_owned(&state.pool, id, auth.id) | 260 | let entry = entries::get_owned(&state.pool, id, auth.id) |
| 187 | .await? | 261 | .await? |
| 188 | .ok_or(AppError::NotFound)?; | 262 | .ok_or(AppError::NotFound)?; |
| 189 | - // En édition on liste aussi la tâche de l'entrée même si archivée depuis. | 263 | + // La tâche de l'entrée reste proposée même archivée depuis. |
| 190 | - let mut task_list = tasks::list_active(&state.pool).await?; | 264 | + let mut options: Vec<TaskOption> = tasks::list_all(&state.pool) |
| 191 | - if !task_list.iter().any(|t| t.id == entry.task_id) { | 265 | + .await? |
| 192 | - task_list.push(TaskRow { | 266 | + .into_iter() |
| 193 | - id: entry.task_id, | 267 | + .filter(|t| !t.archived || t.id == entry.task_id) |
| 194 | - name: entry.task_name.clone(), | 268 | + .map(|t| TaskOption { |
| 195 | - archived: true, | 269 | + selected: t.id == entry.task_id, |
| 196 | - }); | 270 | + name: if t.archived { |
| 197 | - } | 271 | + format!("{} (archivée)", t.name) |
| 198 | - Ok(HtmlTemplate(EntryFormPage { | 272 | + } else { |
| 199 | - title: "Modifier un temps", | 273 | + t.name |
| 200 | - action: format!("/temps/{id}"), | 274 | + }, |
| 275 | + id: t.id, | ||
| 276 | + }) | ||
| 277 | + .collect(); | ||
| 278 | + options.sort_by(|a, b| a.name.cmp(&b.name)); | ||
| 279 | + Ok(HtmlTemplate(EditPage { | ||
| 280 | + shell: shell(&state, &auth, "Modifier le temps", "times", None).await?, | ||
| 201 | csrf: auth.csrf_token(&state), | 281 | csrf: auth.csrf_token(&state), |
| 202 | - tasks: task_options(task_list, Some(entry.task_id)), | 282 | + action: format!("/temps/{id}"), |
| 203 | - minutes: Some(entry.minutes), | 283 | + delete_action: format!("/temps/{id}/supprimer"), |
| 284 | + tasks: options, | ||
| 285 | + minutes: entry.minutes, | ||
| 204 | date: entry.entry_date, | 286 | date: entry.entry_date, |
| 205 | - presets: Vec::new(), | ||
| 206 | - saved: false, | ||
| 207 | - delete_action: Some(format!("/temps/{id}/supprimer")), | ||
| 208 | })) | 287 | })) |
| 209 | } | 288 | } |
| 210 | 289 | ||
| @@ -230,7 +309,7 @@ pub async fn entry_update( | |||
| 230 | if n == 0 { | 309 | if n == 0 { |
| 231 | return Err(AppError::NotFound); | 310 | return Err(AppError::NotFound); |
| 232 | } | 311 | } |
| 233 | - Ok(Redirect::to("/temps")) | 312 | + Ok(Redirect::to("/temps?ok=modif")) |
| 234 | } | 313 | } |
| 235 | 314 | ||
| 236 | #[derive(Deserialize)] | 315 | #[derive(Deserialize)] |
| @@ -250,5 +329,5 @@ pub async fn entry_delete( | |||
| 250 | if entries::delete_owned(&state.pool, id, auth.id).await? == 0 { | 329 | if entries::delete_owned(&state.pool, id, auth.id).await? == 0 { |
| 251 | return Err(AppError::NotFound); | 330 | return Err(AppError::NotFound); |
| 252 | } | 331 | } |
| 253 | - Ok(Redirect::to("/temps")) | 332 | + Ok(Redirect::to("/temps?ok=suppr")) |
| 254 | } | 333 | } |
deleted
src/routes/home.rs +0 -12 | deleted file mode 100644 | ||
| @@ -1,12 +0,0 @@ | ||
| 1 | -use askama::Template; | |
| 2 | - | |
| 3 | -use crate::auth::extractor::AuthUser; | |
| 4 | -use crate::render::HtmlTemplate; | |
| 5 | - | |
| 6 | -#[derive(Template)] | |
| 7 | -#[template(path = "home.html")] | |
| 8 | -pub struct HomePage; | |
| 9 | - | |
| 10 | -pub async fn home(_auth: AuthUser) -> HtmlTemplate<HomePage> { | |
| 11 | - HtmlTemplate(HomePage) | |
| 12 | -} | |
| deleted file mode 100644 | |||
| @@ -1,12 +0,0 @@ | |||
| 1 | -use askama::Template; | ||
| 2 | - | ||
| 3 | -use crate::auth::extractor::AuthUser; | ||
| 4 | -use crate::render::HtmlTemplate; | ||
| 5 | - | ||
| 6 | -#[derive(Template)] | ||
| 7 | -#[template(path = "home.html")] | ||
| 8 | -pub struct HomePage; | ||
| 9 | - | ||
| 10 | -pub async fn home(_auth: AuthUser) -> HtmlTemplate<HomePage> { | ||
| 11 | - HtmlTemplate(HomePage) | ||
| 12 | -} | ||
modified
src/routes/mod.rs +32 -5 | @@ -1,18 +1,21 @@ | ||
| 1 | 1 | mod entries; |
| 2 | -mod home; | |
| 3 | 2 | mod login; |
| 4 | 3 | mod settings; |
| 5 | 4 | mod statics; |
| 6 | 5 | mod stats; |
| 7 | 6 | |
| 8 | 7 | use axum::Router; |
| 8 | +use axum::response::Redirect; | |
| 9 | 9 | use axum::routing::{get, post}; |
| 10 | 10 | |
| 11 | +use crate::auth::extractor::AuthUser; | |
| 12 | +use crate::error::AppError; | |
| 13 | +use crate::render::Shell; | |
| 11 | 14 | use crate::state::AppState; |
| 12 | 15 | |
| 13 | 16 | pub fn router(state: AppState) -> Router { |
| 14 | 17 | Router::new() |
| 15 | - .route("/", get(home::home)) | |
| 18 | + .route("/", get(root)) | |
| 16 | 19 | .route("/login", get(login::login_page).post(login::login_submit)) |
| 17 | 20 | .route("/logout", post(login::logout)) |
| 18 | 21 | .route( |
| @@ -25,7 +28,7 @@ pub fn router(state: AppState) -> Router { | ||
| 25 | 28 | .route("/temps/{id}/supprimer", post(entries::entry_delete)) |
| 26 | 29 | .route("/stats", get(stats::stats_page)) |
| 27 | 30 | .route("/parametres", get(settings::settings_page)) |
| 28 | - .route("/parametres/taches", post(settings::task_create)) | |
| 31 | + .route("/parametres/taches", get(settings::tasks_page).post(settings::task_create)) | |
| 29 | 32 | .route("/parametres/taches/{id}", post(settings::task_rename)) |
| 30 | 33 | .route( |
| 31 | 34 | "/parametres/taches/{id}/archiver", |
| @@ -42,6 +45,30 @@ pub fn router(state: AppState) -> Router { | ||
| 42 | 45 | .with_state(state) |
| 43 | 46 | } |
| 44 | 47 | |
| 45 | -async fn fallback_404() -> crate::error::AppError { | |
| 46 | - crate::error::AppError::NotFound | |
| 48 | +/// L'app s'ouvre sur l'écran d'ajout (comme la maquette). | |
| 49 | +async fn root(_auth: AuthUser) -> Redirect { | |
| 50 | + Redirect::to("/temps/nouveau") | |
| 51 | +} | |
| 52 | + | |
| 53 | +async fn fallback_404() -> AppError { | |
| 54 | + AppError::NotFound | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// Construit le shell (titre + username + tab + toast). | |
| 58 | +pub async fn shell( | |
| 59 | + state: &AppState, | |
| 60 | + auth: &AuthUser, | |
| 61 | + title: &str, | |
| 62 | + tab: &'static str, | |
| 63 | + toast: Option<String>, | |
| 64 | +) -> Result<Shell, AppError> { | |
| 65 | + let username = crate::db::users::username(&state.pool, auth.id) | |
| 66 | + .await? | |
| 67 | + .unwrap_or_default(); | |
| 68 | + Ok(Shell { | |
| 69 | + title: title.to_string(), | |
| 70 | + username, | |
| 71 | + tab, | |
| 72 | + toast, | |
| 73 | + }) | |
| 47 | 74 | } |
| @@ -1,18 +1,21 @@ | |||
| 1 | mod entries; | 1 | mod entries; |
| 2 | -mod home; | ||
| 3 | mod login; | 2 | mod login; |
| 4 | mod settings; | 3 | mod settings; |
| 5 | mod statics; | 4 | mod statics; |
| 6 | mod stats; | 5 | mod stats; |
| 7 | 6 | ||
| 8 | use axum::Router; | 7 | use axum::Router; |
| 8 | +use axum::response::Redirect; | ||
| 9 | use axum::routing::{get, post}; | 9 | use axum::routing::{get, post}; |
| 10 | 10 | ||
| 11 | +use crate::auth::extractor::AuthUser; | ||
| 12 | +use crate::error::AppError; | ||
| 13 | +use crate::render::Shell; | ||
| 11 | use crate::state::AppState; | 14 | use crate::state::AppState; |
| 12 | 15 | ||
| 13 | pub fn router(state: AppState) -> Router { | 16 | pub fn router(state: AppState) -> Router { |
| 14 | Router::new() | 17 | Router::new() |
| 15 | - .route("/", get(home::home)) | 18 | + .route("/", get(root)) |
| 16 | .route("/login", get(login::login_page).post(login::login_submit)) | 19 | .route("/login", get(login::login_page).post(login::login_submit)) |
| 17 | .route("/logout", post(login::logout)) | 20 | .route("/logout", post(login::logout)) |
| 18 | .route( | 21 | .route( |
| @@ -25,7 +28,7 @@ pub fn router(state: AppState) -> Router { | |||
| 25 | .route("/temps/{id}/supprimer", post(entries::entry_delete)) | 28 | .route("/temps/{id}/supprimer", post(entries::entry_delete)) |
| 26 | .route("/stats", get(stats::stats_page)) | 29 | .route("/stats", get(stats::stats_page)) |
| 27 | .route("/parametres", get(settings::settings_page)) | 30 | .route("/parametres", get(settings::settings_page)) |
| 28 | - .route("/parametres/taches", post(settings::task_create)) | 31 | + .route("/parametres/taches", get(settings::tasks_page).post(settings::task_create)) |
| 29 | .route("/parametres/taches/{id}", post(settings::task_rename)) | 32 | .route("/parametres/taches/{id}", post(settings::task_rename)) |
| 30 | .route( | 33 | .route( |
| 31 | "/parametres/taches/{id}/archiver", | 34 | "/parametres/taches/{id}/archiver", |
| @@ -42,6 +45,30 @@ pub fn router(state: AppState) -> Router { | |||
| 42 | .with_state(state) | 45 | .with_state(state) |
| 43 | } | 46 | } |
| 44 | 47 | ||
| 45 | -async fn fallback_404() -> crate::error::AppError { | 48 | +/// L'app s'ouvre sur l'écran d'ajout (comme la maquette). |
| 46 | - crate::error::AppError::NotFound | 49 | +async fn root(_auth: AuthUser) -> Redirect { |
| 50 | + Redirect::to("/temps/nouveau") | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +async fn fallback_404() -> AppError { | ||
| 54 | + AppError::NotFound | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +/// Construit le shell (titre + username + tab + toast). | ||
| 58 | +pub async fn shell( | ||
| 59 | + state: &AppState, | ||
| 60 | + auth: &AuthUser, | ||
| 61 | + title: &str, | ||
| 62 | + tab: &'static str, | ||
| 63 | + toast: Option<String>, | ||
| 64 | +) -> Result<Shell, AppError> { | ||
| 65 | + let username = crate::db::users::username(&state.pool, auth.id) | ||
| 66 | + .await? | ||
| 67 | + .unwrap_or_default(); | ||
| 68 | + Ok(Shell { | ||
| 69 | + title: title.to_string(), | ||
| 70 | + username, | ||
| 71 | + tab, | ||
| 72 | + toast, | ||
| 73 | + }) | ||
| 47 | } | 74 | } |
modified
src/routes/settings.rs +80 -21 | @@ -7,18 +7,20 @@ use uuid::Uuid; | ||
| 7 | 7 | |
| 8 | 8 | use crate::auth::extractor::AuthUser; |
| 9 | 9 | use crate::auth::password; |
| 10 | +use crate::charts::color_map; | |
| 10 | 11 | use crate::db::{tasks, users}; |
| 11 | -use crate::db::tasks::TaskRow; | |
| 12 | 12 | use crate::error::AppError; |
| 13 | -use crate::render::HtmlTemplate; | |
| 13 | +use crate::render::{HtmlTemplate, Shell}; | |
| 14 | +use crate::routes::shell; | |
| 14 | 15 | use crate::state::AppState; |
| 15 | 16 | |
| 17 | +/// ─── Réglages ─── | |
| 18 | + | |
| 16 | 19 | #[derive(Template)] |
| 17 | 20 | #[template(path = "settings.html")] |
| 18 | 21 | pub struct SettingsPage { |
| 19 | - pub tasks: Vec<TaskRow>, | |
| 22 | + pub shell: Shell, | |
| 20 | 23 | pub csrf: String, |
| 21 | - pub notice: Option<&'static str>, | |
| 22 | 24 | pub error: Option<&'static str>, |
| 23 | 25 | } |
| 24 | 26 | |
| @@ -33,27 +35,78 @@ pub async fn settings_page( | ||
| 33 | 35 | auth: AuthUser, |
| 34 | 36 | Query(q): Query<SettingsQuery>, |
| 35 | 37 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { |
| 36 | - let notice = match q.ok.as_deref() { | |
| 37 | - Some("mdp") => Some("Mot de passe modifié."), | |
| 38 | - Some("tache") => Some("Tâche enregistrée."), | |
| 38 | + let toast = match q.ok.as_deref() { | |
| 39 | + Some("mdp") => Some("Mot de passe mis à jour".to_string()), | |
| 39 | 40 | _ => None, |
| 40 | 41 | }; |
| 41 | 42 | let error = match q.err.as_deref() { |
| 42 | - Some("mdp") => Some("Ancien mot de passe incorrect."), | |
| 43 | + Some("mdp") => Some("L'ancien mot de passe est incorrect."), | |
| 43 | 44 | Some("confirm") => Some("La confirmation ne correspond pas."), |
| 44 | 45 | Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."), |
| 45 | - Some("doublon") => Some("Une tâche porte déjà ce nom."), | |
| 46 | - Some("vide") => Some("Le nom de la tâche ne peut pas être vide."), | |
| 47 | 46 | _ => None, |
| 48 | 47 | }; |
| 49 | 48 | Ok(HtmlTemplate(SettingsPage { |
| 50 | - tasks: tasks::list_all(&state.pool).await?, | |
| 49 | + shell: shell(&state, &auth, "Paramètres", "settings", toast).await?, | |
| 51 | 50 | csrf: auth.csrf_token(&state), |
| 52 | - notice, | |
| 53 | 51 | error, |
| 54 | 52 | })) |
| 55 | 53 | } |
| 56 | 54 | |
| 55 | +/// ─── Tâches ─── | |
| 56 | + | |
| 57 | +pub struct TaskView { | |
| 58 | + pub color: &'static str, | |
| 59 | + pub name: String, | |
| 60 | + pub archived: bool, | |
| 61 | + pub rename_action: String, | |
| 62 | + pub toggle_action: String, | |
| 63 | +} | |
| 64 | + | |
| 65 | +#[derive(Template)] | |
| 66 | +#[template(path = "tasks.html")] | |
| 67 | +pub struct TasksPage { | |
| 68 | + pub shell: Shell, | |
| 69 | + pub csrf: String, | |
| 70 | + pub tasks: Vec<TaskView>, | |
| 71 | +} | |
| 72 | + | |
| 73 | +pub async fn tasks_page( | |
| 74 | + State(state): State<AppState>, | |
| 75 | + auth: AuthUser, | |
| 76 | + Query(q): Query<SettingsQuery>, | |
| 77 | +) -> Result<HtmlTemplate<TasksPage>, AppError> { | |
| 78 | + let toast = match (q.ok.as_deref(), q.err.as_deref()) { | |
| 79 | + (Some("ajoutee"), _) => Some("Tâche ajoutée".to_string()), | |
| 80 | + (Some("renommee"), _) => Some("Tâche renommée".to_string()), | |
| 81 | + (Some("archivee"), _) => { | |
| 82 | + Some("Tâche archivée — les temps restent dans les stats".to_string()) | |
| 83 | + } | |
| 84 | + (Some("restauree"), _) => Some("Tâche restaurée".to_string()), | |
| 85 | + (_, Some("doublon")) => Some("Une tâche porte déjà ce nom".to_string()), | |
| 86 | + (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()), | |
| 87 | + _ => None, | |
| 88 | + }; | |
| 89 | + let by_creation = tasks::list_by_creation(&state.pool).await?; | |
| 90 | + let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 91 | + let mut list = by_creation; | |
| 92 | + list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name))); | |
| 93 | + let tasks = list | |
| 94 | + .into_iter() | |
| 95 | + .map(|t| TaskView { | |
| 96 | + color: colors[&t.id], | |
| 97 | + rename_action: format!("/parametres/taches/{}", t.id), | |
| 98 | + toggle_action: format!("/parametres/taches/{}/archiver", t.id), | |
| 99 | + name: t.name, | |
| 100 | + archived: t.archived, | |
| 101 | + }) | |
| 102 | + .collect(); | |
| 103 | + Ok(HtmlTemplate(TasksPage { | |
| 104 | + shell: shell(&state, &auth, "Tâches", "settings", toast).await?, | |
| 105 | + csrf: auth.csrf_token(&state), | |
| 106 | + tasks, | |
| 107 | + })) | |
| 108 | +} | |
| 109 | + | |
| 57 | 110 | #[derive(Deserialize)] |
| 58 | 111 | pub struct TaskForm { |
| 59 | 112 | name: String, |
| @@ -74,11 +127,11 @@ pub async fn task_create( | ||
| 74 | 127 | } |
| 75 | 128 | let name = form.name.trim(); |
| 76 | 129 | if name.is_empty() { |
| 77 | - return Ok(Redirect::to("/parametres?err=vide")); | |
| 130 | + return Ok(Redirect::to("/parametres/taches?err=vide")); | |
| 78 | 131 | } |
| 79 | 132 | match tasks::create(&state.pool, name).await { |
| 80 | - Ok(_) => Ok(Redirect::to("/parametres?ok=tache")), | |
| 81 | - Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=doublon")), | |
| 133 | + Ok(_) => Ok(Redirect::to("/parametres/taches?ok=ajoutee")), | |
| 134 | + Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")), | |
| 82 | 135 | Err(e) => Err(e.into()), |
| 83 | 136 | } |
| 84 | 137 | } |
| @@ -94,26 +147,28 @@ pub async fn task_rename( | ||
| 94 | 147 | } |
| 95 | 148 | let name = form.name.trim(); |
| 96 | 149 | if name.is_empty() { |
| 97 | - return Ok(Redirect::to("/parametres?err=vide")); | |
| 150 | + return Ok(Redirect::to("/parametres/taches?err=vide")); | |
| 98 | 151 | } |
| 99 | 152 | match tasks::rename(&state.pool, id, name).await { |
| 100 | 153 | Ok(0) => Err(AppError::NotFound), |
| 101 | - Ok(_) => Ok(Redirect::to("/parametres?ok=tache")), | |
| 102 | - Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=doublon")), | |
| 154 | + Ok(_) => Ok(Redirect::to("/parametres/taches?ok=renommee")), | |
| 155 | + Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")), | |
| 103 | 156 | Err(e) => Err(e.into()), |
| 104 | 157 | } |
| 105 | 158 | } |
| 106 | 159 | |
| 107 | 160 | #[derive(Deserialize)] |
| 108 | -pub struct CsrfOnly { | |
| 161 | +pub struct ToggleForm { | |
| 109 | 162 | csrf: String, |
| 163 | + /// true si la tâche était archivée (pour le message du toast). | |
| 164 | + restaure: Option<u8>, | |
| 110 | 165 | } |
| 111 | 166 | |
| 112 | 167 | pub async fn task_toggle_archive( |
| 113 | 168 | State(state): State<AppState>, |
| 114 | 169 | auth: AuthUser, |
| 115 | 170 | Path(id): Path<Uuid>, |
| 116 | - Form(form): Form<CsrfOnly>, | |
| 171 | + Form(form): Form<ToggleForm>, | |
| 117 | 172 | ) -> Result<Redirect, AppError> { |
| 118 | 173 | if !auth.check_csrf(&state, &form.csrf) { |
| 119 | 174 | return Err(AppError::Csrf); |
| @@ -121,7 +176,11 @@ pub async fn task_toggle_archive( | ||
| 121 | 176 | if tasks::toggle_archived(&state.pool, id).await? == 0 { |
| 122 | 177 | return Err(AppError::NotFound); |
| 123 | 178 | } |
| 124 | - Ok(Redirect::to("/parametres?ok=tache")) | |
| 179 | + Ok(Redirect::to(if form.restaure.is_some() { | |
| 180 | + "/parametres/taches?ok=restauree" | |
| 181 | + } else { | |
| 182 | + "/parametres/taches?ok=archivee" | |
| 183 | + })) | |
| 125 | 184 | } |
| 126 | 185 | |
| 127 | 186 | #[derive(Deserialize)] |
| @@ -7,18 +7,20 @@ use uuid::Uuid; | |||
| 7 | 7 | ||
| 8 | use crate::auth::extractor::AuthUser; | 8 | use crate::auth::extractor::AuthUser; |
| 9 | use crate::auth::password; | 9 | use crate::auth::password; |
| 10 | +use crate::charts::color_map; | ||
| 10 | use crate::db::{tasks, users}; | 11 | use crate::db::{tasks, users}; |
| 11 | -use crate::db::tasks::TaskRow; | ||
| 12 | use crate::error::AppError; | 12 | use crate::error::AppError; |
| 13 | -use crate::render::HtmlTemplate; | 13 | +use crate::render::{HtmlTemplate, Shell}; |
| 14 | +use crate::routes::shell; | ||
| 14 | use crate::state::AppState; | 15 | use crate::state::AppState; |
| 15 | 16 | ||
| 17 | +/// ─── Réglages ─── | ||
| 18 | + | ||
| 16 | #[derive(Template)] | 19 | #[derive(Template)] |
| 17 | #[template(path = "settings.html")] | 20 | #[template(path = "settings.html")] |
| 18 | pub struct SettingsPage { | 21 | pub struct SettingsPage { |
| 19 | - pub tasks: Vec<TaskRow>, | 22 | + pub shell: Shell, |
| 20 | pub csrf: String, | 23 | pub csrf: String, |
| 21 | - pub notice: Option<&'static str>, | ||
| 22 | pub error: Option<&'static str>, | 24 | pub error: Option<&'static str>, |
| 23 | } | 25 | } |
| 24 | 26 | ||
| @@ -33,27 +35,78 @@ pub async fn settings_page( | |||
| 33 | auth: AuthUser, | 35 | auth: AuthUser, |
| 34 | Query(q): Query<SettingsQuery>, | 36 | Query(q): Query<SettingsQuery>, |
| 35 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { | 37 | ) -> Result<HtmlTemplate<SettingsPage>, AppError> { |
| 36 | - let notice = match q.ok.as_deref() { | 38 | + let toast = match q.ok.as_deref() { |
| 37 | - Some("mdp") => Some("Mot de passe modifié."), | 39 | + Some("mdp") => Some("Mot de passe mis à jour".to_string()), |
| 38 | - Some("tache") => Some("Tâche enregistrée."), | ||
| 39 | _ => None, | 40 | _ => None, |
| 40 | }; | 41 | }; |
| 41 | let error = match q.err.as_deref() { | 42 | let error = match q.err.as_deref() { |
| 42 | - Some("mdp") => Some("Ancien mot de passe incorrect."), | 43 | + Some("mdp") => Some("L'ancien mot de passe est incorrect."), |
| 43 | Some("confirm") => Some("La confirmation ne correspond pas."), | 44 | Some("confirm") => Some("La confirmation ne correspond pas."), |
| 44 | Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."), | 45 | Some("court") => Some("Le nouveau mot de passe doit faire au moins 8 caractères."), |
| 45 | - Some("doublon") => Some("Une tâche porte déjà ce nom."), | ||
| 46 | - Some("vide") => Some("Le nom de la tâche ne peut pas être vide."), | ||
| 47 | _ => None, | 46 | _ => None, |
| 48 | }; | 47 | }; |
| 49 | Ok(HtmlTemplate(SettingsPage { | 48 | Ok(HtmlTemplate(SettingsPage { |
| 50 | - tasks: tasks::list_all(&state.pool).await?, | 49 | + shell: shell(&state, &auth, "Paramètres", "settings", toast).await?, |
| 51 | csrf: auth.csrf_token(&state), | 50 | csrf: auth.csrf_token(&state), |
| 52 | - notice, | ||
| 53 | error, | 51 | error, |
| 54 | })) | 52 | })) |
| 55 | } | 53 | } |
| 56 | 54 | ||
| 55 | +/// ─── Tâches ─── | ||
| 56 | + | ||
| 57 | +pub struct TaskView { | ||
| 58 | + pub color: &'static str, | ||
| 59 | + pub name: String, | ||
| 60 | + pub archived: bool, | ||
| 61 | + pub rename_action: String, | ||
| 62 | + pub toggle_action: String, | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +#[derive(Template)] | ||
| 66 | +#[template(path = "tasks.html")] | ||
| 67 | +pub struct TasksPage { | ||
| 68 | + pub shell: Shell, | ||
| 69 | + pub csrf: String, | ||
| 70 | + pub tasks: Vec<TaskView>, | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +pub async fn tasks_page( | ||
| 74 | + State(state): State<AppState>, | ||
| 75 | + auth: AuthUser, | ||
| 76 | + Query(q): Query<SettingsQuery>, | ||
| 77 | +) -> Result<HtmlTemplate<TasksPage>, AppError> { | ||
| 78 | + let toast = match (q.ok.as_deref(), q.err.as_deref()) { | ||
| 79 | + (Some("ajoutee"), _) => Some("Tâche ajoutée".to_string()), | ||
| 80 | + (Some("renommee"), _) => Some("Tâche renommée".to_string()), | ||
| 81 | + (Some("archivee"), _) => { | ||
| 82 | + Some("Tâche archivée — les temps restent dans les stats".to_string()) | ||
| 83 | + } | ||
| 84 | + (Some("restauree"), _) => Some("Tâche restaurée".to_string()), | ||
| 85 | + (_, Some("doublon")) => Some("Une tâche porte déjà ce nom".to_string()), | ||
| 86 | + (_, Some("vide")) => Some("Le nom ne peut pas être vide".to_string()), | ||
| 87 | + _ => None, | ||
| 88 | + }; | ||
| 89 | + let by_creation = tasks::list_by_creation(&state.pool).await?; | ||
| 90 | + let colors = color_map(&by_creation.iter().map(|t| t.id).collect::<Vec<_>>()); | ||
| 91 | + let mut list = by_creation; | ||
| 92 | + list.sort_by(|a, b| (a.archived, &a.name).cmp(&(b.archived, &b.name))); | ||
| 93 | + let tasks = list | ||
| 94 | + .into_iter() | ||
| 95 | + .map(|t| TaskView { | ||
| 96 | + color: colors[&t.id], | ||
| 97 | + rename_action: format!("/parametres/taches/{}", t.id), | ||
| 98 | + toggle_action: format!("/parametres/taches/{}/archiver", t.id), | ||
| 99 | + name: t.name, | ||
| 100 | + archived: t.archived, | ||
| 101 | + }) | ||
| 102 | + .collect(); | ||
| 103 | + Ok(HtmlTemplate(TasksPage { | ||
| 104 | + shell: shell(&state, &auth, "Tâches", "settings", toast).await?, | ||
| 105 | + csrf: auth.csrf_token(&state), | ||
| 106 | + tasks, | ||
| 107 | + })) | ||
| 108 | +} | ||
| 109 | + | ||
| 57 | #[derive(Deserialize)] | 110 | #[derive(Deserialize)] |
| 58 | pub struct TaskForm { | 111 | pub struct TaskForm { |
| 59 | name: String, | 112 | name: String, |
| @@ -74,11 +127,11 @@ pub async fn task_create( | |||
| 74 | } | 127 | } |
| 75 | let name = form.name.trim(); | 128 | let name = form.name.trim(); |
| 76 | if name.is_empty() { | 129 | if name.is_empty() { |
| 77 | - return Ok(Redirect::to("/parametres?err=vide")); | 130 | + return Ok(Redirect::to("/parametres/taches?err=vide")); |
| 78 | } | 131 | } |
| 79 | match tasks::create(&state.pool, name).await { | 132 | match tasks::create(&state.pool, name).await { |
| 80 | - Ok(_) => Ok(Redirect::to("/parametres?ok=tache")), | 133 | + Ok(_) => Ok(Redirect::to("/parametres/taches?ok=ajoutee")), |
| 81 | - Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=doublon")), | 134 | + Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")), |
| 82 | Err(e) => Err(e.into()), | 135 | Err(e) => Err(e.into()), |
| 83 | } | 136 | } |
| 84 | } | 137 | } |
| @@ -94,26 +147,28 @@ pub async fn task_rename( | |||
| 94 | } | 147 | } |
| 95 | let name = form.name.trim(); | 148 | let name = form.name.trim(); |
| 96 | if name.is_empty() { | 149 | if name.is_empty() { |
| 97 | - return Ok(Redirect::to("/parametres?err=vide")); | 150 | + return Ok(Redirect::to("/parametres/taches?err=vide")); |
| 98 | } | 151 | } |
| 99 | match tasks::rename(&state.pool, id, name).await { | 152 | match tasks::rename(&state.pool, id, name).await { |
| 100 | Ok(0) => Err(AppError::NotFound), | 153 | Ok(0) => Err(AppError::NotFound), |
| 101 | - Ok(_) => Ok(Redirect::to("/parametres?ok=tache")), | 154 | + Ok(_) => Ok(Redirect::to("/parametres/taches?ok=renommee")), |
| 102 | - Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=doublon")), | 155 | + Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres/taches?err=doublon")), |
| 103 | Err(e) => Err(e.into()), | 156 | Err(e) => Err(e.into()), |
| 104 | } | 157 | } |
| 105 | } | 158 | } |
| 106 | 159 | ||
| 107 | #[derive(Deserialize)] | 160 | #[derive(Deserialize)] |
| 108 | -pub struct CsrfOnly { | 161 | +pub struct ToggleForm { |
| 109 | csrf: String, | 162 | csrf: String, |
| 163 | + /// true si la tâche était archivée (pour le message du toast). | ||
| 164 | + restaure: Option<u8>, | ||
| 110 | } | 165 | } |
| 111 | 166 | ||
| 112 | pub async fn task_toggle_archive( | 167 | pub async fn task_toggle_archive( |
| 113 | State(state): State<AppState>, | 168 | State(state): State<AppState>, |
| 114 | auth: AuthUser, | 169 | auth: AuthUser, |
| 115 | Path(id): Path<Uuid>, | 170 | Path(id): Path<Uuid>, |
| 116 | - Form(form): Form<CsrfOnly>, | 171 | + Form(form): Form<ToggleForm>, |
| 117 | ) -> Result<Redirect, AppError> { | 172 | ) -> Result<Redirect, AppError> { |
| 118 | if !auth.check_csrf(&state, &form.csrf) { | 173 | if !auth.check_csrf(&state, &form.csrf) { |
| 119 | return Err(AppError::Csrf); | 174 | return Err(AppError::Csrf); |
| @@ -121,7 +176,11 @@ pub async fn task_toggle_archive( | |||
| 121 | if tasks::toggle_archived(&state.pool, id).await? == 0 { | 176 | if tasks::toggle_archived(&state.pool, id).await? == 0 { |
| 122 | return Err(AppError::NotFound); | 177 | return Err(AppError::NotFound); |
| 123 | } | 178 | } |
| 124 | - Ok(Redirect::to("/parametres?ok=tache")) | 179 | + Ok(Redirect::to(if form.restaure.is_some() { |
| 180 | + "/parametres/taches?ok=restauree" | ||
| 181 | + } else { | ||
| 182 | + "/parametres/taches?ok=archivee" | ||
| 183 | + })) | ||
| 125 | } | 184 | } |
| 126 | 185 | ||
| 127 | #[derive(Deserialize)] | 186 | #[derive(Deserialize)] |
modified
src/routes/stats.rs +50 -22 | @@ -3,24 +3,21 @@ use axum::extract::{Query, State}; | ||
| 3 | 3 | use serde::Deserialize; |
| 4 | 4 | |
| 5 | 5 | use crate::auth::extractor::AuthUser; |
| 6 | -use crate::charts::{ChartView, LegendItem, legend}; | |
| 7 | -use crate::db::{stats, users}; | |
| 6 | +use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend}; | |
| 7 | +use crate::db::{stats, tasks, users}; | |
| 8 | 8 | use crate::error::AppError; |
| 9 | 9 | use crate::period::{Month, Week}; |
| 10 | -use crate::render::HtmlTemplate; | |
| 10 | +use crate::render::{HtmlTemplate, Shell}; | |
| 11 | +use crate::routes::shell; | |
| 11 | 12 | use crate::state::AppState; |
| 12 | 13 | |
| 13 | 14 | #[derive(Template)] |
| 14 | 15 | #[template(path = "stats.html")] |
| 15 | 16 | pub struct StatsPage { |
| 16 | - pub week_chart: ChartView, | |
| 17 | - pub month_chart: ChartView, | |
| 18 | - pub total_chart: ChartView, | |
| 17 | + pub shell: Shell, | |
| 19 | 18 | pub legend: Vec<LegendItem>, |
| 20 | - pub week_prev: String, | |
| 21 | - pub week_next: Option<String>, | |
| 22 | - pub month_prev: String, | |
| 23 | - pub month_next: Option<String>, | |
| 19 | + pub charts: Vec<ChartView>, | |
| 20 | + pub sheets: Vec<Sheet>, | |
| 24 | 21 | } |
| 25 | 22 | |
| 26 | 23 | #[derive(Deserialize)] |
| @@ -35,7 +32,7 @@ fn href(week: Week, month: Month) -> String { | ||
| 35 | 32 | |
| 36 | 33 | pub async fn stats_page( |
| 37 | 34 | State(state): State<AppState>, |
| 38 | - _auth: AuthUser, | |
| 35 | + auth: AuthUser, | |
| 39 | 36 | Query(q): Query<StatsQuery>, |
| 40 | 37 | ) -> Result<HtmlTemplate<StatsPage>, AppError> { |
| 41 | 38 | let week = q |
| @@ -53,24 +50,55 @@ pub async fn stats_page( | ||
| 53 | 50 | let (month_from, month_to) = month.bounds(); |
| 54 | 51 | |
| 55 | 52 | let all_users = users::list_all(&state.pool).await?; |
| 53 | + let all_tasks = tasks::list_by_creation(&state.pool).await?; | |
| 54 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | |
| 55 | + | |
| 56 | 56 | let week_rows = stats::totals(&state.pool, Some(week_from), Some(week_to)).await?; |
| 57 | 57 | let month_rows = stats::totals(&state.pool, Some(month_from), Some(month_to)).await?; |
| 58 | 58 | let total_rows = stats::totals(&state.pool, None, None).await?; |
| 59 | 59 | |
| 60 | - let legend = legend(&[&week_rows, &month_rows, &total_rows]); | |
| 60 | + let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); | |
| 61 | 61 | |
| 62 | - // Les flèches « suivant » disparaissent une fois revenu à la période courante. | |
| 63 | - let week_next = (week != Week::current()).then(|| href(week.next(), month)); | |
| 64 | - let month_next = (month != Month::current()).then(|| href(week, month.next())); | |
| 62 | + let (week_chart, mut sheets) = build_chart( | |
| 63 | + "sem", | |
| 64 | + "Cette semaine", | |
| 65 | + &week.range_label(), | |
| 66 | + &all_users, | |
| 67 | + &week_rows, | |
| 68 | + &colors, | |
| 69 | + Some(ChartNav { | |
| 70 | + prev: href(week.prev(), month), | |
| 71 | + next: (week != Week::current()).then(|| href(week.next(), month)), | |
| 72 | + }), | |
| 73 | + ); | |
| 74 | + let (month_chart, s2) = build_chart( | |
| 75 | + "mois", | |
| 76 | + "Ce mois-ci", | |
| 77 | + &month.label(), | |
| 78 | + &all_users, | |
| 79 | + &month_rows, | |
| 80 | + &colors, | |
| 81 | + Some(ChartNav { | |
| 82 | + prev: href(week, month.prev()), | |
| 83 | + next: (month != Month::current()).then(|| href(week, month.next())), | |
| 84 | + }), | |
| 85 | + ); | |
| 86 | + let (total_chart, s3) = build_chart( | |
| 87 | + "tot", | |
| 88 | + "Depuis le début", | |
| 89 | + "Tout l'historique", | |
| 90 | + &all_users, | |
| 91 | + &total_rows, | |
| 92 | + &colors, | |
| 93 | + None, | |
| 94 | + ); | |
| 95 | + sheets.extend(s2); | |
| 96 | + sheets.extend(s3); | |
| 65 | 97 | |
| 66 | 98 | Ok(HtmlTemplate(StatsPage { |
| 67 | - week_chart: ChartView::build(week.label(), &all_users, &week_rows), | |
| 68 | - month_chart: ChartView::build(month.label(), &all_users, &month_rows), | |
| 69 | - total_chart: ChartView::build("Depuis le début".into(), &all_users, &total_rows), | |
| 99 | + shell: shell(&state, &auth, "Statistiques", "stats", None).await?, | |
| 70 | 100 | legend, |
| 71 | - week_prev: href(week.prev(), month), | |
| 72 | - week_next, | |
| 73 | - month_prev: href(week, month.prev()), | |
| 74 | - month_next, | |
| 101 | + charts: vec![week_chart, month_chart, total_chart], | |
| 102 | + sheets, | |
| 75 | 103 | })) |
| 76 | 104 | } |
| @@ -3,24 +3,21 @@ use axum::extract::{Query, State}; | |||
| 3 | use serde::Deserialize; | 3 | use serde::Deserialize; |
| 4 | 4 | ||
| 5 | use crate::auth::extractor::AuthUser; | 5 | use crate::auth::extractor::AuthUser; |
| 6 | -use crate::charts::{ChartView, LegendItem, legend}; | 6 | +use crate::charts::{ChartNav, ChartView, LegendItem, Sheet, build_chart, color_map, legend}; |
| 7 | -use crate::db::{stats, users}; | 7 | +use crate::db::{stats, tasks, users}; |
| 8 | use crate::error::AppError; | 8 | use crate::error::AppError; |
| 9 | use crate::period::{Month, Week}; | 9 | use crate::period::{Month, Week}; |
| 10 | -use crate::render::HtmlTemplate; | 10 | +use crate::render::{HtmlTemplate, Shell}; |
| 11 | +use crate::routes::shell; | ||
| 11 | use crate::state::AppState; | 12 | use crate::state::AppState; |
| 12 | 13 | ||
| 13 | #[derive(Template)] | 14 | #[derive(Template)] |
| 14 | #[template(path = "stats.html")] | 15 | #[template(path = "stats.html")] |
| 15 | pub struct StatsPage { | 16 | pub struct StatsPage { |
| 16 | - pub week_chart: ChartView, | 17 | + pub shell: Shell, |
| 17 | - pub month_chart: ChartView, | ||
| 18 | - pub total_chart: ChartView, | ||
| 19 | pub legend: Vec<LegendItem>, | 18 | pub legend: Vec<LegendItem>, |
| 20 | - pub week_prev: String, | 19 | + pub charts: Vec<ChartView>, |
| 21 | - pub week_next: Option<String>, | 20 | + pub sheets: Vec<Sheet>, |
| 22 | - pub month_prev: String, | ||
| 23 | - pub month_next: Option<String>, | ||
| 24 | } | 21 | } |
| 25 | 22 | ||
| 26 | #[derive(Deserialize)] | 23 | #[derive(Deserialize)] |
| @@ -35,7 +32,7 @@ fn href(week: Week, month: Month) -> String { | |||
| 35 | 32 | ||
| 36 | pub async fn stats_page( | 33 | pub async fn stats_page( |
| 37 | State(state): State<AppState>, | 34 | State(state): State<AppState>, |
| 38 | - _auth: AuthUser, | 35 | + auth: AuthUser, |
| 39 | Query(q): Query<StatsQuery>, | 36 | Query(q): Query<StatsQuery>, |
| 40 | ) -> Result<HtmlTemplate<StatsPage>, AppError> { | 37 | ) -> Result<HtmlTemplate<StatsPage>, AppError> { |
| 41 | let week = q | 38 | let week = q |
| @@ -53,24 +50,55 @@ pub async fn stats_page( | |||
| 53 | let (month_from, month_to) = month.bounds(); | 50 | let (month_from, month_to) = month.bounds(); |
| 54 | 51 | ||
| 55 | let all_users = users::list_all(&state.pool).await?; | 52 | let all_users = users::list_all(&state.pool).await?; |
| 53 | + let all_tasks = tasks::list_by_creation(&state.pool).await?; | ||
| 54 | + let colors = color_map(&all_tasks.iter().map(|t| t.id).collect::<Vec<_>>()); | ||
| 55 | + | ||
| 56 | let week_rows = stats::totals(&state.pool, Some(week_from), Some(week_to)).await?; | 56 | let week_rows = stats::totals(&state.pool, Some(week_from), Some(week_to)).await?; |
| 57 | let month_rows = stats::totals(&state.pool, Some(month_from), Some(month_to)).await?; | 57 | let month_rows = stats::totals(&state.pool, Some(month_from), Some(month_to)).await?; |
| 58 | let total_rows = stats::totals(&state.pool, None, None).await?; | 58 | let total_rows = stats::totals(&state.pool, None, None).await?; |
| 59 | 59 | ||
| 60 | - let legend = legend(&[&week_rows, &month_rows, &total_rows]); | 60 | + let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors); |
| 61 | 61 | ||
| 62 | - // Les flèches « suivant » disparaissent une fois revenu à la période courante. | 62 | + let (week_chart, mut sheets) = build_chart( |
| 63 | - let week_next = (week != Week::current()).then(|| href(week.next(), month)); | 63 | + "sem", |
| 64 | - let month_next = (month != Month::current()).then(|| href(week, month.next())); | 64 | + "Cette semaine", |
| 65 | + &week.range_label(), | ||
| 66 | + &all_users, | ||
| 67 | + &week_rows, | ||
| 68 | + &colors, | ||
| 69 | + Some(ChartNav { | ||
| 70 | + prev: href(week.prev(), month), | ||
| 71 | + next: (week != Week::current()).then(|| href(week.next(), month)), | ||
| 72 | + }), | ||
| 73 | + ); | ||
| 74 | + let (month_chart, s2) = build_chart( | ||
| 75 | + "mois", | ||
| 76 | + "Ce mois-ci", | ||
| 77 | + &month.label(), | ||
| 78 | + &all_users, | ||
| 79 | + &month_rows, | ||
| 80 | + &colors, | ||
| 81 | + Some(ChartNav { | ||
| 82 | + prev: href(week, month.prev()), | ||
| 83 | + next: (month != Month::current()).then(|| href(week, month.next())), | ||
| 84 | + }), | ||
| 85 | + ); | ||
| 86 | + let (total_chart, s3) = build_chart( | ||
| 87 | + "tot", | ||
| 88 | + "Depuis le début", | ||
| 89 | + "Tout l'historique", | ||
| 90 | + &all_users, | ||
| 91 | + &total_rows, | ||
| 92 | + &colors, | ||
| 93 | + None, | ||
| 94 | + ); | ||
| 95 | + sheets.extend(s2); | ||
| 96 | + sheets.extend(s3); | ||
| 65 | 97 | ||
| 66 | Ok(HtmlTemplate(StatsPage { | 98 | Ok(HtmlTemplate(StatsPage { |
| 67 | - week_chart: ChartView::build(week.label(), &all_users, &week_rows), | 99 | + shell: shell(&state, &auth, "Statistiques", "stats", None).await?, |
| 68 | - month_chart: ChartView::build(month.label(), &all_users, &month_rows), | ||
| 69 | - total_chart: ChartView::build("Depuis le début".into(), &all_users, &total_rows), | ||
| 70 | legend, | 100 | legend, |
| 71 | - week_prev: href(week.prev(), month), | 101 | + charts: vec![week_chart, month_chart, total_chart], |
| 72 | - week_next, | 102 | + sheets, |
| 73 | - month_prev: href(week, month.prev()), | ||
| 74 | - month_next, | ||
| 75 | })) | 103 | })) |
| 76 | } | 104 | } |
added
templates/add_step1.html +18 -0 | new file mode 100644 | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +{% extends "app.html" %} | |
| 2 | +{% block content %} | |
| 3 | +{% if tasks.is_empty() %} | |
| 4 | +<p class="step-hint">Aucune tâche pour l'instant — commence par en créer une.</p> | |
| 5 | +{% else %} | |
| 6 | +<p class="step-hint">Étape 1 sur 2 — choisis la tâche</p> | |
| 7 | +<div class="task-pick"> | |
| 8 | + {% for t in tasks %} | |
| 9 | + <a href="{{ t.href }}"> | |
| 10 | + <span class="dot dot-14" style="background:{{ t.color }}"></span> | |
| 11 | + <span class="tname">{{ t.name }}</span> | |
| 12 | + <span class="tweek">{{ t.week_label }}</span> | |
| 13 | + </a> | |
| 14 | + {% endfor %} | |
| 15 | +</div> | |
| 16 | +{% endif %} | |
| 17 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres/taches">Gérer les tâches</a> | |
| 18 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | +{% extends "app.html" %} | ||
| 2 | +{% block content %} | ||
| 3 | +{% if tasks.is_empty() %} | ||
| 4 | +<p class="step-hint">Aucune tâche pour l'instant — commence par en créer une.</p> | ||
| 5 | +{% else %} | ||
| 6 | +<p class="step-hint">Étape 1 sur 2 — choisis la tâche</p> | ||
| 7 | +<div class="task-pick"> | ||
| 8 | + {% for t in tasks %} | ||
| 9 | + <a href="{{ t.href }}"> | ||
| 10 | + <span class="dot dot-14" style="background:{{ t.color }}"></span> | ||
| 11 | + <span class="tname">{{ t.name }}</span> | ||
| 12 | + <span class="tweek">{{ t.week_label }}</span> | ||
| 13 | + </a> | ||
| 14 | + {% endfor %} | ||
| 15 | +</div> | ||
| 16 | +{% endif %} | ||
| 17 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres/taches">Gérer les tâches</a> | ||
| 18 | +{% endblock %} | ||
added
templates/add_step2.html +46 -0 | new file mode 100644 | ||
| @@ -0,0 +1,46 @@ | ||
| 1 | +{% extends "app.html" %} | |
| 2 | +{% block content %} | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps/nouveau">← Changer de tâche</a> | |
| 4 | +<div class="picked"> | |
| 5 | + <span class="dot dot-16" style="background:{{ picked_color }}"></span> | |
| 6 | + <span class="pname">{{ picked_name }}</span> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<form method="post" action="/temps"> | |
| 10 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 11 | + <input type="hidden" name="tache" value="{{ tache }}"> | |
| 12 | + | |
| 13 | + <p class="mini-label">Durée</p> | |
| 14 | + <div class="duration-row"> | |
| 15 | + <input class="input minutes-input" name="minutes" type="number" inputmode="numeric" | |
| 16 | + min="1" max="1440" required value="{{ minutes }}"> | |
| 17 | + <span class="unit">minutes</span> | |
| 18 | + <span class="pretty">{{ minutes_pretty }}</span> | |
| 19 | + </div> | |
| 20 | + <div class="presets"> | |
| 21 | + {% for p in presets %} | |
| 22 | + <a class="preset" href="{{ p.href }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | |
| 23 | + {% endfor %} | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <p class="mini-label">Date</p> | |
| 27 | + <div class="date-row"> | |
| 28 | + <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="Jour précédent"> | |
| 29 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | |
| 30 | + </a> | |
| 31 | + <input class="input" name="date" type="date" value="{{ date }}" required> | |
| 32 | + {% if let Some(next) = day_next %} | |
| 33 | + <a class="btn btn-secondary btn-icon" href="{{ next }}" aria-label="Jour suivant"> | |
| 34 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | |
| 35 | + </a> | |
| 36 | + {% else %} | |
| 37 | + <span class="btn btn-secondary btn-icon" aria-disabled="true"> | |
| 38 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | |
| 39 | + </span> | |
| 40 | + {% endif %} | |
| 41 | + </div> | |
| 42 | + <p class="date-label">{{ date_label }}</p> | |
| 43 | + | |
| 44 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">Ajouter du temps</button> | |
| 45 | +</form> | |
| 46 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | +{% extends "app.html" %} | ||
| 2 | +{% block content %} | ||
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps/nouveau">← Changer de tâche</a> | ||
| 4 | +<div class="picked"> | ||
| 5 | + <span class="dot dot-16" style="background:{{ picked_color }}"></span> | ||
| 6 | + <span class="pname">{{ picked_name }}</span> | ||
| 7 | +</div> | ||
| 8 | + | ||
| 9 | +<form method="post" action="/temps"> | ||
| 10 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 11 | + <input type="hidden" name="tache" value="{{ tache }}"> | ||
| 12 | + | ||
| 13 | + <p class="mini-label">Durée</p> | ||
| 14 | + <div class="duration-row"> | ||
| 15 | + <input class="input minutes-input" name="minutes" type="number" inputmode="numeric" | ||
| 16 | + min="1" max="1440" required value="{{ minutes }}"> | ||
| 17 | + <span class="unit">minutes</span> | ||
| 18 | + <span class="pretty">{{ minutes_pretty }}</span> | ||
| 19 | + </div> | ||
| 20 | + <div class="presets"> | ||
| 21 | + {% for p in presets %} | ||
| 22 | + <a class="preset" href="{{ p.href }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a> | ||
| 23 | + {% endfor %} | ||
| 24 | + </div> | ||
| 25 | + | ||
| 26 | + <p class="mini-label">Date</p> | ||
| 27 | + <div class="date-row"> | ||
| 28 | + <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="Jour précédent"> | ||
| 29 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | ||
| 30 | + </a> | ||
| 31 | + <input class="input" name="date" type="date" value="{{ date }}" required> | ||
| 32 | + {% if let Some(next) = day_next %} | ||
| 33 | + <a class="btn btn-secondary btn-icon" href="{{ next }}" aria-label="Jour suivant"> | ||
| 34 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | ||
| 35 | + </a> | ||
| 36 | + {% else %} | ||
| 37 | + <span class="btn btn-secondary btn-icon" aria-disabled="true"> | ||
| 38 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | ||
| 39 | + </span> | ||
| 40 | + {% endif %} | ||
| 41 | + </div> | ||
| 42 | + <p class="date-label">{{ date_label }}</p> | ||
| 43 | + | ||
| 44 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">Ajouter du temps</button> | ||
| 45 | +</form> | ||
| 46 | +{% endblock %} | ||
added
templates/app.html +30 -0 | new file mode 100644 | ||
| @@ -0,0 +1,30 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ shell.title }} · time-time{% endblock %} | |
| 3 | +{% block body %} | |
| 4 | +<header class="app-header"> | |
| 5 | + <h1>{{ shell.title }}</h1> | |
| 6 | + <span class="who">{{ shell.username }}</span> | |
| 7 | +</header> | |
| 8 | +<main class="app-main"> | |
| 9 | +{% block content %}{% endblock %} | |
| 10 | +</main> | |
| 11 | +{% if let Some(t) = shell.toast %}<div class="toast">{{ t }}</div>{% endif %} | |
| 12 | +<nav class="tabbar"> | |
| 13 | + <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> | |
| 14 | + <svg width="21" height="21" 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> | |
| 15 | + <span>Ajouter</span> | |
| 16 | + </a> | |
| 17 | + <a class="tab" href="/stats" {% if shell.tab == "stats" %}aria-current="page"{% endif %}> | |
| 18 | + <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg> | |
| 19 | + <span>Stats</span> | |
| 20 | + </a> | |
| 21 | + <a class="tab" href="/temps" {% if shell.tab == "times" %}aria-current="page"{% endif %}> | |
| 22 | + <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path></svg> | |
| 23 | + <span>Mes temps</span> | |
| 24 | + </a> | |
| 25 | + <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}> | |
| 26 | + <svg width="21" height="21" 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="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg> | |
| 27 | + <span>Réglages</span> | |
| 28 | + </a> | |
| 29 | +</nav> | |
| 30 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,30 @@ | |||
| 1 | +{% extends "base.html" %} | ||
| 2 | +{% block title %}{{ shell.title }} · time-time{% endblock %} | ||
| 3 | +{% block body %} | ||
| 4 | +<header class="app-header"> | ||
| 5 | + <h1>{{ shell.title }}</h1> | ||
| 6 | + <span class="who">{{ shell.username }}</span> | ||
| 7 | +</header> | ||
| 8 | +<main class="app-main"> | ||
| 9 | +{% block content %}{% endblock %} | ||
| 10 | +</main> | ||
| 11 | +{% if let Some(t) = shell.toast %}<div class="toast">{{ t }}</div>{% endif %} | ||
| 12 | +<nav class="tabbar"> | ||
| 13 | + <a class="tab" href="/temps/nouveau" {% if shell.tab == "add" %}aria-current="page"{% endif %}> | ||
| 14 | + <svg width="21" height="21" 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> | ||
| 15 | + <span>Ajouter</span> | ||
| 16 | + </a> | ||
| 17 | + <a class="tab" href="/stats" {% if shell.tab == "stats" %}aria-current="page"{% endif %}> | ||
| 18 | + <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V10M12 20V4M19 20v-6"></path></svg> | ||
| 19 | + <span>Stats</span> | ||
| 20 | + </a> | ||
| 21 | + <a class="tab" href="/temps" {% if shell.tab == "times" %}aria-current="page"{% endif %}> | ||
| 22 | + <svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"></path></svg> | ||
| 23 | + <span>Mes temps</span> | ||
| 24 | + </a> | ||
| 25 | + <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}> | ||
| 26 | + <svg width="21" height="21" 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="3"></circle><path d="M19.4 15a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 7 19.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.6 1.6 0 0 0 3 14.9a1.6 1.6 0 0 0-1.5-1H1a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 2.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.6 1.6 0 0 0 7 3.7h.1A1.6 1.6 0 0 0 8.1 2.2V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1"></path></svg> | ||
| 27 | + <span>Réglages</span> | ||
| 28 | + </a> | ||
| 29 | +</nav> | ||
| 30 | +{% endblock %} | ||
modified
templates/base.html +6 -5 | @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | -<meta name="theme-color" content="#1d3557"> | |
| 6 | +<meta name="theme-color" content="#f5ead8"> | |
| 7 | 7 | <link rel="manifest" href="/manifest.json"> |
| 8 | 8 | <link rel="icon" href="/icon-512.png"> |
| 9 | 9 | <link rel="apple-touch-icon" href="/icon-512.png"> |
| @@ -11,9 +11,10 @@ | ||
| 11 | 11 | <title>{% block title %}time-time{% endblock %}</title> |
| 12 | 12 | </head> |
| 13 | 13 | <body> |
| 14 | -<header class="topbar"><a href="/" class="brand">⏱ time-time</a></header> | |
| 15 | -<main class="page"> | |
| 16 | -{% block content %}{% endblock %} | |
| 17 | -</main> | |
| 14 | +<div class="viewport"> | |
| 15 | +<div class="shell"> | |
| 16 | +{% block body %}{% endblock %} | |
| 17 | +</div> | |
| 18 | +</div> | |
| 18 | 19 | </body> |
| 19 | 20 | </html> |
| @@ -3,7 +3,7 @@ | |||
| 3 | <head> | 3 | <head> |
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | -<meta name="theme-color" content="#1d3557"> | 6 | +<meta name="theme-color" content="#f5ead8"> |
| 7 | <link rel="manifest" href="/manifest.json"> | 7 | <link rel="manifest" href="/manifest.json"> |
| 8 | <link rel="icon" href="/icon-512.png"> | 8 | <link rel="icon" href="/icon-512.png"> |
| 9 | <link rel="apple-touch-icon" href="/icon-512.png"> | 9 | <link rel="apple-touch-icon" href="/icon-512.png"> |
| @@ -11,9 +11,10 @@ | |||
| 11 | <title>{% block title %}time-time{% endblock %}</title> | 11 | <title>{% block title %}time-time{% endblock %}</title> |
| 12 | </head> | 12 | </head> |
| 13 | <body> | 13 | <body> |
| 14 | -<header class="topbar"><a href="/" class="brand">⏱ time-time</a></header> | 14 | +<div class="viewport"> |
| 15 | -<main class="page"> | 15 | +<div class="shell"> |
| 16 | -{% block content %}{% endblock %} | 16 | +{% block body %}{% endblock %} |
| 17 | -</main> | 17 | +</div> |
| 18 | +</div> | ||
| 18 | </body> | 19 | </body> |
| 19 | </html> | 20 | </html> |
deleted
templates/chart.svg.html +0 -21 | deleted file mode 100644 | ||
| @@ -1,21 +0,0 @@ | ||
| 1 | -<svg viewBox="0 0 400 260" width="100%" class="chart" role="img" aria-label="{{ chart.title }}"> | |
| 2 | - {% for t in chart.y_ticks %} | |
| 3 | - <line x1="{{ chart.left() }}" x2="{{ chart.right() }}" y1="{{ t.y }}" y2="{{ t.y }}" class="gridline"/> | |
| 4 | - <text x="{{ chart.left() - 4.0 }}" y="{{ t.y + 3.0 }}" text-anchor="end" class="tick">{{ t.label }}</text> | |
| 5 | - {% endfor %} | |
| 6 | - <line x1="{{ chart.left() }}" x2="{{ chart.right() }}" y1="{{ chart.baseline }}" y2="{{ chart.baseline }}" class="axis"/> | |
| 7 | - {% for bar in chart.bars %} | |
| 8 | - {% for s in bar.segments %} | |
| 9 | - <rect x="{{ s.x }}" y="{{ s.y }}" width="{{ s.w }}" height="{{ s.h }}" fill="{{ s.color }}"> | |
| 10 | - <title>{{ s.title }}</title> | |
| 11 | - </rect> | |
| 12 | - {% endfor %} | |
| 13 | - <text x="{{ bar.label_x }}" y="248" text-anchor="middle" class="bar-label">{{ bar.user_label }}</text> | |
| 14 | - {% if !bar.total_label.is_empty() %} | |
| 15 | - <text x="{{ bar.label_x }}" y="{{ bar.total_y }}" text-anchor="middle" class="total-label">{{ bar.total_label }}</text> | |
| 16 | - {% endif %} | |
| 17 | - {% endfor %} | |
| 18 | - {% if chart.empty %} | |
| 19 | - <text x="218" y="130" text-anchor="middle" class="empty-label">Aucun temps sur cette période</text> | |
| 20 | - {% endif %} | |
| 21 | -</svg> | |
| deleted file mode 100644 | |||
| @@ -1,21 +0,0 @@ | |||
| 1 | -<svg viewBox="0 0 400 260" width="100%" class="chart" role="img" aria-label="{{ chart.title }}"> | ||
| 2 | - {% for t in chart.y_ticks %} | ||
| 3 | - <line x1="{{ chart.left() }}" x2="{{ chart.right() }}" y1="{{ t.y }}" y2="{{ t.y }}" class="gridline"/> | ||
| 4 | - <text x="{{ chart.left() - 4.0 }}" y="{{ t.y + 3.0 }}" text-anchor="end" class="tick">{{ t.label }}</text> | ||
| 5 | - {% endfor %} | ||
| 6 | - <line x1="{{ chart.left() }}" x2="{{ chart.right() }}" y1="{{ chart.baseline }}" y2="{{ chart.baseline }}" class="axis"/> | ||
| 7 | - {% for bar in chart.bars %} | ||
| 8 | - {% for s in bar.segments %} | ||
| 9 | - <rect x="{{ s.x }}" y="{{ s.y }}" width="{{ s.w }}" height="{{ s.h }}" fill="{{ s.color }}"> | ||
| 10 | - <title>{{ s.title }}</title> | ||
| 11 | - </rect> | ||
| 12 | - {% endfor %} | ||
| 13 | - <text x="{{ bar.label_x }}" y="248" text-anchor="middle" class="bar-label">{{ bar.user_label }}</text> | ||
| 14 | - {% if !bar.total_label.is_empty() %} | ||
| 15 | - <text x="{{ bar.label_x }}" y="{{ bar.total_y }}" text-anchor="middle" class="total-label">{{ bar.total_label }}</text> | ||
| 16 | - {% endif %} | ||
| 17 | - {% endfor %} | ||
| 18 | - {% if chart.empty %} | ||
| 19 | - <text x="218" y="130" text-anchor="middle" class="empty-label">Aucun temps sur cette période</text> | ||
| 20 | - {% endif %} | ||
| 21 | -</svg> | ||
added
templates/edit.html +31 -0 | new file mode 100644 | ||
| @@ -0,0 +1,31 @@ | ||
| 1 | +{% extends "app.html" %} | |
| 2 | +{% block content %} | |
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps">← Mes temps</a> | |
| 4 | +<form method="post" action="{{ action }}"> | |
| 5 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 6 | + <div class="field" style="margin-bottom:var(--space-3)"> | |
| 7 | + <label for="tache">Tâche</label> | |
| 8 | + <select class="input" id="tache" name="tache" required style="min-height:44px"> | |
| 9 | + {% for t in tasks %} | |
| 10 | + <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}>{{ t.name }}</option> | |
| 11 | + {% endfor %} | |
| 12 | + </select> | |
| 13 | + </div> | |
| 14 | + <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)"> | |
| 15 | + <div class="field" style="flex:1"> | |
| 16 | + <label for="minutes">Durée (min)</label> | |
| 17 | + <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" | |
| 18 | + min="1" max="1440" required value="{{ minutes }}" style="min-height:44px"> | |
| 19 | + </div> | |
| 20 | + <div class="field" style="flex:1.4"> | |
| 21 | + <label for="date">Date</label> | |
| 22 | + <input class="input" id="date" name="date" type="date" value="{{ date }}" required style="min-height:44px"> | |
| 23 | + </div> | |
| 24 | + </div> | |
| 25 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Enregistrer</button> | |
| 26 | +</form> | |
| 27 | +<form method="post" action="{{ delete_action }}" style="margin-top:var(--space-3)"> | |
| 28 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 29 | + <button type="submit" class="btn btn-secondary btn-block" style="color:var(--color-accent-700)">Supprimer</button> | |
| 30 | +</form> | |
| 31 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | +{% extends "app.html" %} | ||
| 2 | +{% block content %} | ||
| 3 | +<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0" href="/temps">← Mes temps</a> | ||
| 4 | +<form method="post" action="{{ action }}"> | ||
| 5 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 6 | + <div class="field" style="margin-bottom:var(--space-3)"> | ||
| 7 | + <label for="tache">Tâche</label> | ||
| 8 | + <select class="input" id="tache" name="tache" required style="min-height:44px"> | ||
| 9 | + {% for t in tasks %} | ||
| 10 | + <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}>{{ t.name }}</option> | ||
| 11 | + {% endfor %} | ||
| 12 | + </select> | ||
| 13 | + </div> | ||
| 14 | + <div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)"> | ||
| 15 | + <div class="field" style="flex:1"> | ||
| 16 | + <label for="minutes">Durée (min)</label> | ||
| 17 | + <input class="input" id="minutes" name="minutes" type="number" inputmode="numeric" | ||
| 18 | + min="1" max="1440" required value="{{ minutes }}" style="min-height:44px"> | ||
| 19 | + </div> | ||
| 20 | + <div class="field" style="flex:1.4"> | ||
| 21 | + <label for="date">Date</label> | ||
| 22 | + <input class="input" id="date" name="date" type="date" value="{{ date }}" required style="min-height:44px"> | ||
| 23 | + </div> | ||
| 24 | + </div> | ||
| 25 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Enregistrer</button> | ||
| 26 | +</form> | ||
| 27 | +<form method="post" action="{{ delete_action }}" style="margin-top:var(--space-3)"> | ||
| 28 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 29 | + <button type="submit" class="btn btn-secondary btn-block" style="color:var(--color-accent-700)">Supprimer</button> | ||
| 30 | +</form> | ||
| 31 | +{% endblock %} | ||
deleted
templates/entries_list.html +0 -25 | deleted file mode 100644 | ||
| @@ -1,25 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Mes temps · time-time{% endblock %} | |
| 3 | -{% block content %} | |
| 4 | -<h1>Mes temps</h1> | |
| 5 | -{% if entries.is_empty() %} | |
| 6 | -<p>Aucun temps enregistré{% if page > 1 %} sur cette page{% endif %}.</p> | |
| 7 | -{% else %} | |
| 8 | -<ul class="entries"> | |
| 9 | - {% for e in entries %} | |
| 10 | - <li> | |
| 11 | - <a class="entry" href="/temps/{{ e.id }}/modifier"> | |
| 12 | - <span class="entry-date">{{ e.date }}</span> | |
| 13 | - <span class="entry-task">{{ e.task_name }}</span> | |
| 14 | - <span class="entry-duration">{{ e.duration }}</span> | |
| 15 | - </a> | |
| 16 | - </li> | |
| 17 | - {% endfor %} | |
| 18 | -</ul> | |
| 19 | -{% endif %} | |
| 20 | -<nav class="pager"> | |
| 21 | - {% if page > 1 %}<a class="btn" href="/temps?page={{ page - 1 }}">← Plus récents</a>{% endif %} | |
| 22 | - {% if has_next %}<a class="btn" href="/temps?page={{ page + 1 }}">Plus anciens →</a>{% endif %} | |
| 23 | -</nav> | |
| 24 | -<p><a class="btn" href="/stats">← Statistiques</a></p> | |
| 25 | -{% endblock %} | |
| deleted file mode 100644 | |||
| @@ -1,25 +0,0 @@ | |||
| 1 | -{% extends "base.html" %} | ||
| 2 | -{% block title %}Mes temps · time-time{% endblock %} | ||
| 3 | -{% block content %} | ||
| 4 | -<h1>Mes temps</h1> | ||
| 5 | -{% if entries.is_empty() %} | ||
| 6 | -<p>Aucun temps enregistré{% if page > 1 %} sur cette page{% endif %}.</p> | ||
| 7 | -{% else %} | ||
| 8 | -<ul class="entries"> | ||
| 9 | - {% for e in entries %} | ||
| 10 | - <li> | ||
| 11 | - <a class="entry" href="/temps/{{ e.id }}/modifier"> | ||
| 12 | - <span class="entry-date">{{ e.date }}</span> | ||
| 13 | - <span class="entry-task">{{ e.task_name }}</span> | ||
| 14 | - <span class="entry-duration">{{ e.duration }}</span> | ||
| 15 | - </a> | ||
| 16 | - </li> | ||
| 17 | - {% endfor %} | ||
| 18 | -</ul> | ||
| 19 | -{% endif %} | ||
| 20 | -<nav class="pager"> | ||
| 21 | - {% if page > 1 %}<a class="btn" href="/temps?page={{ page - 1 }}">← Plus récents</a>{% endif %} | ||
| 22 | - {% if has_next %}<a class="btn" href="/temps?page={{ page + 1 }}">Plus anciens →</a>{% endif %} | ||
| 23 | -</nav> | ||
| 24 | -<p><a class="btn" href="/stats">← Statistiques</a></p> | ||
| 25 | -{% endblock %} | ||
deleted
templates/entry_form.html +0 -43 | deleted file mode 100644 | ||
| @@ -1,43 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}{{ title }} · time-time{% endblock %} | |
| 3 | -{% block content %} | |
| 4 | -<h1>{{ title }}</h1> | |
| 5 | -{% if saved %}<p class="flash flash-ok">Temps enregistré ✔</p>{% endif %} | |
| 6 | -{% if tasks.is_empty() %} | |
| 7 | -<p class="flash flash-error">Aucune tâche disponible — créez-en une dans <a href="/parametres">Paramètres</a>.</p> | |
| 8 | -{% else %} | |
| 9 | -<form method="post" action="{{ action }}" class="stack"> | |
| 10 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 11 | - | |
| 12 | - <label for="minutes">Durée (minutes)</label> | |
| 13 | - <input id="minutes" name="minutes" type="number" inputmode="numeric" | |
| 14 | - min="1" max="1440" required | |
| 15 | - {% if let Some(m) = minutes %}value="{{ m }}"{% endif %}> | |
| 16 | - {% if !presets.is_empty() %} | |
| 17 | - <div class="presets"> | |
| 18 | - {% for p in presets %}<a class="btn btn-preset" href="{{ p.href }}">{{ p.label }}</a>{% endfor %} | |
| 19 | - </div> | |
| 20 | - {% endif %} | |
| 21 | - | |
| 22 | - <label for="tache">Tâche</label> | |
| 23 | - <select id="tache" name="tache" required> | |
| 24 | - {% for t in tasks %} | |
| 25 | - <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}> | |
| 26 | - {{ t.name }}{% if t.archived %} (archivée){% endif %} | |
| 27 | - </option> | |
| 28 | - {% endfor %} | |
| 29 | - </select> | |
| 30 | - | |
| 31 | - <label for="date">Date</label> | |
| 32 | - <input id="date" name="date" type="date" value="{{ date }}" required> | |
| 33 | - | |
| 34 | - <button type="submit" class="btn btn-primary btn-big">Valider</button> | |
| 35 | -</form> | |
| 36 | -{% if let Some(del) = delete_action %} | |
| 37 | -<form method="post" action="{{ del }}" class="stack danger-zone"> | |
| 38 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 39 | - <button type="submit" class="btn btn-danger">Supprimer ce temps</button> | |
| 40 | -</form> | |
| 41 | -{% endif %} | |
| 42 | -{% endif %} | |
| 43 | -{% endblock %} | |
| deleted file mode 100644 | |||
| @@ -1,43 +0,0 @@ | |||
| 1 | -{% extends "base.html" %} | ||
| 2 | -{% block title %}{{ title }} · time-time{% endblock %} | ||
| 3 | -{% block content %} | ||
| 4 | -<h1>{{ title }}</h1> | ||
| 5 | -{% if saved %}<p class="flash flash-ok">Temps enregistré ✔</p>{% endif %} | ||
| 6 | -{% if tasks.is_empty() %} | ||
| 7 | -<p class="flash flash-error">Aucune tâche disponible — créez-en une dans <a href="/parametres">Paramètres</a>.</p> | ||
| 8 | -{% else %} | ||
| 9 | -<form method="post" action="{{ action }}" class="stack"> | ||
| 10 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 11 | - | ||
| 12 | - <label for="minutes">Durée (minutes)</label> | ||
| 13 | - <input id="minutes" name="minutes" type="number" inputmode="numeric" | ||
| 14 | - min="1" max="1440" required | ||
| 15 | - {% if let Some(m) = minutes %}value="{{ m }}"{% endif %}> | ||
| 16 | - {% if !presets.is_empty() %} | ||
| 17 | - <div class="presets"> | ||
| 18 | - {% for p in presets %}<a class="btn btn-preset" href="{{ p.href }}">{{ p.label }}</a>{% endfor %} | ||
| 19 | - </div> | ||
| 20 | - {% endif %} | ||
| 21 | - | ||
| 22 | - <label for="tache">Tâche</label> | ||
| 23 | - <select id="tache" name="tache" required> | ||
| 24 | - {% for t in tasks %} | ||
| 25 | - <option value="{{ t.id }}"{% if t.selected %} selected{% endif %}> | ||
| 26 | - {{ t.name }}{% if t.archived %} (archivée){% endif %} | ||
| 27 | - </option> | ||
| 28 | - {% endfor %} | ||
| 29 | - </select> | ||
| 30 | - | ||
| 31 | - <label for="date">Date</label> | ||
| 32 | - <input id="date" name="date" type="date" value="{{ date }}" required> | ||
| 33 | - | ||
| 34 | - <button type="submit" class="btn btn-primary btn-big">Valider</button> | ||
| 35 | -</form> | ||
| 36 | -{% if let Some(del) = delete_action %} | ||
| 37 | -<form method="post" action="{{ del }}" class="stack danger-zone"> | ||
| 38 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 39 | - <button type="submit" class="btn btn-danger">Supprimer ce temps</button> | ||
| 40 | -</form> | ||
| 41 | -{% endif %} | ||
| 42 | -{% endif %} | ||
| 43 | -{% endblock %} | ||
deleted
templates/home.html +0 -8 | deleted file mode 100644 | ||
| @@ -1,8 +0,0 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block content %} | |
| 3 | -<nav class="stack menu"> | |
| 4 | - <a class="btn btn-primary btn-big" href="/temps/nouveau">➕ Ajouter un temps</a> | |
| 5 | - <a class="btn btn-big" href="/stats">📊 Statistiques</a> | |
| 6 | - <a class="btn btn-big" href="/parametres">⚙️ Paramètres</a> | |
| 7 | -</nav> | |
| 8 | -{% endblock %} | |
| deleted file mode 100644 | |||
| @@ -1,8 +0,0 @@ | |||
| 1 | -{% extends "base.html" %} | ||
| 2 | -{% block content %} | ||
| 3 | -<nav class="stack menu"> | ||
| 4 | - <a class="btn btn-primary btn-big" href="/temps/nouveau">➕ Ajouter un temps</a> | ||
| 5 | - <a class="btn btn-big" href="/stats">📊 Statistiques</a> | ||
| 6 | - <a class="btn btn-big" href="/parametres">⚙️ Paramètres</a> | ||
| 7 | -</nav> | ||
| 8 | -{% endblock %} | ||
modified
templates/login.html +21 -11 | @@ -1,14 +1,24 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% block title %}Connexion · time-time{% endblock %} |
| 3 | -{% block content %} | |
| 4 | -<h1>Connexion</h1> | |
| 5 | -{% if error %}<p class="flash flash-error">Identifiants incorrects.</p>{% endif %} | |
| 6 | -<form method="post" action="/login" class="stack"> | |
| 7 | - <label for="username">Nom d'utilisateur</label> | |
| 8 | - <input id="username" name="username" type="text" autocomplete="username" | |
| 9 | - autocapitalize="none" autocorrect="off" required autofocus> | |
| 10 | - <label for="password">Mot de passe</label> | |
| 11 | - <input id="password" name="password" type="password" autocomplete="current-password" required> | |
| 12 | - <button type="submit" class="btn btn-primary">Se connecter</button> | |
| 13 | -</form> | |
| 3 | +{% block body %} | |
| 4 | +<div class="login"> | |
| 5 | + <div class="login-icon"> | |
| 6 | + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg> | |
| 7 | + </div> | |
| 8 | + <h1>time-time</h1> | |
| 9 | + <p class="tagline">Le temps passé sur chaque tâche, partagé entre tous.</p> | |
| 10 | + <form method="post" action="/login"> | |
| 11 | + <div class="field" style="margin-bottom:var(--space-3)"> | |
| 12 | + <label for="username">Identifiant</label> | |
| 13 | + <input class="input" id="username" name="username" autocomplete="username" | |
| 14 | + autocapitalize="none" autocorrect="off" required autofocus> | |
| 15 | + </div> | |
| 16 | + <div class="field" style="margin-bottom:var(--space-4)"> | |
| 17 | + <label for="password">Mot de passe</label> | |
| 18 | + <input class="input" id="password" name="password" type="password" autocomplete="current-password" required> | |
| 19 | + </div> | |
| 20 | + {% if error %}<p class="form-error">Identifiants incorrects.</p>{% endif %} | |
| 21 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:48px;font-size:16px">Se connecter</button> | |
| 22 | + </form> | |
| 23 | +</div> | |
| 14 | 24 | {% endblock %} |
| @@ -1,14 +1,24 @@ | |||
| 1 | {% extends "base.html" %} | 1 | {% extends "base.html" %} |
| 2 | {% block title %}Connexion · time-time{% endblock %} | 2 | {% block title %}Connexion · time-time{% endblock %} |
| 3 | -{% block content %} | 3 | +{% block body %} |
| 4 | -<h1>Connexion</h1> | 4 | +<div class="login"> |
| 5 | -{% if error %}<p class="flash flash-error">Identifiants incorrects.</p>{% endif %} | 5 | + <div class="login-icon"> |
| 6 | -<form method="post" action="/login" class="stack"> | 6 | + <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#f5ead8" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg> |
| 7 | - <label for="username">Nom d'utilisateur</label> | 7 | + </div> |
| 8 | - <input id="username" name="username" type="text" autocomplete="username" | 8 | + <h1>time-time</h1> |
| 9 | - autocapitalize="none" autocorrect="off" required autofocus> | 9 | + <p class="tagline">Le temps passé sur chaque tâche, partagé entre tous.</p> |
| 10 | - <label for="password">Mot de passe</label> | 10 | + <form method="post" action="/login"> |
| 11 | - <input id="password" name="password" type="password" autocomplete="current-password" required> | 11 | + <div class="field" style="margin-bottom:var(--space-3)"> |
| 12 | - <button type="submit" class="btn btn-primary">Se connecter</button> | 12 | + <label for="username">Identifiant</label> |
| 13 | -</form> | 13 | + <input class="input" id="username" name="username" autocomplete="username" |
| 14 | + autocapitalize="none" autocorrect="off" required autofocus> | ||
| 15 | + </div> | ||
| 16 | + <div class="field" style="margin-bottom:var(--space-4)"> | ||
| 17 | + <label for="password">Mot de passe</label> | ||
| 18 | + <input class="input" id="password" name="password" type="password" autocomplete="current-password" required> | ||
| 19 | + </div> | ||
| 20 | + {% if error %}<p class="form-error">Identifiants incorrects.</p>{% endif %} | ||
| 21 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:48px;font-size:16px">Se connecter</button> | ||
| 22 | + </form> | ||
| 23 | +</div> | ||
| 14 | {% endblock %} | 24 | {% endblock %} |
modified
templates/settings.html +27 -40 | @@ -1,50 +1,37 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Paramètres · time-time{% endblock %} | |
| 1 | +{% extends "app.html" %} | |
| 3 | 2 | {% block content %} |
| 4 | -<h1>Paramètres</h1> | |
| 5 | -{% if let Some(n) = notice %}<p class="flash flash-ok">{{ n }}</p>{% endif %} | |
| 6 | -{% if let Some(e) = error %}<p class="flash flash-error">{{ e }}</p>{% endif %} | |
| 3 | +<div class="card" style="margin-bottom:var(--space-4)"> | |
| 4 | + <div class="card-kicker">Compte</div> | |
| 5 | + <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div> | |
| 6 | + <p class="card-body">Les comptes sont créés en ligne de commande. Tu peux changer ton mot de passe ici.</p> | |
| 7 | +</div> | |
| 7 | 8 | |
| 8 | -<h2>Tâches</h2> | |
| 9 | -<ul class="task-list"> | |
| 10 | - {% for t in tasks %} | |
| 11 | - <li class="task {% if t.archived %}task-archived{% endif %}"> | |
| 12 | - <form method="post" action="/parametres/taches/{{ t.id }}" class="task-rename"> | |
| 13 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 14 | - <input name="name" value="{{ t.name }}" required aria-label="Nom de la tâche"> | |
| 15 | - <button type="submit" class="btn btn-small">Renommer</button> | |
| 16 | - </form> | |
| 17 | - <form method="post" action="/parametres/taches/{{ t.id }}/archiver"> | |
| 18 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 19 | - <button type="submit" class="btn btn-small"> | |
| 20 | - {% if t.archived %}Restaurer{% else %}Archiver{% endif %} | |
| 21 | - </button> | |
| 22 | - </form> | |
| 23 | - </li> | |
| 24 | - {% endfor %} | |
| 25 | -</ul> | |
| 26 | -<form method="post" action="/parametres/taches" class="task-add"> | |
| 9 | +<h4 style="margin:var(--space-6) 0 var(--space-3)">Changer de mot de passe</h4> | |
| 10 | +<form method="post" action="/parametres/mot-de-passe"> | |
| 27 | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 28 | - <input name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche"> | |
| 29 | - <button type="submit" class="btn btn-primary">Créer</button> | |
| 12 | + <div class="field" style="margin-bottom:var(--space-3)"> | |
| 13 | + <label for="ancien">Mot de passe actuel</label> | |
| 14 | + <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px"> | |
| 15 | + </div> | |
| 16 | + <div class="field" style="margin-bottom:var(--space-3)"> | |
| 17 | + <label for="nouveau">Nouveau mot de passe</label> | |
| 18 | + <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> | |
| 19 | + </div> | |
| 20 | + <div class="field" style="margin-bottom:var(--space-4)"> | |
| 21 | + <label for="confirmation">Confirmation</label> | |
| 22 | + <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> | |
| 23 | + </div> | |
| 24 | + {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | |
| 25 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Enregistrer</button> | |
| 30 | 26 | </form> |
| 31 | -<p class="hint">Archiver une tâche la retire du formulaire d'ajout ; les temps déjà saisis restent dans les statistiques.</p> | |
| 32 | 27 | |
| 33 | -<h2>Mot de passe</h2> | |
| 34 | -<form method="post" action="/parametres/mot-de-passe" class="stack"> | |
| 35 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 36 | - <label for="ancien">Ancien mot de passe</label> | |
| 37 | - <input id="ancien" name="ancien" type="password" autocomplete="current-password" required> | |
| 38 | - <label for="nouveau">Nouveau mot de passe</label> | |
| 39 | - <input id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8"> | |
| 40 | - <label for="confirmation">Confirmation</label> | |
| 41 | - <input id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8"> | |
| 42 | - <button type="submit" class="btn btn-primary">Changer le mot de passe</button> | |
| 43 | -</form> | |
| 28 | +<h4 style="margin:var(--space-8) 0 var(--space-3)">Tâches</h4> | |
| 29 | +<a class="btn btn-secondary btn-block" href="/parametres/taches" style="min-height:46px">Gérer les tâches</a> | |
| 44 | 30 | |
| 45 | -<h2>Session</h2> | |
| 46 | 31 | <form method="post" action="/logout"> |
| 47 | 32 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 48 | - <button type="submit" class="btn btn-danger">Se déconnecter</button> | |
| 33 | + <button type="submit" class="btn btn-block" | |
| 34 | + style="min-height:46px;margin-top:var(--space-8);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Se déconnecter</button> | |
| 49 | 35 | </form> |
| 36 | +<p class="text-muted" style="font-size:11px;margin-top:var(--space-3);text-align:center">Le biscuit de session sera révoqué.</p> | |
| 50 | 37 | {% endblock %} |
| @@ -1,50 +1,37 @@ | |||
| 1 | -{% extends "base.html" %} | 1 | +{% extends "app.html" %} |
| 2 | -{% block title %}Paramètres · time-time{% endblock %} | ||
| 3 | {% block content %} | 2 | {% block content %} |
| 4 | -<h1>Paramètres</h1> | 3 | +<div class="card" style="margin-bottom:var(--space-4)"> |
| 5 | -{% if let Some(n) = notice %}<p class="flash flash-ok">{{ n }}</p>{% endif %} | 4 | + <div class="card-kicker">Compte</div> |
| 6 | -{% if let Some(e) = error %}<p class="flash flash-error">{{ e }}</p>{% endif %} | 5 | + <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div> |
| 6 | + <p class="card-body">Les comptes sont créés en ligne de commande. Tu peux changer ton mot de passe ici.</p> | ||
| 7 | +</div> | ||
| 7 | 8 | ||
| 8 | -<h2>Tâches</h2> | 9 | +<h4 style="margin:var(--space-6) 0 var(--space-3)">Changer de mot de passe</h4> |
| 9 | -<ul class="task-list"> | 10 | +<form method="post" action="/parametres/mot-de-passe"> |
| 10 | - {% for t in tasks %} | ||
| 11 | - <li class="task {% if t.archived %}task-archived{% endif %}"> | ||
| 12 | - <form method="post" action="/parametres/taches/{{ t.id }}" class="task-rename"> | ||
| 13 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 14 | - <input name="name" value="{{ t.name }}" required aria-label="Nom de la tâche"> | ||
| 15 | - <button type="submit" class="btn btn-small">Renommer</button> | ||
| 16 | - </form> | ||
| 17 | - <form method="post" action="/parametres/taches/{{ t.id }}/archiver"> | ||
| 18 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 19 | - <button type="submit" class="btn btn-small"> | ||
| 20 | - {% if t.archived %}Restaurer{% else %}Archiver{% endif %} | ||
| 21 | - </button> | ||
| 22 | - </form> | ||
| 23 | - </li> | ||
| 24 | - {% endfor %} | ||
| 25 | -</ul> | ||
| 26 | -<form method="post" action="/parametres/taches" class="task-add"> | ||
| 27 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 28 | - <input name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche"> | 12 | + <div class="field" style="margin-bottom:var(--space-3)"> |
| 29 | - <button type="submit" class="btn btn-primary">Créer</button> | 13 | + <label for="ancien">Mot de passe actuel</label> |
| 14 | + <input class="input" id="ancien" name="ancien" type="password" autocomplete="current-password" required style="min-height:44px"> | ||
| 15 | + </div> | ||
| 16 | + <div class="field" style="margin-bottom:var(--space-3)"> | ||
| 17 | + <label for="nouveau">Nouveau mot de passe</label> | ||
| 18 | + <input class="input" id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> | ||
| 19 | + </div> | ||
| 20 | + <div class="field" style="margin-bottom:var(--space-4)"> | ||
| 21 | + <label for="confirmation">Confirmation</label> | ||
| 22 | + <input class="input" id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8" style="min-height:44px"> | ||
| 23 | + </div> | ||
| 24 | + {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | ||
| 25 | + <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">Enregistrer</button> | ||
| 30 | </form> | 26 | </form> |
| 31 | -<p class="hint">Archiver une tâche la retire du formulaire d'ajout ; les temps déjà saisis restent dans les statistiques.</p> | ||
| 32 | 27 | ||
| 33 | -<h2>Mot de passe</h2> | 28 | +<h4 style="margin:var(--space-8) 0 var(--space-3)">Tâches</h4> |
| 34 | -<form method="post" action="/parametres/mot-de-passe" class="stack"> | 29 | +<a class="btn btn-secondary btn-block" href="/parametres/taches" style="min-height:46px">Gérer les tâches</a> |
| 35 | - <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 36 | - <label for="ancien">Ancien mot de passe</label> | ||
| 37 | - <input id="ancien" name="ancien" type="password" autocomplete="current-password" required> | ||
| 38 | - <label for="nouveau">Nouveau mot de passe</label> | ||
| 39 | - <input id="nouveau" name="nouveau" type="password" autocomplete="new-password" required minlength="8"> | ||
| 40 | - <label for="confirmation">Confirmation</label> | ||
| 41 | - <input id="confirmation" name="confirmation" type="password" autocomplete="new-password" required minlength="8"> | ||
| 42 | - <button type="submit" class="btn btn-primary">Changer le mot de passe</button> | ||
| 43 | -</form> | ||
| 44 | 30 | ||
| 45 | -<h2>Session</h2> | ||
| 46 | <form method="post" action="/logout"> | 31 | <form method="post" action="/logout"> |
| 47 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 32 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 48 | - <button type="submit" class="btn btn-danger">Se déconnecter</button> | 33 | + <button type="submit" class="btn btn-block" |
| 34 | + style="min-height:46px;margin-top:var(--space-8);color:var(--color-accent-700);border:1px solid var(--color-accent-300)">Se déconnecter</button> | ||
| 49 | </form> | 35 | </form> |
| 36 | +<p class="text-muted" style="font-size:11px;margin-top:var(--space-3);text-align:center">Le biscuit de session sera révoqué.</p> | ||
| 50 | {% endblock %} | 37 | {% endblock %} |
modified
templates/stats.html +57 -27 | @@ -1,42 +1,72 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Statistiques · time-time{% endblock %} | |
| 1 | +{% extends "app.html" %} | |
| 3 | 2 | {% block content %} |
| 4 | -<h1>Statistiques</h1> | |
| 5 | - | |
| 6 | 3 | {% if !legend.is_empty() %} |
| 7 | 4 | <ul class="legend"> |
| 8 | 5 | {% for item in legend %} |
| 9 | - <li><span class="swatch" style="background:{{ item.color }}"></span>{{ item.name }}</li> | |
| 6 | + <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li> | |
| 10 | 7 | {% endfor %} |
| 11 | 8 | </ul> |
| 12 | 9 | {% endif %} |
| 13 | 10 | |
| 14 | -<section class="chart-block"> | |
| 11 | +{% for c in charts %} | |
| 12 | +<section class="chart-section"> | |
| 15 | 13 | <div class="chart-head"> |
| 16 | - <a class="btn btn-nav" href="{{ week_prev }}" aria-label="Semaine précédente">←</a> | |
| 17 | - <h2>{{ week_chart.title }}</h2> | |
| 18 | - {% if let Some(next) = week_next %} | |
| 19 | - <a class="btn btn-nav" href="{{ next }}" aria-label="Semaine suivante">→</a> | |
| 20 | - {% else %}<span class="btn-nav-placeholder"></span>{% endif %} | |
| 14 | + <h2>{{ c.title }}</h2> | |
| 15 | + {% if c.nav %} | |
| 16 | + <div class="chart-nav"> | |
| 17 | + <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="Période précédente"> | |
| 18 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> | |
| 19 | + </a> | |
| 20 | + {% if let Some(next) = c.next %} | |
| 21 | + <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="Période suivante"> | |
| 22 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | |
| 23 | + </a> | |
| 24 | + {% else %} | |
| 25 | + <span class="btn btn-secondary btn-icon-sm" aria-disabled="true"> | |
| 26 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | |
| 27 | + </span> | |
| 28 | + {% endif %} | |
| 29 | + </div> | |
| 30 | + {% endif %} | |
| 21 | 31 | </div> |
| 22 | - {% let chart = week_chart %}{% include "chart.svg.html" %} | |
| 23 | -</section> | |
| 32 | + <p class="chart-range">{{ c.range }}</p> | |
| 24 | 33 | |
| 25 | -<section class="chart-block"> | |
| 26 | - <div class="chart-head"> | |
| 27 | - <a class="btn btn-nav" href="{{ month_prev }}" aria-label="Mois précédent">←</a> | |
| 28 | - <h2>{{ month_chart.title }}</h2> | |
| 29 | - {% if let Some(next) = month_next %} | |
| 30 | - <a class="btn btn-nav" href="{{ next }}" aria-label="Mois suivant">→</a> | |
| 31 | - {% else %}<span class="btn-nav-placeholder"></span>{% endif %} | |
| 34 | + {% if c.empty %} | |
| 35 | + <p class="chart-empty">Aucun temps enregistré sur cette période.</p> | |
| 36 | + {% else %} | |
| 37 | + <div class="cols"> | |
| 38 | + {% for row in c.rows %} | |
| 39 | + <div class="col"> | |
| 40 | + <span class="total">{{ row.total }}</span> | |
| 41 | + <div class="col-bar" style="height:{{ row.height_pct }}%"> | |
| 42 | + {% for s in row.segs %} | |
| 43 | + <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}" | |
| 44 | + title="{{ s.aria }}" aria-label="{{ s.aria }}"></a> | |
| 45 | + {% endfor %} | |
| 46 | + </div> | |
| 47 | + <span class="user">{{ row.user }}</span> | |
| 48 | + </div> | |
| 49 | + {% endfor %} | |
| 32 | 50 | </div> |
| 33 | - {% let chart = month_chart %}{% include "chart.svg.html" %} | |
| 51 | + {% endif %} | |
| 34 | 52 | </section> |
| 53 | +{% endfor %} | |
| 35 | 54 | |
| 36 | -<section class="chart-block"> | |
| 37 | - <div class="chart-head"><h2>{{ total_chart.title }}</h2></div> | |
| 38 | - {% let chart = total_chart %}{% include "chart.svg.html" %} | |
| 39 | -</section> | |
| 40 | - | |
| 41 | -<p><a class="btn btn-big" href="/temps">✏️ Modifier / supprimer mes temps</a></p> | |
| 55 | +{% for s in sheets %} | |
| 56 | +<div class="sheet-wrap" id="{{ s.id }}"> | |
| 57 | + <a class="sheet-backdrop" href="#fermer" aria-label="Fermer"></a> | |
| 58 | + <div class="sheet"> | |
| 59 | + <div class="sheet-head"> | |
| 60 | + <span class="dot dot-14" style="background:{{ s.color }}"></span> | |
| 61 | + <span class="pname">{{ s.task }}</span> | |
| 62 | + <a class="btn btn-ghost close" href="#fermer">Fermer</a> | |
| 63 | + </div> | |
| 64 | + <div class="sheet-figures"> | |
| 65 | + <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> | |
| 66 | + <div><div class="k">Part de son temps</div><div class="v">{{ s.share }}</div></div> | |
| 67 | + </div> | |
| 68 | + <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> | |
| 69 | + </div> | |
| 70 | +</div> | |
| 71 | +{% endfor %} | |
| 42 | 72 | {% endblock %} |
| @@ -1,42 +1,72 @@ | |||
| 1 | -{% extends "base.html" %} | 1 | +{% extends "app.html" %} |
| 2 | -{% block title %}Statistiques · time-time{% endblock %} | ||
| 3 | {% block content %} | 2 | {% block content %} |
| 4 | -<h1>Statistiques</h1> | ||
| 5 | - | ||
| 6 | {% if !legend.is_empty() %} | 3 | {% if !legend.is_empty() %} |
| 7 | <ul class="legend"> | 4 | <ul class="legend"> |
| 8 | {% for item in legend %} | 5 | {% for item in legend %} |
| 9 | - <li><span class="swatch" style="background:{{ item.color }}"></span>{{ item.name }}</li> | 6 | + <li><span class="dot" style="background:{{ item.color }}"></span>{{ item.name }}</li> |
| 10 | {% endfor %} | 7 | {% endfor %} |
| 11 | </ul> | 8 | </ul> |
| 12 | {% endif %} | 9 | {% endif %} |
| 13 | 10 | ||
| 14 | -<section class="chart-block"> | 11 | +{% for c in charts %} |
| 12 | +<section class="chart-section"> | ||
| 15 | <div class="chart-head"> | 13 | <div class="chart-head"> |
| 16 | - <a class="btn btn-nav" href="{{ week_prev }}" aria-label="Semaine précédente">←</a> | 14 | + <h2>{{ c.title }}</h2> |
| 17 | - <h2>{{ week_chart.title }}</h2> | 15 | + {% if c.nav %} |
| 18 | - {% if let Some(next) = week_next %} | 16 | + <div class="chart-nav"> |
| 19 | - <a class="btn btn-nav" href="{{ next }}" aria-label="Semaine suivante">→</a> | 17 | + <a class="btn btn-secondary btn-icon-sm" href="{{ c.prev }}" aria-label="Période précédente"> |
| 20 | - {% else %}<span class="btn-nav-placeholder"></span>{% endif %} | 18 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"></path></svg> |
| 19 | + </a> | ||
| 20 | + {% if let Some(next) = c.next %} | ||
| 21 | + <a class="btn btn-secondary btn-icon-sm" href="{{ next }}" aria-label="Période suivante"> | ||
| 22 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | ||
| 23 | + </a> | ||
| 24 | + {% else %} | ||
| 25 | + <span class="btn btn-secondary btn-icon-sm" aria-disabled="true"> | ||
| 26 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg> | ||
| 27 | + </span> | ||
| 28 | + {% endif %} | ||
| 29 | + </div> | ||
| 30 | + {% endif %} | ||
| 21 | </div> | 31 | </div> |
| 22 | - {% let chart = week_chart %}{% include "chart.svg.html" %} | 32 | + <p class="chart-range">{{ c.range }}</p> |
| 23 | -</section> | ||
| 24 | 33 | ||
| 25 | -<section class="chart-block"> | 34 | + {% if c.empty %} |
| 26 | - <div class="chart-head"> | 35 | + <p class="chart-empty">Aucun temps enregistré sur cette période.</p> |
| 27 | - <a class="btn btn-nav" href="{{ month_prev }}" aria-label="Mois précédent">←</a> | 36 | + {% else %} |
| 28 | - <h2>{{ month_chart.title }}</h2> | 37 | + <div class="cols"> |
| 29 | - {% if let Some(next) = month_next %} | 38 | + {% for row in c.rows %} |
| 30 | - <a class="btn btn-nav" href="{{ next }}" aria-label="Mois suivant">→</a> | 39 | + <div class="col"> |
| 31 | - {% else %}<span class="btn-nav-placeholder"></span>{% endif %} | 40 | + <span class="total">{{ row.total }}</span> |
| 41 | + <div class="col-bar" style="height:{{ row.height_pct }}%"> | ||
| 42 | + {% for s in row.segs %} | ||
| 43 | + <a href="#{{ s.sheet_id }}" style="flex-grow:{{ s.flex }};background:{{ s.color }}" | ||
| 44 | + title="{{ s.aria }}" aria-label="{{ s.aria }}"></a> | ||
| 45 | + {% endfor %} | ||
| 46 | + </div> | ||
| 47 | + <span class="user">{{ row.user }}</span> | ||
| 48 | + </div> | ||
| 49 | + {% endfor %} | ||
| 32 | </div> | 50 | </div> |
| 33 | - {% let chart = month_chart %}{% include "chart.svg.html" %} | 51 | + {% endif %} |
| 34 | </section> | 52 | </section> |
| 53 | +{% endfor %} | ||
| 35 | 54 | ||
| 36 | -<section class="chart-block"> | 55 | +{% for s in sheets %} |
| 37 | - <div class="chart-head"><h2>{{ total_chart.title }}</h2></div> | 56 | +<div class="sheet-wrap" id="{{ s.id }}"> |
| 38 | - {% let chart = total_chart %}{% include "chart.svg.html" %} | 57 | + <a class="sheet-backdrop" href="#fermer" aria-label="Fermer"></a> |
| 39 | -</section> | 58 | + <div class="sheet"> |
| 40 | - | 59 | + <div class="sheet-head"> |
| 41 | -<p><a class="btn btn-big" href="/temps">✏️ Modifier / supprimer mes temps</a></p> | 60 | + <span class="dot dot-14" style="background:{{ s.color }}"></span> |
| 61 | + <span class="pname">{{ s.task }}</span> | ||
| 62 | + <a class="btn btn-ghost close" href="#fermer">Fermer</a> | ||
| 63 | + </div> | ||
| 64 | + <div class="sheet-figures"> | ||
| 65 | + <div><div class="k">{{ s.who }}</div><div class="v">{{ s.duration }}</div></div> | ||
| 66 | + <div><div class="k">Part de son temps</div><div class="v">{{ s.share }}</div></div> | ||
| 67 | + </div> | ||
| 68 | + <p class="sheet-note">{{ s.period }} · {{ s.count }}</p> | ||
| 69 | + </div> | ||
| 70 | +</div> | ||
| 71 | +{% endfor %} | ||
| 42 | {% endblock %} | 72 | {% endblock %} |
added
templates/tasks.html +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +{% extends "app.html" %} | |
| 2 | +{% block content %} | |
| 3 | +<p class="step-hint">Les tâches sont communes à tout le monde. Archiver une tâche la retire des listes ; les temps déjà saisis restent dans les stats.</p> | |
| 4 | +<form method="post" action="/parametres/taches" class="task-add-row"> | |
| 5 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 6 | + <input class="input" name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche"> | |
| 7 | + <button type="submit" class="btn btn-primary">Ajouter</button> | |
| 8 | +</form> | |
| 9 | +{% for t in tasks %} | |
| 10 | +<div class="task-row {% if t.archived %}archived{% endif %}"> | |
| 11 | + <span class="dot dot-12" style="background:{{ t.color }}"></span> | |
| 12 | + <form method="post" action="{{ t.rename_action }}" class="rename"> | |
| 13 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 14 | + <input class="input" name="name" value="{{ t.name }}" required aria-label="Renommer la tâche (Entrée pour valider)"> | |
| 15 | + <button type="submit" class="visually-hidden">Renommer</button> | |
| 16 | + </form> | |
| 17 | + <form method="post" action="{{ t.toggle_action }}"> | |
| 18 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | |
| 19 | + {% if t.archived %} | |
| 20 | + <input type="hidden" name="restaure" value="1"> | |
| 21 | + <button type="submit" class="btn btn-ghost" style="font-size:12px">Restaurer</button> | |
| 22 | + {% else %} | |
| 23 | + <button type="submit" class="btn btn-secondary btn-icon-sm" aria-label="Archiver"> | |
| 24 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"></rect><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"></path><path d="M10 12h4"></path></svg> | |
| 25 | + </button> | |
| 26 | + {% endif %} | |
| 27 | + </form> | |
| 28 | +</div> | |
| 29 | +{% endfor %} | |
| 30 | +<p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">Renommer : modifie le nom puis appuie sur Entrée.</p> | |
| 31 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres">← Retour aux réglages</a> | |
| 32 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +{% extends "app.html" %} | ||
| 2 | +{% block content %} | ||
| 3 | +<p class="step-hint">Les tâches sont communes à tout le monde. Archiver une tâche la retire des listes ; les temps déjà saisis restent dans les stats.</p> | ||
| 4 | +<form method="post" action="/parametres/taches" class="task-add-row"> | ||
| 5 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 6 | + <input class="input" name="name" placeholder="Nouvelle tâche" required aria-label="Nouvelle tâche"> | ||
| 7 | + <button type="submit" class="btn btn-primary">Ajouter</button> | ||
| 8 | +</form> | ||
| 9 | +{% for t in tasks %} | ||
| 10 | +<div class="task-row {% if t.archived %}archived{% endif %}"> | ||
| 11 | + <span class="dot dot-12" style="background:{{ t.color }}"></span> | ||
| 12 | + <form method="post" action="{{ t.rename_action }}" class="rename"> | ||
| 13 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 14 | + <input class="input" name="name" value="{{ t.name }}" required aria-label="Renommer la tâche (Entrée pour valider)"> | ||
| 15 | + <button type="submit" class="visually-hidden">Renommer</button> | ||
| 16 | + </form> | ||
| 17 | + <form method="post" action="{{ t.toggle_action }}"> | ||
| 18 | + <input type="hidden" name="csrf" value="{{ csrf }}"> | ||
| 19 | + {% if t.archived %} | ||
| 20 | + <input type="hidden" name="restaure" value="1"> | ||
| 21 | + <button type="submit" class="btn btn-ghost" style="font-size:12px">Restaurer</button> | ||
| 22 | + {% else %} | ||
| 23 | + <button type="submit" class="btn btn-secondary btn-icon-sm" aria-label="Archiver"> | ||
| 24 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"></rect><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"></path><path d="M10 12h4"></path></svg> | ||
| 25 | + </button> | ||
| 26 | + {% endif %} | ||
| 27 | + </form> | ||
| 28 | +</div> | ||
| 29 | +{% endfor %} | ||
| 30 | +<p class="text-muted" style="font-size:12px;margin-top:var(--space-3)">Renommer : modifie le nom puis appuie sur Entrée.</p> | ||
| 31 | +<a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/parametres">← Retour aux réglages</a> | ||
| 32 | +{% endblock %} | ||
added
templates/times.html +25 -0 | new file mode 100644 | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +{% extends "app.html" %} | |
| 2 | +{% block content %} | |
| 3 | +{% if entries.is_empty() %} | |
| 4 | +<p class="chart-empty">Tu n'as encore enregistré aucun temps.</p> | |
| 5 | +{% else %} | |
| 6 | +<ul class="times"> | |
| 7 | + {% for e in entries %} | |
| 8 | + <li> | |
| 9 | + <span class="dot dot-12" style="background:{{ e.color }}"></span> | |
| 10 | + <div class="tinfo"> | |
| 11 | + <div class="ttask">{{ e.task }}</div> | |
| 12 | + <div class="tdate">{{ e.date }}</div> | |
| 13 | + </div> | |
| 14 | + <span class="tdur">{{ e.duration }}</span> | |
| 15 | + <a class="btn btn-secondary btn-icon-sm" href="{{ e.edit_href }}" aria-label="Modifier"> | |
| 16 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> | |
| 17 | + </a> | |
| 18 | + </li> | |
| 19 | + {% endfor %} | |
| 20 | +</ul> | |
| 21 | +{% endif %} | |
| 22 | +{% if let Some(more) = more_href %} | |
| 23 | +<a class="btn btn-secondary btn-block" style="margin-top:var(--space-4)" href="{{ more }}">Voir plus</a> | |
| 24 | +{% endif %} | |
| 25 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | +{% extends "app.html" %} | ||
| 2 | +{% block content %} | ||
| 3 | +{% if entries.is_empty() %} | ||
| 4 | +<p class="chart-empty">Tu n'as encore enregistré aucun temps.</p> | ||
| 5 | +{% else %} | ||
| 6 | +<ul class="times"> | ||
| 7 | + {% for e in entries %} | ||
| 8 | + <li> | ||
| 9 | + <span class="dot dot-12" style="background:{{ e.color }}"></span> | ||
| 10 | + <div class="tinfo"> | ||
| 11 | + <div class="ttask">{{ e.task }}</div> | ||
| 12 | + <div class="tdate">{{ e.date }}</div> | ||
| 13 | + </div> | ||
| 14 | + <span class="tdur">{{ e.duration }}</span> | ||
| 15 | + <a class="btn btn-secondary btn-icon-sm" href="{{ e.edit_href }}" aria-label="Modifier"> | ||
| 16 | + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg> | ||
| 17 | + </a> | ||
| 18 | + </li> | ||
| 19 | + {% endfor %} | ||
| 20 | +</ul> | ||
| 21 | +{% endif %} | ||
| 22 | +{% if let Some(more) = more_href %} | ||
| 23 | +<a class="btn btn-secondary btn-block" style="margin-top:var(--space-4)" href="{{ more }}">Voir plus</a> | ||
| 24 | +{% endif %} | ||
| 25 | +{% endblock %} | ||