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

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

Download ZIP

feat: 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.
Julien Durillon committed 2026-08-06T20:43:37+02:00 Browse files
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 differBinary files a/assets/icon-512.png and b/assets/icon-512.png differ
modified assets/manifest.json +2 -2
@@ -4,8 +4,8 @@
44 "description": "Comptage de temps passé sur des tâches",
55 "start_url": "/",
66 "display": "standalone",
7- "background_color": "#f7f8fa",
8- "theme_color": "#1d3557",
7+ "background_color": "#f5ead8",
8+ "theme_color": "#f5ead8",
99 "lang": "fr",
1010 "icons": [
1111 {
@@ -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+
24 :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;
2037
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%; }
2145 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;
2875 }
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); }
2989
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);
3594 }
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;
41100 }
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; }
42103
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);
47109 }
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; }
48112
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 ═══ */
51114
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};
22
33 use uuid::Uuid;
44
55 use crate::db::stats::StatRow;
66 use crate::db::users::UserRow;
77
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",
1212 ];
1313
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
1817 .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()
2121 }
2222
2323 /// « 45 min », « 2 h », « 12 h 30 »
@@ -30,41 +30,43 @@ pub fn fmt_minutes(total: i64) -> String {
3030 }
3131 }
3232
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,
4639 }
4740
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>,
5447 }
5548
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 {
6250 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>,
6358 }
6459
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,
6870 }
6971
7072 pub struct LegendItem {
@@ -72,118 +74,110 @@ pub struct LegendItem {
7274 pub color: &'static str,
7375 }
7476
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>,
8480 }
8581
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+ }
121101
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);
125112
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+ });
159145 }
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();
162155
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+ )
178172 }
179173
180174 /// 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> {
182176 let mut seen: BTreeMap<String, &'static str> = BTreeMap::new();
183177 for rows in row_sets {
184178 for r in *rows {
185179 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]));
187181 }
188182 }
189183 seen.into_iter()
@@ -202,15 +196,20 @@ mod tests {
202196 }
203197 }
204198
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 {
206200 StatRow {
207201 user_id: Uuid::from_u128(uid),
208202 task_id: Uuid::from_u128(tid),
209203 task_name: task.into(),
210204 total,
205+ entries,
211206 }
212207 }
213208
209+ fn colors() -> HashMap<Uuid, &'static str> {
210+ color_map(&[Uuid::from_u128(10), Uuid::from_u128(11)])
211+ }
212+
214213 #[test]
215214 fn fmt() {
216215 assert_eq!(fmt_minutes(45), "45 min");
@@ -219,49 +218,41 @@ mod tests {
219218 }
220219
221220 #[test]
222- fn geometry_sums_to_totals() {
221+ fn columns_scale_to_max() {
223222 let users = [user(1, "alice"), user(2, "bob")];
224223 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),
228227 ];
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");
243239 }
244240
245241 #[test]
246- fn zero_user_has_no_segments() {
242+ fn zero_user_min_height() {
247243 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);
252248 assert!(!chart.empty);
253249 }
254250
255251 #[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]);
266257 }
267258 }
@@ -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_id16+ 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 ≈ ordre86+ 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 = users96+ }
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_user99+ 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 = users113+ 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- LEFT166+ prev,
174- }167+ next,
175- pub fn right(&self) -> f32 {168+ rows: view_rows,
176- RIGHT169+ },
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;
22 use sqlx::PgPool;
33 use uuid::Uuid;
44
5-pub const PAGE_SIZE: i64 = 20;
6-
75 #[derive(sqlx::FromRow)]
86 pub struct EntryRow {
97 pub id: Uuid,
@@ -35,29 +33,47 @@ pub async fn insert(
3533 Ok(id)
3634 }
3735
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(
4140 pool: &PgPool,
4241 user_id: Uuid,
43- page: i64,
42+ limit: i64,
4443 ) -> sqlx::Result<(Vec<EntryRow>, bool)> {
45- let offset = (page - 1) * PAGE_SIZE;
4644 let mut rows: Vec<EntryRow> = sqlx::query_as(
4745 "SELECT e.id, e.task_id, t.name AS task_name, e.minutes, e.entry_date
4846 FROM time_entries e JOIN tasks t ON t.id = e.task_id
4947 WHERE e.user_id = $1
5048 ORDER BY e.entry_date DESC, e.id DESC
51- LIMIT $2 OFFSET $3",
49+ LIMIT $2",
5250 )
5351 .bind(user_id)
54- .bind(PAGE_SIZE + 1)
55- .bind(offset)
52+ .bind(limit + 1)
5653 .fetch_all(pool)
5754 .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
6177 }
6278
6379 /// 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_date45 "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_id46 FROM time_entries e JOIN tasks t ON t.id = e.task_id
49 WHERE e.user_id = $147 WHERE e.user_id = $1
50 ORDER BY e.entry_date DESC, e.id DESC48 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 {
88 pub task_id: Uuid,
99 pub task_name: String,
1010 pub total: i64,
11+ /// Nombre de saisies (pour la sheet de détail).
12+ pub entries: i64,
1113 }
1214
1315 /// Totaux par utilisateur × tâche sur [from, to) ; bornes NULL = depuis le début.
@@ -19,7 +21,7 @@ pub async fn totals(
1921 ) -> sqlx::Result<Vec<StatRow>> {
2022 sqlx::query_as(
2123 "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
2325 FROM time_entries e
2426 JOIN tasks t ON t.id = e.task_id
2527 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 total24+ SUM(e.minutes)::BIGINT AS total, COUNT(*)::BIGINT AS entries
23 FROM time_entries e25 FROM time_entries e
24 JOIN tasks t ON t.id = e.task_id26 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 {
88 pub archived: bool,
99 }
1010
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")
1414 .fetch_all(pool)
1515 .await
1616 }
1717
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")
2122 .fetch_all(pool)
2223 .await
2324 }
@@ -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 .await15 .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 .await23 .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
4848 Ok(())
4949 }
5050
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+
5159 pub async fn list_all(pool: &PgPool) -> sqlx::Result<Vec<UserRow>> {
5260 sqlx::query_as("SELECT id, username FROM users ORDER BY username")
5361 .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 {
99 Utc::now().with_timezone(&Paris).date_naive()
1010 }
1111
12+const WEEKDAY_NAMES: [&str; 7] = [
13+ "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche",
14+];
15+
1216 const MONTH_NAMES: [&str; 12] = [
1317 "janvier",
1418 "février",
@@ -24,6 +28,34 @@ const MONTH_NAMES: [&str; 12] = [
2428 "décembre",
2529 ];
2630
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+
2759 /// Semaine ISO (lundi → dimanche), format query `2026-W12`.
2860 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
2961 pub struct Week {
@@ -70,18 +102,11 @@ impl Week {
70102 Self::of(self.bounds().0 + Duration::days(7))
71103 }
72104
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 {
75107 let (start, end) = self.bounds();
76108 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))
85110 }
86111 }
87112
@@ -160,9 +185,13 @@ impl Month {
160185 }
161186 }
162187
163- /// « mars 2026 »
188+ /// « Mars 2026 »
164189 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+ ))
166195 }
167196 }
168197
@@ -228,12 +257,11 @@ mod tests {
228257
229258 #[test]
230259 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"
237264 );
265+ assert_eq!(day_month(d(2026, 8, 3)), "3 août");
238266 }
239267 }
@@ -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> {
1515 }
1616 }
1717 }
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+
13 use askama::Template;
24 use axum::Form;
35 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};
68 use serde::Deserialize;
79 use uuid::Uuid;
810
911 use crate::auth::extractor::AuthUser;
10-use crate::charts::fmt_minutes;
12+use crate::charts::{color_map, fmt_minutes};
1113 use crate::db::entries::{self, EntryRow};
1214 use crate::db::tasks::{self, TaskRow};
1315 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;
1619 use crate::state::AppState;
1720
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 {
2026 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,
2131 }
2232
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>,
2938 }
3039
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,
4146 }
4247
4348 #[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,
4952 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,
5358 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>,
5764 }
5865
5966 #[derive(Deserialize)]
6067 pub struct NewQuery {
61- minutes: Option<i32>,
6268 tache: Option<Uuid>,
69+ minutes: Option<i32>,
6370 date: Option<NaiveDate>,
64- ok: Option<u8>,
71+ /// minutes de la saisie qui vient d'aboutir (toast).
72+ ok: Option<i64>,
6573 }
6674
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}")
8677 }
8778
8879 pub async fn entry_new(
8980 State(state): State<AppState>,
9081 auth: AuthUser,
9182 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())
105146 }
106147
107148 #[derive(Deserialize)]
@@ -131,80 +172,118 @@ pub async fn entry_create(
131172 }
132173 validate(&form)?;
133174 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)))
135176 }
136177
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 ───
144179
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,
149183 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>,
150194 }
151195
152196 #[derive(Deserialize)]
153197 pub struct PageQuery {
154198 page: Option<i64>,
199+ ok: Option<String>,
155200 }
156201
157202 pub async fn entries_list(
158203 State(state): State<AppState>,
159204 auth: AuthUser,
160205 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+ };
162212 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?,
166230 entries,
167- page,
168- has_next,
231+ more_href: has_more.then(|| format!("/temps?page={}", page + 1)),
169232 }))
170233 }
171234
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,
179253 }
180254
181255 pub async fn entry_edit(
182256 State(state): State<AppState>,
183257 auth: AuthUser,
184258 Path(id): Path<Uuid>,
185-) -> Result<HtmlTemplate<EntryFormPage>, AppError> {
259+) -> Result<HtmlTemplate<EditPage>, AppError> {
186260 let entry = entries::get_owned(&state.pool, id, auth.id)
187261 .await?
188262 .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?,
201281 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,
204286 date: entry.entry_date,
205- presets: Vec::new(),
206- saved: false,
207- delete_action: Some(format!("/temps/{id}/supprimer")),
208287 }))
209288 }
210289
@@ -230,7 +309,7 @@ pub async fn entry_update(
230309 if n == 0 {
231310 return Err(AppError::NotFound);
232311 }
233- Ok(Redirect::to("/temps"))
312+ Ok(Redirect::to("/temps?ok=modif"))
234313 }
235314
236315 #[derive(Deserialize)]
@@ -250,5 +329,5 @@ pub async fn entry_delete(
250329 if entries::delete_owned(&state.pool, id, auth.id).await? == 0 {
251330 return Err(AppError::NotFound);
252331 }
253- Ok(Redirect::to("/temps"))
332+ Ok(Redirect::to("/temps?ok=suppr"))
254333 }
@@ -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- tasks41+
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 @@
11 mod entries;
2-mod home;
32 mod login;
43 mod settings;
54 mod statics;
65 mod stats;
76
87 use axum::Router;
8+use axum::response::Redirect;
99 use axum::routing::{get, post};
1010
11+use crate::auth::extractor::AuthUser;
12+use crate::error::AppError;
13+use crate::render::Shell;
1114 use crate::state::AppState;
1215
1316 pub fn router(state: AppState) -> Router {
1417 Router::new()
15- .route("/", get(home::home))
18+ .route("/", get(root))
1619 .route("/login", get(login::login_page).post(login::login_submit))
1720 .route("/logout", post(login::logout))
1821 .route(
@@ -25,7 +28,7 @@ pub fn router(state: AppState) -> Router {
2528 .route("/temps/{id}/supprimer", post(entries::entry_delete))
2629 .route("/stats", get(stats::stats_page))
2730 .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))
2932 .route("/parametres/taches/{id}", post(settings::task_rename))
3033 .route(
3134 "/parametres/taches/{id}/archiver",
@@ -42,6 +45,30 @@ pub fn router(state: AppState) -> Router {
4245 .with_state(state)
4346 }
4447
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+ })
4774 }
@@ -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::NotFound49+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;
77
88 use crate::auth::extractor::AuthUser;
99 use crate::auth::password;
10+use crate::charts::color_map;
1011 use crate::db::{tasks, users};
11-use crate::db::tasks::TaskRow;
1212 use crate::error::AppError;
13-use crate::render::HtmlTemplate;
13+use crate::render::{HtmlTemplate, Shell};
14+use crate::routes::shell;
1415 use crate::state::AppState;
1516
17+/// ─── Réglages ───
18+
1619 #[derive(Template)]
1720 #[template(path = "settings.html")]
1821 pub struct SettingsPage {
19- pub tasks: Vec<TaskRow>,
22+ pub shell: Shell,
2023 pub csrf: String,
21- pub notice: Option<&'static str>,
2224 pub error: Option<&'static str>,
2325 }
2426
@@ -33,27 +35,78 @@ pub async fn settings_page(
3335 auth: AuthUser,
3436 Query(q): Query<SettingsQuery>,
3537 ) -> 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()),
3940 _ => None,
4041 };
4142 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."),
4344 Some("confirm") => Some("La confirmation ne correspond pas."),
4445 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."),
4746 _ => None,
4847 };
4948 Ok(HtmlTemplate(SettingsPage {
50- tasks: tasks::list_all(&state.pool).await?,
49+ shell: shell(&state, &auth, "Paramètres", "settings", toast).await?,
5150 csrf: auth.csrf_token(&state),
52- notice,
5351 error,
5452 }))
5553 }
5654
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+
57110 #[derive(Deserialize)]
58111 pub struct TaskForm {
59112 name: String,
@@ -74,11 +127,11 @@ pub async fn task_create(
74127 }
75128 let name = form.name.trim();
76129 if name.is_empty() {
77- return Ok(Redirect::to("/parametres?err=vide"));
130+ return Ok(Redirect::to("/parametres/taches?err=vide"));
78131 }
79132 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")),
82135 Err(e) => Err(e.into()),
83136 }
84137 }
@@ -94,26 +147,28 @@ pub async fn task_rename(
94147 }
95148 let name = form.name.trim();
96149 if name.is_empty() {
97- return Ok(Redirect::to("/parametres?err=vide"));
150+ return Ok(Redirect::to("/parametres/taches?err=vide"));
98151 }
99152 match tasks::rename(&state.pool, id, name).await {
100153 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")),
103156 Err(e) => Err(e.into()),
104157 }
105158 }
106159
107160 #[derive(Deserialize)]
108-pub struct CsrfOnly {
161+pub struct ToggleForm {
109162 csrf: String,
163+ /// true si la tâche était archivée (pour le message du toast).
164+ restaure: Option<u8>,
110165 }
111166
112167 pub async fn task_toggle_archive(
113168 State(state): State<AppState>,
114169 auth: AuthUser,
115170 Path(id): Path<Uuid>,
116- Form(form): Form<CsrfOnly>,
171+ Form(form): Form<ToggleForm>,
117172 ) -> Result<Redirect, AppError> {
118173 if !auth.check_csrf(&state, &form.csrf) {
119174 return Err(AppError::Csrf);
@@ -121,7 +176,11 @@ pub async fn task_toggle_archive(
121176 if tasks::toggle_archived(&state.pool, id).await? == 0 {
122177 return Err(AppError::NotFound);
123178 }
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+ }))
125184 }
126185
127186 #[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};
33 use serde::Deserialize;
44
55 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};
88 use crate::error::AppError;
99 use crate::period::{Month, Week};
10-use crate::render::HtmlTemplate;
10+use crate::render::{HtmlTemplate, Shell};
11+use crate::routes::shell;
1112 use crate::state::AppState;
1213
1314 #[derive(Template)]
1415 #[template(path = "stats.html")]
1516 pub struct StatsPage {
16- pub week_chart: ChartView,
17- pub month_chart: ChartView,
18- pub total_chart: ChartView,
17+ pub shell: Shell,
1918 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>,
2421 }
2522
2623 #[derive(Deserialize)]
@@ -35,7 +32,7 @@ fn href(week: Week, month: Month) -> String {
3532
3633 pub async fn stats_page(
3734 State(state): State<AppState>,
38- _auth: AuthUser,
35+ auth: AuthUser,
3936 Query(q): Query<StatsQuery>,
4037 ) -> Result<HtmlTemplate<StatsPage>, AppError> {
4138 let week = q
@@ -53,24 +50,55 @@ pub async fn stats_page(
5350 let (month_from, month_to) = month.bounds();
5451
5552 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+
5656 let week_rows = stats::totals(&state.pool, Some(week_from), Some(week_to)).await?;
5757 let month_rows = stats::totals(&state.pool, Some(month_from), Some(month_to)).await?;
5858 let total_rows = stats::totals(&state.pool, None, None).await?;
5959
60- let legend = legend(&[&week_rows, &month_rows, &total_rows]);
60+ let legend = legend(&[&week_rows, &month_rows, &total_rows], &colors);
6161
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);
6597
6698 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?,
70100 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,
75103 }))
76104 }
@@ -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 = q38 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 @@
33 <head>
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1">
6-<meta name="theme-color" content="#1d3557">
6+<meta name="theme-color" content="#f5ead8">
77 <link rel="manifest" href="/manifest.json">
88 <link rel="icon" href="/icon-512.png">
99 <link rel="apple-touch-icon" href="/icon-512.png">
@@ -11,9 +11,10 @@
1111 <title>{% block title %}time-time{% endblock %}</title>
1212 </head>
1313 <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>
1819 </body>
1920 </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 @@
11 {% extends "base.html" %}
22 {% 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>
1424 {% 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" %}
32 {% 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>
78
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">
2711 <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>
3026 </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>
3227
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>
4430
45-<h2>Session</h2>
4631 <form method="post" action="/logout">
4732 <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>
4935 </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>
5037 {% 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" %}
32 {% block content %}
4-<h1>Statistiques</h1>
5-
63 {% if !legend.is_empty() %}
74 <ul class="legend">
85 {% 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>
107 {% endfor %}
118 </ul>
129 {% endif %}
1310
14-<section class="chart-block">
11+{% for c in charts %}
12+<section class="chart-section">
1513 <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 %}
2131 </div>
22- {% let chart = week_chart %}{% include "chart.svg.html" %}
23-</section>
32+ <p class="chart-range">{{ c.range }}</p>
2433
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 %}
3250 </div>
33- {% let chart = month_chart %}{% include "chart.svg.html" %}
51+ {% endif %}
3452 </section>
53+{% endfor %}
3554
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 %}
4272 {% 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 %}