judu/ex-egopublic Fork
⑂ 225c26f
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(todos): todo list d'équipe — tâches datées par catégorie, prise avec verrou preneur, complétion avec temps optionnel et reprogrammation (médiane des intervalles), assignations informatives avec filtre « Mes tâches », onglet Tâches (remplace Mes temps, relié depuis Stats et Réglages)Unverified

Claude-Session: https://claude.ai/code/session_01FPKHKGfqvnvXKY9tNhYmkh
Julien Durillon committed 2026-08-08T22:50:32+02:00 Browse files
225c26f parent: bb28d5f
modified a11y/audit.mjs +8 -3
@@ -1,6 +1,6 @@
11 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
22 //
3-// Parcourt les 9 écrans de l'app (viewport mobile 390×844) et échoue
3+// Parcourt les 12 écrans de l'app (viewport mobile 390×844) et échoue
44 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
55 // les violations moindres sont listées en avertissement.
66 //
@@ -57,13 +57,15 @@ if (page.url().includes('/login')) {
5757 process.exit(2);
5858 }
5959
60-// Une catégorie et une saisie existantes servent aux pages paramétrées.
60+// Une catégorie, une saisie et une todo existantes servent aux pages paramétrées.
6161 const ids = await page.evaluate(async () => {
6262 const step1 = await fetch('/temps/nouveau').then((r) => r.text());
6363 const times = await fetch('/temps').then((r) => r.text());
64+ const todos = await fetch('/taches').then((r) => r.text());
6465 return {
6566 categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null,
6667 entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,
68+ todo: todos.match(/\/taches\/([0-9a-f-]{36})\/terminer/)?.[1] ?? null,
6769 };
6870 });
6971
@@ -77,8 +79,11 @@ const pages = [
7779 { name: 'settings', url: '/parametres' },
7880 { name: 'categories', url: '/parametres/categories' },
7981 { name: 'team-sheet', url: '/temps/nouveau#equipes' },
82+ { name: 'todos', url: '/taches' },
83+ { name: 'todo-new', url: '/taches/nouvelle' },
84+ ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
8085 ].filter(Boolean);
81-if (pages.length < 9) console.warn('⚠ catégorie ou saisie manquante : certains écrans sont sautés.');
86+if (pages.length < 12) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');
8287
8388 let blocking = 0;
8489 for (const p of pages) {
@@ -1,6 +1,6 @@
1 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.1 // Audit d'accessibilité récurrent — axe-core dans Chrome headless.
2 //2 //
3-// Parcourt les 9 écrans de l'app (viewport mobile 390×844) et échoue3+// Parcourt les 12 écrans de l'app (viewport mobile 390×844) et échoue
4 // (exit 1) à la moindre violation axe « critical » ou « serious » ;4 // (exit 1) à la moindre violation axe « critical » ou « serious » ;
5 // les violations moindres sont listées en avertissement.5 // les violations moindres sont listées en avertissement.
6 //6 //
@@ -57,13 +57,15 @@ if (page.url().includes('/login')) {
57 process.exit(2);57 process.exit(2);
58 }58 }
59 59
60-// Une catégorie et une saisie existantes servent aux pages paramétrées.60+// Une catégorie, une saisie et une todo existantes servent aux pages paramétrées.
61 const ids = await page.evaluate(async () => {61 const ids = await page.evaluate(async () => {
62 const step1 = await fetch('/temps/nouveau').then((r) => r.text());62 const step1 = await fetch('/temps/nouveau').then((r) => r.text());
63 const times = await fetch('/temps').then((r) => r.text());63 const times = await fetch('/temps').then((r) => r.text());
64+ const todos = await fetch('/taches').then((r) => r.text());
64 return {65 return {
65 categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null,66 categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null,
66 entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,67 entry: times.match(/\/temps\/([0-9a-f-]{36})\/modifier/)?.[1] ?? null,
68+ todo: todos.match(/\/taches\/([0-9a-f-]{36})\/terminer/)?.[1] ?? null,
67 };69 };
68 });70 });
69 71
@@ -77,8 +79,11 @@ const pages = [
77 { name: 'settings', url: '/parametres' },79 { name: 'settings', url: '/parametres' },
78 { name: 'categories', url: '/parametres/categories' },80 { name: 'categories', url: '/parametres/categories' },
79 { name: 'team-sheet', url: '/temps/nouveau#equipes' },81 { name: 'team-sheet', url: '/temps/nouveau#equipes' },
82+ { name: 'todos', url: '/taches' },
83+ { name: 'todo-new', url: '/taches/nouvelle' },
84+ ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` },
80 ].filter(Boolean);85 ].filter(Boolean);
81-if (pages.length < 9) console.warn('⚠ catégorie ou saisie manquante : certains écrans sont sautés.');86+if (pages.length < 12) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.');
82 87
83 let blocking = 0;88 let blocking = 0;
84 for (const p of pages) {89 for (const p of pages) {
modified assets/style.css +86 -0
@@ -443,6 +443,92 @@ a.team-pill:hover { filter: brightness(.96); }
443443 clip: rect(0 0 0 0); white-space: nowrap;
444444 }
445445
446+/* — todos — */
447+.todo-toolbar { display: flex; justify-content: flex-end; margin-bottom: var(--space-4); }
448+.todo-empty {
449+ background: var(--color-surface); border-radius: var(--radius-lg);
450+ padding: var(--space-8) var(--space-4); text-align: center;
451+}
452+.todo-empty .te-title { margin: 0 0 var(--space-2); font-family: var(--font-heading); font-size: 21px; }
453+.todo-empty .te-body { margin: 0; font-size: 14px; opacity: .7; text-wrap: pretty; }
454+.blk-head { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-3); }
455+.blk-head h2 { margin: 0; font-size: 15px; }
456+.blk-empty { font-size: 14px; opacity: .7; margin: 0 0 var(--space-8); }
457+.blk-toggle {
458+ margin-left: auto; display: flex; align-items: center; gap: 8px;
459+ font-size: 13px; color: var(--color-text); text-decoration: none;
460+}
461+/* Interrupteur (rendu serveur : .on ; variante checkbox via .swt-check). */
462+.swt {
463+ width: 44px; height: 26px; flex: none; padding: 3px; border-radius: 999px;
464+ display: flex; align-items: center;
465+ background: var(--color-neutral-300); transition: background .15s ease;
466+}
467+.swt .knob {
468+ width: 20px; height: 20px; border-radius: 999px; background: var(--color-bg);
469+ box-shadow: var(--shadow-sm); transition: transform .15s ease;
470+}
471+.swt.on { background: var(--color-accent-700); }
472+.swt.on .knob { transform: translateX(18px); }
473+.todo-grid { display: grid; gap: var(--space-2); margin-bottom: var(--space-8); }
474+.todo-card { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-4); }
475+.todo-card.other { background: var(--color-neutral-200); }
476+.todo-card.forme { box-shadow: inset 0 0 0 1.5px var(--color-accent-2); }
477+.todo-main { display: flex; align-items: center; gap: var(--space-3); }
478+.todo-name { font-size: 16px; }
479+.todo-meta { font-size: 13px; opacity: .7; }
480+.todo-meta.late { opacity: 1; color: var(--color-accent-700); font-weight: 600; }
481+.todo-for { font-size: 13px; margin-top: 2px; opacity: .7; }
482+.todo-for.forme { opacity: 1; color: var(--color-accent-2-900); font-weight: 600; }
483+.todo-actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
484+.todo-actions form { display: contents; }
485+.todo-actions .btn { min-height: 44px; flex: 1; }
486+.later {
487+ background: var(--color-surface); border-radius: var(--radius-lg);
488+ padding: var(--space-3) var(--space-4); margin-bottom: var(--space-4);
489+}
490+.later summary { cursor: pointer; font-size: 15px; font-weight: 700; list-style: none; }
491+.later-row {
492+ display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0;
493+ border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
494+}
495+/* Chips radio/checkbox (création : catégorie, assignés). */
496+.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); position: relative; }
497+.chip {
498+ display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
499+ border-radius: 999px; cursor: pointer; font-size: 14px;
500+ border: 1px solid var(--color-divider);
501+}
502+.chip input { position: absolute; width: 1px; height: 1px; opacity: 0; }
503+.chip:has(input:checked) {
504+ border-color: transparent; background: var(--color-accent-2-200);
505+ color: var(--color-accent-2-900);
506+}
507+.chip:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: 2px; }
508+.chip .cdot { width: 10px; height: 10px; border-radius: 999px; flex: none; }
509+/* Flux « effectuée » : toggles-interrupteurs et sections estompées si off. */
510+.done-title { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-6); }
511+.done-check {
512+ width: 30px; height: 30px; border-radius: 999px; flex: none;
513+ background: var(--color-accent-700); color: var(--color-bg);
514+ display: flex; align-items: center; justify-content: center;
515+}
516+.done-name { font-family: var(--font-heading); font-size: 21px; text-wrap: pretty; }
517+.done-head { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-3); }
518+.done-head h2 { margin: 0; font-size: 15px; }
519+.swt-check {
520+ margin-left: auto; display: flex; align-items: center; gap: 8px;
521+ font-size: 13px; cursor: pointer; position: relative;
522+}
523+.swt-check input { position: absolute; width: 1px; height: 1px; opacity: 0; }
524+.swt-check:has(input:checked) .swt { background: var(--color-accent-700); }
525+.swt-check:has(input:checked) .swt .knob { transform: translateX(18px); }
526+.swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
527+.done-sec .done-body { transition: opacity .15s ease; }
528+.done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; }
529+/* Carte-lien « Mes saisies » (Réglages). */
530+a.times-link { flex-direction: row; align-items: center; text-decoration: none; color: var(--color-text); }
531+
446532 /* — toast — */
447533 /* En haut : le bas de l'écran est déjà occupé par la pill timer et la tab bar. */
448534 .toast {
@@ -443,6 +443,92 @@ a.team-pill:hover { filter: brightness(.96); }
443 clip: rect(0 0 0 0); white-space: nowrap;443 clip: rect(0 0 0 0); white-space: nowrap;
444 }444 }
445 445
446+/* — todos — */
447+.todo-toolbar { display: flex; justify-content: flex-end; margin-bottom: var(--space-4); }
448+.todo-empty {
449+ background: var(--color-surface); border-radius: var(--radius-lg);
450+ padding: var(--space-8) var(--space-4); text-align: center;
451+}
452+.todo-empty .te-title { margin: 0 0 var(--space-2); font-family: var(--font-heading); font-size: 21px; }
453+.todo-empty .te-body { margin: 0; font-size: 14px; opacity: .7; text-wrap: pretty; }
454+.blk-head { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-3); }
455+.blk-head h2 { margin: 0; font-size: 15px; }
456+.blk-empty { font-size: 14px; opacity: .7; margin: 0 0 var(--space-8); }
457+.blk-toggle {
458+ margin-left: auto; display: flex; align-items: center; gap: 8px;
459+ font-size: 13px; color: var(--color-text); text-decoration: none;
460+}
461+/* Interrupteur (rendu serveur : .on ; variante checkbox via .swt-check). */
462+.swt {
463+ width: 44px; height: 26px; flex: none; padding: 3px; border-radius: 999px;
464+ display: flex; align-items: center;
465+ background: var(--color-neutral-300); transition: background .15s ease;
466+}
467+.swt .knob {
468+ width: 20px; height: 20px; border-radius: 999px; background: var(--color-bg);
469+ box-shadow: var(--shadow-sm); transition: transform .15s ease;
470+}
471+.swt.on { background: var(--color-accent-700); }
472+.swt.on .knob { transform: translateX(18px); }
473+.todo-grid { display: grid; gap: var(--space-2); margin-bottom: var(--space-8); }
474+.todo-card { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-4); }
475+.todo-card.other { background: var(--color-neutral-200); }
476+.todo-card.forme { box-shadow: inset 0 0 0 1.5px var(--color-accent-2); }
477+.todo-main { display: flex; align-items: center; gap: var(--space-3); }
478+.todo-name { font-size: 16px; }
479+.todo-meta { font-size: 13px; opacity: .7; }
480+.todo-meta.late { opacity: 1; color: var(--color-accent-700); font-weight: 600; }
481+.todo-for { font-size: 13px; margin-top: 2px; opacity: .7; }
482+.todo-for.forme { opacity: 1; color: var(--color-accent-2-900); font-weight: 600; }
483+.todo-actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
484+.todo-actions form { display: contents; }
485+.todo-actions .btn { min-height: 44px; flex: 1; }
486+.later {
487+ background: var(--color-surface); border-radius: var(--radius-lg);
488+ padding: var(--space-3) var(--space-4); margin-bottom: var(--space-4);
489+}
490+.later summary { cursor: pointer; font-size: 15px; font-weight: 700; list-style: none; }
491+.later-row {
492+ display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0;
493+ border-top: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
494+}
495+/* Chips radio/checkbox (création : catégorie, assignés). */
496+.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); position: relative; }
497+.chip {
498+ display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
499+ border-radius: 999px; cursor: pointer; font-size: 14px;
500+ border: 1px solid var(--color-divider);
501+}
502+.chip input { position: absolute; width: 1px; height: 1px; opacity: 0; }
503+.chip:has(input:checked) {
504+ border-color: transparent; background: var(--color-accent-2-200);
505+ color: var(--color-accent-2-900);
506+}
507+.chip:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: 2px; }
508+.chip .cdot { width: 10px; height: 10px; border-radius: 999px; flex: none; }
509+/* Flux « effectuée » : toggles-interrupteurs et sections estompées si off. */
510+.done-title { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-6); }
511+.done-check {
512+ width: 30px; height: 30px; border-radius: 999px; flex: none;
513+ background: var(--color-accent-700); color: var(--color-bg);
514+ display: flex; align-items: center; justify-content: center;
515+}
516+.done-name { font-family: var(--font-heading); font-size: 21px; text-wrap: pretty; }
517+.done-head { display: flex; align-items: center; gap: var(--space-3); margin: 0 0 var(--space-3); }
518+.done-head h2 { margin: 0; font-size: 15px; }
519+.swt-check {
520+ margin-left: auto; display: flex; align-items: center; gap: 8px;
521+ font-size: 13px; cursor: pointer; position: relative;
522+}
523+.swt-check input { position: absolute; width: 1px; height: 1px; opacity: 0; }
524+.swt-check:has(input:checked) .swt { background: var(--color-accent-700); }
525+.swt-check:has(input:checked) .swt .knob { transform: translateX(18px); }
526+.swt-check:has(input:focus-visible) .swt { outline: 2px solid var(--color-accent); outline-offset: 2px; }
527+.done-sec .done-body { transition: opacity .15s ease; }
528+.done-sec:has(.swt-check input:not(:checked)) .done-body { opacity: .35; }
529+/* Carte-lien « Mes saisies » (Réglages). */
530+a.times-link { flex-direction: row; align-items: center; text-decoration: none; color: var(--color-text); }
531+
446 /* — toast — */532 /* — toast — */
447 /* En haut : le bas de l'écran est déjà occupé par la pill timer et la tab bar. */533 /* En haut : le bas de l'écran est déjà occupé par la pill timer et la tab bar. */
448 .toast {534 .toast {
added migrations/0006_todos.sql +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+-- Todo list d'équipe. Une todo est UNE ligne réutilisée : la reprogrammation
2+-- la repasse « à faire » avec une nouvelle date d'activation ; l'historique
3+-- (todo_state_changes) porte les complétions, le créateur et le preneur.
4+
5+-- Table enum quasi immuable — les autres tables référencent l'id.
6+-- state_order = ordre d'affichage des blocs (en cours d'abord).
7+CREATE TABLE todo_states (
8+ id SMALLINT PRIMARY KEY,
9+ state_order SMALLINT NOT NULL,
10+ state_name TEXT NOT NULL UNIQUE,
11+ description TEXT NOT NULL
12+);
13+INSERT INTO todo_states (id, state_order, state_name, description) VALUES
14+ (1, 1, 'in_progress', 'en cours'),
15+ (2, 2, 'todo', 'à faire'),
16+ (3, 3, 'done', 'fait');
17+
18+CREATE TABLE todos (
19+ id UUID PRIMARY KEY,
20+ category_id UUID NOT NULL REFERENCES categories(id),
21+ name TEXT NOT NULL,
22+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23+ activation_date DATE NOT NULL,
24+ -- État courant dénormalisé (listing) ; la vérité historique est dans
25+ -- todo_state_changes, maintenue dans la même transaction.
26+ state_id SMALLINT NOT NULL REFERENCES todo_states(id)
27+);
28+CREATE INDEX idx_todos_listing ON todos (category_id, state_id, activation_date);
29+
30+CREATE TABLE todo_state_changes (
31+ id UUID PRIMARY KEY,
32+ todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
33+ state_id SMALLINT NOT NULL REFERENCES todo_states(id),
34+ user_id UUID NOT NULL REFERENCES users(id),
35+ changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
36+);
37+CREATE INDEX idx_todo_changes ON todo_state_changes (todo_id, changed_at);
38+
39+-- Assignations purement informatives : aucun droit, juste de l'affichage,
40+-- un tri secondaire et un filtre. Zéro ou plusieurs par todo.
41+CREATE TABLE todo_assignees (
42+ todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
43+ user_id UUID NOT NULL REFERENCES users(id),
44+ PRIMARY KEY (todo_id, user_id)
45+);
new file mode 100644
@@ -0,0 +1,45 @@
1+-- Todo list d'équipe. Une todo est UNE ligne réutilisée : la reprogrammation
2+-- la repasse « à faire » avec une nouvelle date d'activation ; l'historique
3+-- (todo_state_changes) porte les complétions, le créateur et le preneur.
4+
5+-- Table enum quasi immuable — les autres tables référencent l'id.
6+-- state_order = ordre d'affichage des blocs (en cours d'abord).
7+CREATE TABLE todo_states (
8+ id SMALLINT PRIMARY KEY,
9+ state_order SMALLINT NOT NULL,
10+ state_name TEXT NOT NULL UNIQUE,
11+ description TEXT NOT NULL
12+);
13+INSERT INTO todo_states (id, state_order, state_name, description) VALUES
14+ (1, 1, 'in_progress', 'en cours'),
15+ (2, 2, 'todo', 'à faire'),
16+ (3, 3, 'done', 'fait');
17+
18+CREATE TABLE todos (
19+ id UUID PRIMARY KEY,
20+ category_id UUID NOT NULL REFERENCES categories(id),
21+ name TEXT NOT NULL,
22+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
23+ activation_date DATE NOT NULL,
24+ -- État courant dénormalisé (listing) ; la vérité historique est dans
25+ -- todo_state_changes, maintenue dans la même transaction.
26+ state_id SMALLINT NOT NULL REFERENCES todo_states(id)
27+);
28+CREATE INDEX idx_todos_listing ON todos (category_id, state_id, activation_date);
29+
30+CREATE TABLE todo_state_changes (
31+ id UUID PRIMARY KEY,
32+ todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
33+ state_id SMALLINT NOT NULL REFERENCES todo_states(id),
34+ user_id UUID NOT NULL REFERENCES users(id),
35+ changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
36+);
37+CREATE INDEX idx_todo_changes ON todo_state_changes (todo_id, changed_at);
38+
39+-- Assignations purement informatives : aucun droit, juste de l'affichage,
40+-- un tri secondaire et un filtre. Zéro ou plusieurs par todo.
41+CREATE TABLE todo_assignees (
42+ todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE,
43+ user_id UUID NOT NULL REFERENCES users(id),
44+ PRIMARY KEY (todo_id, user_id)
45+);
modified src/db/mod.rs +1 -0
@@ -3,5 +3,6 @@ pub mod invitations;
33 pub mod stats;
44 pub mod categories;
55 pub mod timers;
6+pub mod todos;
67 pub mod teams;
78 pub mod users;
@@ -3,5 +3,6 @@ pub mod invitations;
3 pub mod stats;3 pub mod stats;
4 pub mod categories;4 pub mod categories;
5 pub mod timers;5 pub mod timers;
6+pub mod todos;
6 pub mod teams;7 pub mod teams;
7 pub mod users;8 pub mod users;
added src/db/todos.rs +401 -0
new file mode 100644
@@ -0,0 +1,401 @@
1+use chrono::{DateTime, NaiveDate, Utc};
2+use sqlx::PgPool;
3+use uuid::Uuid;
4+
5+/// États — mêmes ids que la table enum `todo_states` (seedée en migration).
6+pub const STATE_IN_PROGRESS: i16 = 1;
7+pub const STATE_TODO: i16 = 2;
8+pub const STATE_DONE: i16 = 3;
9+
10+#[derive(sqlx::FromRow)]
11+pub struct TodoRow {
12+ pub id: Uuid,
13+ pub name: String,
14+ pub category_id: Uuid,
15+ pub activation_date: NaiveDate,
16+ pub state_id: i16,
17+ pub created_at: DateTime<Utc>,
18+ /// Preneur si « en cours » (dernier passage à in_progress).
19+ pub taker_id: Option<Uuid>,
20+ pub taker_name: Option<String>,
21+ pub taken_at: Option<DateTime<Utc>>,
22+}
23+
24+#[derive(sqlx::FromRow)]
25+pub struct AssigneeRow {
26+ pub todo_id: Uuid,
27+ pub user_id: Uuid,
28+ pub username: String,
29+}
30+
31+/// Clause commune : la todo appartient à l'équipe active ET, si elle est
32+/// « en cours », seul son preneur peut agir dessus. Toujours en SQL.
33+const GUARD: &str = "t.id = $1
34+ AND EXISTS (SELECT 1 FROM categories c WHERE c.id = t.category_id AND c.team_id = $2)
35+ AND (t.state_id <> 1 OR $3 = (
36+ SELECT sc.user_id FROM todo_state_changes sc
37+ WHERE sc.todo_id = t.id AND sc.state_id = 1
38+ ORDER BY sc.changed_at DESC LIMIT 1))";
39+
40+/// Toutes les todos non faites de l'équipe (les trois blocs se filtrent en Rust).
41+pub async fn list_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TodoRow>> {
42+ sqlx::query_as(
43+ "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at,
44+ tk.user_id AS taker_id, tk.username AS taker_name, tk.changed_at AS taken_at
45+ FROM todos t
46+ JOIN categories c ON c.id = t.category_id
47+ LEFT JOIN LATERAL (
48+ SELECT sc.user_id, u.username, sc.changed_at
49+ FROM todo_state_changes sc JOIN users u ON u.id = sc.user_id
50+ WHERE sc.todo_id = t.id AND sc.state_id = 1 AND t.state_id = 1
51+ ORDER BY sc.changed_at DESC LIMIT 1
52+ ) tk ON true
53+ WHERE c.team_id = $1 AND t.state_id <> 3
54+ ORDER BY t.activation_date, t.id",
55+ )
56+ .bind(team_id)
57+ .fetch_all(pool)
58+ .await
59+}
60+
61+/// Assignés de toutes les todos ouvertes de l'équipe, triés par username.
62+pub async fn assignees_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<AssigneeRow>> {
63+ sqlx::query_as(
64+ "SELECT a.todo_id, a.user_id, u.username
65+ FROM todo_assignees a
66+ JOIN users u ON u.id = a.user_id
67+ JOIN todos t ON t.id = a.todo_id
68+ JOIN categories c ON c.id = t.category_id
69+ WHERE c.team_id = $1 AND t.state_id <> 3
70+ ORDER BY u.username",
71+ )
72+ .bind(team_id)
73+ .fetch_all(pool)
74+ .await
75+}
76+
77+/// Nombre de todos « à faire » actives (badge de l'onglet Tâches).
78+pub async fn open_count(pool: &PgPool, team_id: Uuid, today: NaiveDate) -> sqlx::Result<i64> {
79+ sqlx::query_scalar(
80+ "SELECT COUNT(*) FROM todos t
81+ JOIN categories c ON c.id = t.category_id
82+ WHERE c.team_id = $1 AND t.state_id = 2 AND t.activation_date <= $2",
83+ )
84+ .bind(team_id)
85+ .bind(today)
86+ .fetch_one(pool)
87+ .await
88+}
89+
90+/// Création : la catégorie doit être de l'équipe, les assignés membres de
91+/// l'équipe (les autres ids sont ignorés silencieusement). Une ligne
92+/// d'historique « à faire » porte le créateur.
93+pub async fn create(
94+ pool: &PgPool,
95+ team_id: Uuid,
96+ me: Uuid,
97+ category_id: Uuid,
98+ name: &str,
99+ activation: NaiveDate,
100+ assignees: &[Uuid],
101+) -> sqlx::Result<u64> {
102+ let mut tx = pool.begin().await?;
103+ let id = Uuid::now_v7();
104+ let created = sqlx::query(
105+ "INSERT INTO todos (id, category_id, name, activation_date, state_id)
106+ SELECT $1, $2, $3, $4, 2
107+ WHERE EXISTS (SELECT 1 FROM categories WHERE id = $2 AND team_id = $5)",
108+ )
109+ .bind(id)
110+ .bind(category_id)
111+ .bind(name)
112+ .bind(activation)
113+ .bind(team_id)
114+ .execute(&mut *tx)
115+ .await?
116+ .rows_affected();
117+ if created == 0 {
118+ return Ok(0);
119+ }
120+ log_change(&mut tx, id, STATE_TODO, me).await?;
121+ set_assignees(&mut tx, id, team_id, assignees).await?;
122+ tx.commit().await?;
123+ Ok(1)
124+}
125+
126+/// Todo éditable par moi (garde équipe + preneur) — pour l'écran d'édition.
127+pub async fn get_editable(
128+ pool: &PgPool,
129+ team_id: Uuid,
130+ me: Uuid,
131+ id: Uuid,
132+) -> sqlx::Result<Option<TodoRow>> {
133+ sqlx::query_as(&format!(
134+ "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at,
135+ NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at
136+ FROM todos t WHERE {GUARD} AND t.state_id <> 3"
137+ ))
138+ .bind(id)
139+ .bind(team_id)
140+ .bind(me)
141+ .fetch_optional(pool)
142+ .await
143+}
144+
145+/// Assignés d'une seule todo.
146+pub async fn assignees_of(pool: &PgPool, id: Uuid) -> sqlx::Result<Vec<Uuid>> {
147+ sqlx::query_scalar("SELECT user_id FROM todo_assignees WHERE todo_id = $1")
148+ .bind(id)
149+ .fetch_all(pool)
150+ .await
151+}
152+
153+/// Édition (nom, catégorie de la même équipe, date, assignés).
154+pub async fn update(
155+ pool: &PgPool,
156+ team_id: Uuid,
157+ me: Uuid,
158+ id: Uuid,
159+ category_id: Uuid,
160+ name: &str,
161+ activation: NaiveDate,
162+ assignees: &[Uuid],
163+) -> sqlx::Result<u64> {
164+ let mut tx = pool.begin().await?;
165+ let n = sqlx::query(&format!(
166+ "UPDATE todos t SET category_id = $4, name = $5, activation_date = $6
167+ WHERE {GUARD} AND t.state_id <> 3
168+ AND EXISTS (SELECT 1 FROM categories WHERE id = $4 AND team_id = $2)"
169+ ))
170+ .bind(id)
171+ .bind(team_id)
172+ .bind(me)
173+ .bind(category_id)
174+ .bind(name)
175+ .bind(activation)
176+ .execute(&mut *tx)
177+ .await?
178+ .rows_affected();
179+ if n == 0 {
180+ return Ok(0);
181+ }
182+ sqlx::query("DELETE FROM todo_assignees WHERE todo_id = $1")
183+ .bind(id)
184+ .execute(&mut *tx)
185+ .await?;
186+ set_assignees(&mut tx, id, team_id, assignees).await?;
187+ tx.commit().await?;
188+ Ok(1)
189+}
190+
191+/// Suppression (interdite une fois faite) — l'historique part en cascade.
192+pub async fn delete(pool: &PgPool, team_id: Uuid, me: Uuid, id: Uuid) -> sqlx::Result<u64> {
193+ Ok(sqlx::query(&format!(
194+ "DELETE FROM todos t WHERE {GUARD} AND t.state_id <> 3"
195+ ))
196+ .bind(id)
197+ .bind(team_id)
198+ .bind(me)
199+ .execute(pool)
200+ .await?
201+ .rows_affected())
202+}
203+
204+/// à faire → en cours (n'importe quel membre : la garde preneur ne joue pas
205+/// puisque l'état exigé est « à faire »).
206+pub async fn take(pool: &PgPool, team_id: Uuid, me: Uuid, id: Uuid) -> sqlx::Result<u64> {
207+ transition(pool, team_id, me, id, STATE_TODO, STATE_IN_PROGRESS).await
208+}
209+
210+/// en cours → à faire, par le preneur seulement (garde SQL).
211+pub async fn release(pool: &PgPool, team_id: Uuid, me: Uuid, id: Uuid) -> sqlx::Result<u64> {
212+ transition(pool, team_id, me, id, STATE_IN_PROGRESS, STATE_TODO).await
213+}
214+
215+async fn transition(
216+ pool: &PgPool,
217+ team_id: Uuid,
218+ me: Uuid,
219+ id: Uuid,
220+ from: i16,
221+ to: i16,
222+) -> sqlx::Result<u64> {
223+ let mut tx = pool.begin().await?;
224+ let n = sqlx::query(&format!(
225+ "UPDATE todos t SET state_id = $4 WHERE {GUARD} AND t.state_id = $5"
226+ ))
227+ .bind(id)
228+ .bind(team_id)
229+ .bind(me)
230+ .bind(to)
231+ .bind(from)
232+ .execute(&mut *tx)
233+ .await?
234+ .rows_affected();
235+ if n == 0 {
236+ return Ok(0);
237+ }
238+ log_change(&mut tx, id, to, me).await?;
239+ tx.commit().await?;
240+ Ok(1)
241+}
242+
243+/// Todo terminable par moi (à faire ou en cours dont je suis preneur) — pour
244+/// l'écran de complétion. Retourne aussi le nom de la catégorie.
245+pub async fn get_finishable(
246+ pool: &PgPool,
247+ team_id: Uuid,
248+ me: Uuid,
249+ id: Uuid,
250+) -> sqlx::Result<Option<TodoRow>> {
251+ sqlx::query_as(&format!(
252+ "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at,
253+ NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at
254+ FROM todos t WHERE {GUARD} AND t.state_id IN (1, 2)"
255+ ))
256+ .bind(id)
257+ .bind(team_id)
258+ .bind(me)
259+ .fetch_optional(pool)
260+ .await
261+}
262+
263+/// Dates (Paris, côté appelant : on renvoie les timestamps) des complétions
264+/// passées d'une todo.
265+pub async fn done_at(pool: &PgPool, id: Uuid) -> sqlx::Result<Vec<DateTime<Utc>>> {
266+ sqlx::query_scalar(
267+ "SELECT changed_at FROM todo_state_changes
268+ WHERE todo_id = $1 AND state_id = 3 ORDER BY changed_at",
269+ )
270+ .bind(id)
271+ .fetch_all(pool)
272+ .await
273+}
274+
275+/// Complétion en un seul POST : historique « fait », temps optionnel crédité
276+/// au marqueur dans la catégorie de la todo, reprogrammation optionnelle
277+/// (même ligne repassée « à faire »). 0 = todo introuvable/interdite.
278+pub async fn finish(
279+ pool: &PgPool,
280+ team_id: Uuid,
281+ me: Uuid,
282+ id: Uuid,
283+ time: Option<(i32, NaiveDate)>,
284+ next: Option<NaiveDate>,
285+) -> sqlx::Result<u64> {
286+ let mut tx = pool.begin().await?;
287+ let n = sqlx::query(&format!(
288+ "UPDATE todos t SET state_id = 3 WHERE {GUARD} AND t.state_id IN (1, 2)"
289+ ))
290+ .bind(id)
291+ .bind(team_id)
292+ .bind(me)
293+ .execute(&mut *tx)
294+ .await?
295+ .rows_affected();
296+ if n == 0 {
297+ return Ok(0);
298+ }
299+ log_change(&mut tx, id, STATE_DONE, me).await?;
300+ if let Some((minutes, date)) = time {
301+ sqlx::query(
302+ "INSERT INTO time_entries (id, user_id, category_id, minutes, entry_date)
303+ SELECT $1, $2, t.category_id, $3, $4 FROM todos t WHERE t.id = $5",
304+ )
305+ .bind(Uuid::now_v7())
306+ .bind(me)
307+ .bind(minutes)
308+ .bind(date)
309+ .bind(id)
310+ .execute(&mut *tx)
311+ .await?;
312+ }
313+ if let Some(date) = next {
314+ sqlx::query("UPDATE todos SET state_id = 2, activation_date = $2 WHERE id = $1")
315+ .bind(id)
316+ .bind(date)
317+ .execute(&mut *tx)
318+ .await?;
319+ log_change(&mut tx, id, STATE_TODO, me).await?;
320+ }
321+ tx.commit().await?;
322+ Ok(1)
323+}
324+
325+/// Départ d'équipe : les todos que je tenais repassent « à faire » et mes
326+/// assignations dans cette équipe disparaissent — rien d'autre ne bouge.
327+pub async fn on_member_leave(
328+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
329+ team_id: Uuid,
330+ user_id: Uuid,
331+) -> sqlx::Result<()> {
332+ let released: Vec<Uuid> = sqlx::query_scalar(
333+ "UPDATE todos t SET state_id = 2
334+ FROM categories c
335+ WHERE c.id = t.category_id AND c.team_id = $1 AND t.state_id = 1
336+ AND $2 = (SELECT sc.user_id FROM todo_state_changes sc
337+ WHERE sc.todo_id = t.id AND sc.state_id = 1
338+ ORDER BY sc.changed_at DESC LIMIT 1)
339+ RETURNING t.id",
340+ )
341+ .bind(team_id)
342+ .bind(user_id)
343+ .fetch_all(&mut **tx)
344+ .await?;
345+ for id in released {
346+ log_change(tx, id, STATE_TODO, user_id).await?;
347+ }
348+ sqlx::query(
349+ "DELETE FROM todo_assignees a
350+ USING todos t, categories c
351+ WHERE a.todo_id = t.id AND c.id = t.category_id
352+ AND c.team_id = $1 AND a.user_id = $2",
353+ )
354+ .bind(team_id)
355+ .bind(user_id)
356+ .execute(&mut **tx)
357+ .await?;
358+ Ok(())
359+}
360+
361+async fn log_change(
362+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
363+ todo_id: Uuid,
364+ state_id: i16,
365+ user_id: Uuid,
366+) -> sqlx::Result<()> {
367+ sqlx::query(
368+ "INSERT INTO todo_state_changes (id, todo_id, state_id, user_id) VALUES ($1, $2, $3, $4)",
369+ )
370+ .bind(Uuid::now_v7())
371+ .bind(todo_id)
372+ .bind(state_id)
373+ .bind(user_id)
374+ .execute(&mut **tx)
375+ .await?;
376+ Ok(())
377+}
378+
379+/// N'insère que les assignés réellement membres de l'équipe.
380+async fn set_assignees(
381+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
382+ todo_id: Uuid,
383+ team_id: Uuid,
384+ assignees: &[Uuid],
385+) -> sqlx::Result<()> {
386+ if assignees.is_empty() {
387+ return Ok(());
388+ }
389+ sqlx::query(
390+ "INSERT INTO todo_assignees (todo_id, user_id)
391+ SELECT $1, tm.user_id FROM team_members tm
392+ WHERE tm.team_id = $2 AND tm.user_id = ANY($3)
393+ ON CONFLICT DO NOTHING",
394+ )
395+ .bind(todo_id)
396+ .bind(team_id)
397+ .bind(assignees)
398+ .execute(&mut **tx)
399+ .await?;
400+ Ok(())
401+}
new file mode 100644
@@ -0,0 +1,401 @@
1+use chrono::{DateTime, NaiveDate, Utc};
2+use sqlx::PgPool;
3+use uuid::Uuid;
4+
5+/// États — mêmes ids que la table enum `todo_states` (seedée en migration).
6+pub const STATE_IN_PROGRESS: i16 = 1;
7+pub const STATE_TODO: i16 = 2;
8+pub const STATE_DONE: i16 = 3;
9+
10+#[derive(sqlx::FromRow)]
11+pub struct TodoRow {
12+ pub id: Uuid,
13+ pub name: String,
14+ pub category_id: Uuid,
15+ pub activation_date: NaiveDate,
16+ pub state_id: i16,
17+ pub created_at: DateTime<Utc>,
18+ /// Preneur si « en cours » (dernier passage à in_progress).
19+ pub taker_id: Option<Uuid>,
20+ pub taker_name: Option<String>,
21+ pub taken_at: Option<DateTime<Utc>>,
22+}
23+
24+#[derive(sqlx::FromRow)]
25+pub struct AssigneeRow {
26+ pub todo_id: Uuid,
27+ pub user_id: Uuid,
28+ pub username: String,
29+}
30+
31+/// Clause commune : la todo appartient à l'équipe active ET, si elle est
32+/// « en cours », seul son preneur peut agir dessus. Toujours en SQL.
33+const GUARD: &str = "t.id = $1
34+ AND EXISTS (SELECT 1 FROM categories c WHERE c.id = t.category_id AND c.team_id = $2)
35+ AND (t.state_id <> 1 OR $3 = (
36+ SELECT sc.user_id FROM todo_state_changes sc
37+ WHERE sc.todo_id = t.id AND sc.state_id = 1
38+ ORDER BY sc.changed_at DESC LIMIT 1))";
39+
40+/// Toutes les todos non faites de l'équipe (les trois blocs se filtrent en Rust).
41+pub async fn list_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TodoRow>> {
42+ sqlx::query_as(
43+ "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at,
44+ tk.user_id AS taker_id, tk.username AS taker_name, tk.changed_at AS taken_at
45+ FROM todos t
46+ JOIN categories c ON c.id = t.category_id
47+ LEFT JOIN LATERAL (
48+ SELECT sc.user_id, u.username, sc.changed_at
49+ FROM todo_state_changes sc JOIN users u ON u.id = sc.user_id
50+ WHERE sc.todo_id = t.id AND sc.state_id = 1 AND t.state_id = 1
51+ ORDER BY sc.changed_at DESC LIMIT 1
52+ ) tk ON true
53+ WHERE c.team_id = $1 AND t.state_id <> 3
54+ ORDER BY t.activation_date, t.id",
55+ )
56+ .bind(team_id)
57+ .fetch_all(pool)
58+ .await
59+}
60+
61+/// Assignés de toutes les todos ouvertes de l'équipe, triés par username.
62+pub async fn assignees_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<AssigneeRow>> {
63+ sqlx::query_as(
64+ "SELECT a.todo_id, a.user_id, u.username
65+ FROM todo_assignees a
66+ JOIN users u ON u.id = a.user_id
67+ JOIN todos t ON t.id = a.todo_id
68+ JOIN categories c ON c.id = t.category_id
69+ WHERE c.team_id = $1 AND t.state_id <> 3
70+ ORDER BY u.username",
71+ )
72+ .bind(team_id)
73+ .fetch_all(pool)
74+ .await
75+}
76+
77+/// Nombre de todos « à faire » actives (badge de l'onglet Tâches).
78+pub async fn open_count(pool: &PgPool, team_id: Uuid, today: NaiveDate) -> sqlx::Result<i64> {
79+ sqlx::query_scalar(
80+ "SELECT COUNT(*) FROM todos t
81+ JOIN categories c ON c.id = t.category_id
82+ WHERE c.team_id = $1 AND t.state_id = 2 AND t.activation_date <= $2",
83+ )
84+ .bind(team_id)
85+ .bind(today)
86+ .fetch_one(pool)
87+ .await
88+}
89+
90+/// Création : la catégorie doit être de l'équipe, les assignés membres de
91+/// l'équipe (les autres ids sont ignorés silencieusement). Une ligne
92+/// d'historique « à faire » porte le créateur.
93+pub async fn create(
94+ pool: &PgPool,
95+ team_id: Uuid,
96+ me: Uuid,
97+ category_id: Uuid,
98+ name: &str,
99+ activation: NaiveDate,
100+ assignees: &[Uuid],
101+) -> sqlx::Result<u64> {
102+ let mut tx = pool.begin().await?;
103+ let id = Uuid::now_v7();
104+ let created = sqlx::query(
105+ "INSERT INTO todos (id, category_id, name, activation_date, state_id)
106+ SELECT $1, $2, $3, $4, 2
107+ WHERE EXISTS (SELECT 1 FROM categories WHERE id = $2 AND team_id = $5)",
108+ )
109+ .bind(id)
110+ .bind(category_id)
111+ .bind(name)
112+ .bind(activation)
113+ .bind(team_id)
114+ .execute(&mut *tx)
115+ .await?
116+ .rows_affected();
117+ if created == 0 {
118+ return Ok(0);
119+ }
120+ log_change(&mut tx, id, STATE_TODO, me).await?;
121+ set_assignees(&mut tx, id, team_id, assignees).await?;
122+ tx.commit().await?;
123+ Ok(1)
124+}
125+
126+/// Todo éditable par moi (garde équipe + preneur) — pour l'écran d'édition.
127+pub async fn get_editable(
128+ pool: &PgPool,
129+ team_id: Uuid,
130+ me: Uuid,
131+ id: Uuid,
132+) -> sqlx::Result<Option<TodoRow>> {
133+ sqlx::query_as(&format!(
134+ "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at,
135+ NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at
136+ FROM todos t WHERE {GUARD} AND t.state_id <> 3"
137+ ))
138+ .bind(id)
139+ .bind(team_id)
140+ .bind(me)
141+ .fetch_optional(pool)
142+ .await
143+}
144+
145+/// Assignés d'une seule todo.
146+pub async fn assignees_of(pool: &PgPool, id: Uuid) -> sqlx::Result<Vec<Uuid>> {
147+ sqlx::query_scalar("SELECT user_id FROM todo_assignees WHERE todo_id = $1")
148+ .bind(id)
149+ .fetch_all(pool)
150+ .await
151+}
152+
153+/// Édition (nom, catégorie de la même équipe, date, assignés).
154+pub async fn update(
155+ pool: &PgPool,
156+ team_id: Uuid,
157+ me: Uuid,
158+ id: Uuid,
159+ category_id: Uuid,
160+ name: &str,
161+ activation: NaiveDate,
162+ assignees: &[Uuid],
163+) -> sqlx::Result<u64> {
164+ let mut tx = pool.begin().await?;
165+ let n = sqlx::query(&format!(
166+ "UPDATE todos t SET category_id = $4, name = $5, activation_date = $6
167+ WHERE {GUARD} AND t.state_id <> 3
168+ AND EXISTS (SELECT 1 FROM categories WHERE id = $4 AND team_id = $2)"
169+ ))
170+ .bind(id)
171+ .bind(team_id)
172+ .bind(me)
173+ .bind(category_id)
174+ .bind(name)
175+ .bind(activation)
176+ .execute(&mut *tx)
177+ .await?
178+ .rows_affected();
179+ if n == 0 {
180+ return Ok(0);
181+ }
182+ sqlx::query("DELETE FROM todo_assignees WHERE todo_id = $1")
183+ .bind(id)
184+ .execute(&mut *tx)
185+ .await?;
186+ set_assignees(&mut tx, id, team_id, assignees).await?;
187+ tx.commit().await?;
188+ Ok(1)
189+}
190+
191+/// Suppression (interdite une fois faite) — l'historique part en cascade.
192+pub async fn delete(pool: &PgPool, team_id: Uuid, me: Uuid, id: Uuid) -> sqlx::Result<u64> {
193+ Ok(sqlx::query(&format!(
194+ "DELETE FROM todos t WHERE {GUARD} AND t.state_id <> 3"
195+ ))
196+ .bind(id)
197+ .bind(team_id)
198+ .bind(me)
199+ .execute(pool)
200+ .await?
201+ .rows_affected())
202+}
203+
204+/// à faire → en cours (n'importe quel membre : la garde preneur ne joue pas
205+/// puisque l'état exigé est « à faire »).
206+pub async fn take(pool: &PgPool, team_id: Uuid, me: Uuid, id: Uuid) -> sqlx::Result<u64> {
207+ transition(pool, team_id, me, id, STATE_TODO, STATE_IN_PROGRESS).await
208+}
209+
210+/// en cours → à faire, par le preneur seulement (garde SQL).
211+pub async fn release(pool: &PgPool, team_id: Uuid, me: Uuid, id: Uuid) -> sqlx::Result<u64> {
212+ transition(pool, team_id, me, id, STATE_IN_PROGRESS, STATE_TODO).await
213+}
214+
215+async fn transition(
216+ pool: &PgPool,
217+ team_id: Uuid,
218+ me: Uuid,
219+ id: Uuid,
220+ from: i16,
221+ to: i16,
222+) -> sqlx::Result<u64> {
223+ let mut tx = pool.begin().await?;
224+ let n = sqlx::query(&format!(
225+ "UPDATE todos t SET state_id = $4 WHERE {GUARD} AND t.state_id = $5"
226+ ))
227+ .bind(id)
228+ .bind(team_id)
229+ .bind(me)
230+ .bind(to)
231+ .bind(from)
232+ .execute(&mut *tx)
233+ .await?
234+ .rows_affected();
235+ if n == 0 {
236+ return Ok(0);
237+ }
238+ log_change(&mut tx, id, to, me).await?;
239+ tx.commit().await?;
240+ Ok(1)
241+}
242+
243+/// Todo terminable par moi (à faire ou en cours dont je suis preneur) — pour
244+/// l'écran de complétion. Retourne aussi le nom de la catégorie.
245+pub async fn get_finishable(
246+ pool: &PgPool,
247+ team_id: Uuid,
248+ me: Uuid,
249+ id: Uuid,
250+) -> sqlx::Result<Option<TodoRow>> {
251+ sqlx::query_as(&format!(
252+ "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at,
253+ NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at
254+ FROM todos t WHERE {GUARD} AND t.state_id IN (1, 2)"
255+ ))
256+ .bind(id)
257+ .bind(team_id)
258+ .bind(me)
259+ .fetch_optional(pool)
260+ .await
261+}
262+
263+/// Dates (Paris, côté appelant : on renvoie les timestamps) des complétions
264+/// passées d'une todo.
265+pub async fn done_at(pool: &PgPool, id: Uuid) -> sqlx::Result<Vec<DateTime<Utc>>> {
266+ sqlx::query_scalar(
267+ "SELECT changed_at FROM todo_state_changes
268+ WHERE todo_id = $1 AND state_id = 3 ORDER BY changed_at",
269+ )
270+ .bind(id)
271+ .fetch_all(pool)
272+ .await
273+}
274+
275+/// Complétion en un seul POST : historique « fait », temps optionnel crédité
276+/// au marqueur dans la catégorie de la todo, reprogrammation optionnelle
277+/// (même ligne repassée « à faire »). 0 = todo introuvable/interdite.
278+pub async fn finish(
279+ pool: &PgPool,
280+ team_id: Uuid,
281+ me: Uuid,
282+ id: Uuid,
283+ time: Option<(i32, NaiveDate)>,
284+ next: Option<NaiveDate>,
285+) -> sqlx::Result<u64> {
286+ let mut tx = pool.begin().await?;
287+ let n = sqlx::query(&format!(
288+ "UPDATE todos t SET state_id = 3 WHERE {GUARD} AND t.state_id IN (1, 2)"
289+ ))
290+ .bind(id)
291+ .bind(team_id)
292+ .bind(me)
293+ .execute(&mut *tx)
294+ .await?
295+ .rows_affected();
296+ if n == 0 {
297+ return Ok(0);
298+ }
299+ log_change(&mut tx, id, STATE_DONE, me).await?;
300+ if let Some((minutes, date)) = time {
301+ sqlx::query(
302+ "INSERT INTO time_entries (id, user_id, category_id, minutes, entry_date)
303+ SELECT $1, $2, t.category_id, $3, $4 FROM todos t WHERE t.id = $5",
304+ )
305+ .bind(Uuid::now_v7())
306+ .bind(me)
307+ .bind(minutes)
308+ .bind(date)
309+ .bind(id)
310+ .execute(&mut *tx)
311+ .await?;
312+ }
313+ if let Some(date) = next {
314+ sqlx::query("UPDATE todos SET state_id = 2, activation_date = $2 WHERE id = $1")
315+ .bind(id)
316+ .bind(date)
317+ .execute(&mut *tx)
318+ .await?;
319+ log_change(&mut tx, id, STATE_TODO, me).await?;
320+ }
321+ tx.commit().await?;
322+ Ok(1)
323+}
324+
325+/// Départ d'équipe : les todos que je tenais repassent « à faire » et mes
326+/// assignations dans cette équipe disparaissent — rien d'autre ne bouge.
327+pub async fn on_member_leave(
328+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
329+ team_id: Uuid,
330+ user_id: Uuid,
331+) -> sqlx::Result<()> {
332+ let released: Vec<Uuid> = sqlx::query_scalar(
333+ "UPDATE todos t SET state_id = 2
334+ FROM categories c
335+ WHERE c.id = t.category_id AND c.team_id = $1 AND t.state_id = 1
336+ AND $2 = (SELECT sc.user_id FROM todo_state_changes sc
337+ WHERE sc.todo_id = t.id AND sc.state_id = 1
338+ ORDER BY sc.changed_at DESC LIMIT 1)
339+ RETURNING t.id",
340+ )
341+ .bind(team_id)
342+ .bind(user_id)
343+ .fetch_all(&mut **tx)
344+ .await?;
345+ for id in released {
346+ log_change(tx, id, STATE_TODO, user_id).await?;
347+ }
348+ sqlx::query(
349+ "DELETE FROM todo_assignees a
350+ USING todos t, categories c
351+ WHERE a.todo_id = t.id AND c.id = t.category_id
352+ AND c.team_id = $1 AND a.user_id = $2",
353+ )
354+ .bind(team_id)
355+ .bind(user_id)
356+ .execute(&mut **tx)
357+ .await?;
358+ Ok(())
359+}
360+
361+async fn log_change(
362+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
363+ todo_id: Uuid,
364+ state_id: i16,
365+ user_id: Uuid,
366+) -> sqlx::Result<()> {
367+ sqlx::query(
368+ "INSERT INTO todo_state_changes (id, todo_id, state_id, user_id) VALUES ($1, $2, $3, $4)",
369+ )
370+ .bind(Uuid::now_v7())
371+ .bind(todo_id)
372+ .bind(state_id)
373+ .bind(user_id)
374+ .execute(&mut **tx)
375+ .await?;
376+ Ok(())
377+}
378+
379+/// N'insère que les assignés réellement membres de l'équipe.
380+async fn set_assignees(
381+ tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
382+ todo_id: Uuid,
383+ team_id: Uuid,
384+ assignees: &[Uuid],
385+) -> sqlx::Result<()> {
386+ if assignees.is_empty() {
387+ return Ok(());
388+ }
389+ sqlx::query(
390+ "INSERT INTO todo_assignees (todo_id, user_id)
391+ SELECT $1, tm.user_id FROM team_members tm
392+ WHERE tm.team_id = $2 AND tm.user_id = ANY($3)
393+ ON CONFLICT DO NOTHING",
394+ )
395+ .bind(todo_id)
396+ .bind(team_id)
397+ .bind(assignees)
398+ .execute(&mut **tx)
399+ .await?;
400+ Ok(())
401+}
modified src/main.rs +1 -0
@@ -7,6 +7,7 @@ mod error;
77 mod period;
88 mod render;
99 mod routes;
10+mod schedule;
1011 mod state;
1112 mod team;
1213
@@ -7,6 +7,7 @@ mod error;
7 mod period;7 mod period;
8 mod render;8 mod render;
9 mod routes;9 mod routes;
10+mod schedule;
10 mod state;11 mod state;
11 mod team;12 mod team;
12 13
modified src/render.rs +2 -0
@@ -54,6 +54,8 @@ pub struct Shell {
5454 pub toast: Option<String>,
5555 /// Invitations en attente — badge sur l'onglet Réglages.
5656 pub invite_count: i64,
57+ /// Tâches « à faire » actives de l'équipe — badge sur l'onglet Tâches.
58+ pub todo_count: i64,
5759 /// Jeton des forms de la pill timer (démarrer / arrêter).
5860 pub csrf: String,
5961 /// Où revenir après un démarrage de timer (page courante, déduite du tab).
@@ -54,6 +54,8 @@ pub struct Shell {
54 pub toast: Option<String>,54 pub toast: Option<String>,
55 /// Invitations en attente — badge sur l'onglet Réglages.55 /// Invitations en attente — badge sur l'onglet Réglages.
56 pub invite_count: i64,56 pub invite_count: i64,
57+ /// Tâches « à faire » actives de l'équipe — badge sur l'onglet Tâches.
58+ pub todo_count: i64,
57 /// Jeton des forms de la pill timer (démarrer / arrêter).59 /// Jeton des forms de la pill timer (démarrer / arrêter).
58 pub csrf: String,60 pub csrf: String,
59 /// Où revenir après un démarrage de timer (page courante, déduite du tab).61 /// Où revenir après un démarrage de timer (page courante, déduite du tab).
modified src/routes/mod.rs +17 -1
@@ -5,6 +5,7 @@ mod statics;
55 mod stats;
66 mod teams;
77 mod timer;
8+mod todos;
89
910 use axum::Router;
1011 use axum::extract::Request;
@@ -15,7 +16,7 @@ use axum::routing::{get, post};
1516
1617 use crate::auth::extractor::AuthUser;
1718 use crate::charts::{fmt_minutes, team_color};
18-use crate::db::{invitations, teams as teams_db, timers, users};
19+use crate::db::{invitations, teams as teams_db, timers, todos as todos_db, users};
1920 use crate::error::AppError;
2021 use crate::render::{Shell, TeamChoiceView, TimerView};
2122 use crate::state::AppState;
@@ -46,6 +47,17 @@ pub fn router(state: AppState) -> Router {
4647 "/parametres/mot-de-passe",
4748 post(settings::password_change),
4849 )
50+ .route("/taches", get(todos::todos_page).post(todos::todo_create))
51+ .route("/taches/nouvelle", get(todos::todo_new))
52+ .route("/taches/{id}/modifier", get(todos::todo_edit))
53+ .route("/taches/{id}", post(todos::todo_update))
54+ .route("/taches/{id}/supprimer", post(todos::todo_delete))
55+ .route("/taches/{id}/prendre", post(todos::todo_take))
56+ .route("/taches/{id}/relacher", post(todos::todo_release))
57+ .route(
58+ "/taches/{id}/terminer",
59+ get(todos::todo_done_page).post(todos::todo_finish),
60+ )
4961 .route("/timer/demarrer", post(timer::timer_start))
5062 .route("/timer/arreter", post(timer::timer_stop))
5163 .route("/timer/abandonner", post(timer::timer_abandon))
@@ -122,6 +134,8 @@ pub async fn shell(
122134 .await?
123135 .unwrap_or_default();
124136 let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?;
137+ let todo_count =
138+ todos_db::open_count(&state.pool, ctx.team_id, crate::period::paris_today()).await?;
125139 let timer_row = timers::get(&state.pool, ctx.user.id).await?;
126140 let badge = timer_row.is_some();
127141 let timer = timer_row.map(|t| {
@@ -163,9 +177,11 @@ pub async fn shell(
163177 start_return: match tab {
164178 "stats" => "/stats",
165179 "times" => "/temps",
180+ "todos" => "/taches",
166181 "settings" => "/parametres",
167182 _ => "/temps/nouveau",
168183 },
184+ todo_count,
169185 timer,
170186 show_start: false,
171187 badge,
@@ -5,6 +5,7 @@ mod statics;
5 mod stats;5 mod stats;
6 mod teams;6 mod teams;
7 mod timer;7 mod timer;
8+mod todos;
8 9
9 use axum::Router;10 use axum::Router;
10 use axum::extract::Request;11 use axum::extract::Request;
@@ -15,7 +16,7 @@ use axum::routing::{get, post};
15 16
16 use crate::auth::extractor::AuthUser;17 use crate::auth::extractor::AuthUser;
17 use crate::charts::{fmt_minutes, team_color};18 use crate::charts::{fmt_minutes, team_color};
18-use crate::db::{invitations, teams as teams_db, timers, users};19+use crate::db::{invitations, teams as teams_db, timers, todos as todos_db, users};
19 use crate::error::AppError;20 use crate::error::AppError;
20 use crate::render::{Shell, TeamChoiceView, TimerView};21 use crate::render::{Shell, TeamChoiceView, TimerView};
21 use crate::state::AppState;22 use crate::state::AppState;
@@ -46,6 +47,17 @@ pub fn router(state: AppState) -> Router {
46 "/parametres/mot-de-passe",47 "/parametres/mot-de-passe",
47 post(settings::password_change),48 post(settings::password_change),
48 )49 )
50+ .route("/taches", get(todos::todos_page).post(todos::todo_create))
51+ .route("/taches/nouvelle", get(todos::todo_new))
52+ .route("/taches/{id}/modifier", get(todos::todo_edit))
53+ .route("/taches/{id}", post(todos::todo_update))
54+ .route("/taches/{id}/supprimer", post(todos::todo_delete))
55+ .route("/taches/{id}/prendre", post(todos::todo_take))
56+ .route("/taches/{id}/relacher", post(todos::todo_release))
57+ .route(
58+ "/taches/{id}/terminer",
59+ get(todos::todo_done_page).post(todos::todo_finish),
60+ )
49 .route("/timer/demarrer", post(timer::timer_start))61 .route("/timer/demarrer", post(timer::timer_start))
50 .route("/timer/arreter", post(timer::timer_stop))62 .route("/timer/arreter", post(timer::timer_stop))
51 .route("/timer/abandonner", post(timer::timer_abandon))63 .route("/timer/abandonner", post(timer::timer_abandon))
@@ -122,6 +134,8 @@ pub async fn shell(
122 .await?134 .await?
123 .unwrap_or_default();135 .unwrap_or_default();
124 let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?;136 let invite_count = invitations::count_for(&state.pool, ctx.user.id).await?;
137+ let todo_count =
138+ todos_db::open_count(&state.pool, ctx.team_id, crate::period::paris_today()).await?;
125 let timer_row = timers::get(&state.pool, ctx.user.id).await?;139 let timer_row = timers::get(&state.pool, ctx.user.id).await?;
126 let badge = timer_row.is_some();140 let badge = timer_row.is_some();
127 let timer = timer_row.map(|t| {141 let timer = timer_row.map(|t| {
@@ -163,9 +177,11 @@ pub async fn shell(
163 start_return: match tab {177 start_return: match tab {
164 "stats" => "/stats",178 "stats" => "/stats",
165 "times" => "/temps",179 "times" => "/temps",
180+ "todos" => "/taches",
166 "settings" => "/parametres",181 "settings" => "/parametres",
167 _ => "/temps/nouveau",182 _ => "/temps/nouveau",
168 },183 },
184+ todo_count,
169 timer,185 timer,
170 show_start: false,186 show_start: false,
171 badge,187 badge,
modified src/routes/teams.rs +4 -0
@@ -135,6 +135,10 @@ pub async fn team_leave(
135135 }
136136 // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté.
137137 timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?;
138+ // Mes todos « en cours » repassent à faire, mes assignations disparaissent.
139+ let mut tx = state.pool.begin().await?;
140+ crate::db::todos::on_member_leave(&mut tx, ctx.team_id, ctx.user.id).await?;
141+ tx.commit().await?;
138142 teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
139143 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
140144 Ok((
@@ -135,6 +135,10 @@ pub async fn team_leave(
135 }135 }
136 // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté.136 // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté.
137 timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?;137 timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?;
138+ // Mes todos « en cours » repassent à faire, mes assignations disparaissent.
139+ let mut tx = state.pool.begin().await?;
140+ crate::db::todos::on_member_leave(&mut tx, ctx.team_id, ctx.user.id).await?;
141+ tx.commit().await?;
138 teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;142 teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?;
139 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.143 // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut.
140 Ok((144 Ok((
added src/routes/todos.rs +592 -0
new file mode 100644
@@ -0,0 +1,592 @@
1+use std::collections::HashMap;
2+
3+use askama::Template;
4+use axum::Form;
5+use axum::extract::{Path, Query, State};
6+use axum::response::Redirect;
7+use chrono::{Duration, NaiveDate};
8+use chrono_tz::Europe::Paris;
9+use serde::Deserialize;
10+use uuid::Uuid;
11+
12+use crate::charts::{color_map, fmt_minutes};
13+use crate::db::categories;
14+use crate::db::teams;
15+use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow};
16+use crate::error::AppError;
17+use crate::period::{day_label, day_month, paris_today};
18+use crate::render::{HtmlTemplate, Shell};
19+use crate::routes::shell;
20+use crate::schedule;
21+use crate::state::AppState;
22+use crate::team::ActiveTeam;
23+
24+/// ─── Écran liste ───
25+
26+pub struct TodoCard {
27+ pub id: Uuid,
28+ pub name: String,
29+ pub color: &'static str,
30+ /// « pris par julien · 5 août », « depuis le 3 août », « à partir du… »
31+ pub meta: String,
32+ /// Mise en évidence du retard (à faire uniquement).
33+ pub late: bool,
34+ /// Bloc en cours : je suis le preneur (actions visibles).
35+ pub mine: bool,
36+ /// Bloc en cours : prise par quelqu'un d'autre (carte estompée).
37+ pub other: bool,
38+ /// « pour blandine et gisèle » (les autres assignés, jamais moi) — vide si rien à dire.
39+ pub assignee_line: String,
40+ /// Je suis assigné → liseré + ligne d'assignés en évidence.
41+ pub for_me: bool,
42+}
43+
44+#[derive(Template)]
45+#[template(path = "todos.html")]
46+pub struct TodosPage {
47+ pub shell: Shell,
48+ pub csrf: String,
49+ pub doing: Vec<TodoCard>,
50+ pub open: Vec<TodoCard>,
51+ pub later: Vec<TodoCard>,
52+ /// Le bloc existe (même si le filtre le vide).
53+ pub has_doing: bool,
54+ pub has_open: bool,
55+ pub empty: bool,
56+ /// Toggles « Mes tâches » : état + lien qui inverse (en préservant l'autre).
57+ pub doing_filter_on: bool,
58+ pub open_filter_on: bool,
59+ pub doing_toggle_href: String,
60+ pub open_toggle_href: String,
61+}
62+
63+#[derive(Deserialize)]
64+pub struct ListQuery {
65+ encours: Option<String>,
66+ afaire: Option<String>,
67+ ok: Option<String>,
68+}
69+
70+fn toast_of(ok: Option<&str>) -> Option<String> {
71+ Some(
72+ match ok? {
73+ "creee" => "Tâche créée ✓",
74+ "modifiee" => "Tâche modifiée ✓",
75+ "supprimee" => "Tâche supprimée",
76+ "prise" => "Tâche prise ✓",
77+ "relachee" => "Tâche relâchée",
78+ "faite" => "Tâche faite ✓",
79+ "faite_reprog" => "Faite et reprogrammée ✓",
80+ _ => return None,
81+ }
82+ .to_string(),
83+ )
84+}
85+
86+fn list_href(doing_on: bool, open_on: bool) -> String {
87+ match (doing_on, open_on) {
88+ (false, false) => "/taches".to_string(),
89+ (true, false) => "/taches?encours=moi".to_string(),
90+ (false, true) => "/taches?afaire=moi".to_string(),
91+ (true, true) => "/taches?encours=moi&afaire=moi".to_string(),
92+ }
93+}
94+
95+/// « pour blandine », « pour blandine et gisèle » — moi exclu (le liseré dit
96+/// déjà que c'est pour moi).
97+fn assignee_line(names: &[&str]) -> String {
98+ match names {
99+ [] => String::new(),
100+ [a] => format!("pour {a}"),
101+ [rest @ .., last] => format!("pour {} et {last}", rest.join(", ")),
102+ }
103+}
104+
105+pub async fn todos_page(
106+ State(state): State<AppState>,
107+ ctx: ActiveTeam,
108+ Query(q): Query<ListQuery>,
109+) -> Result<HtmlTemplate<TodosPage>, AppError> {
110+ let today = paris_today();
111+ let me = ctx.user.id;
112+ let doing_on = q.encours.as_deref() == Some("moi");
113+ let open_on = q.afaire.as_deref() == Some("moi");
114+
115+ let rows = todos::list_open(&state.pool, ctx.team_id).await?;
116+ let mut by_todo: HashMap<Uuid, Vec<(Uuid, String)>> = HashMap::new();
117+ for a in todos::assignees_open(&state.pool, ctx.team_id).await? {
118+ by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username));
119+ }
120+ let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?;
121+ let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>());
122+
123+ let card = |t: &TodoRow| {
124+ let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]);
125+ let for_me = assignees.iter().any(|(id, _)| *id == me);
126+ let others: Vec<&str> = assignees
127+ .iter()
128+ .filter(|(id, _)| *id != me)
129+ .map(|(_, n)| n.as_str())
130+ .collect();
131+ let mine = t.taker_id == Some(me);
132+ let late = t.state_id == STATE_TODO && t.activation_date < today;
133+ let meta = if t.state_id == STATE_IN_PROGRESS {
134+ let on = t
135+ .taken_at
136+ .map(|at| day_month(at.with_timezone(&Paris).date_naive()))
137+ .unwrap_or_default();
138+ format!("pris par {} · {on}", t.taker_name.as_deref().unwrap_or("?"))
139+ } else if late {
140+ format!("depuis le {}", day_month(t.activation_date))
141+ } else if t.activation_date == today {
142+ "activée aujourd'hui".to_string()
143+ } else {
144+ format!("à partir du {}", day_month(t.activation_date))
145+ };
146+ TodoCard {
147+ id: t.id,
148+ name: t.name.clone(),
149+ color: colors.get(&t.category_id).copied().unwrap_or("#a19786"),
150+ meta,
151+ late,
152+ mine,
153+ other: t.state_id == STATE_IN_PROGRESS && !mine,
154+ assignee_line: assignee_line(&others),
155+ for_me,
156+ }
157+ };
158+
159+ let assigned_to_me = |t: &TodoRow| {
160+ by_todo
161+ .get(&t.id)
162+ .is_some_and(|a| a.iter().any(|(id, _)| *id == me))
163+ };
164+ let unassigned = |t: &TodoRow| by_todo.get(&t.id).is_none_or(Vec::is_empty);
165+
166+ // Tri : date d'activation croissante puis, à date égale, les miennes
167+ // d'abord (list_open trie déjà par date + id ; on raffine en stable).
168+ let mut base: Vec<&TodoRow> = rows.iter().collect();
169+ base.sort_by_key(|t| (t.activation_date, !assigned_to_me(t)));
170+
171+ let select = |state_wanted: i16, active: bool, filter_on: bool| -> Vec<TodoCard> {
172+ let mut sel: Vec<&&TodoRow> = base
173+ .iter()
174+ .filter(|t| t.state_id == state_wanted)
175+ .filter(|t| (t.activation_date <= today) == active)
176+ .filter(|t| !filter_on || assigned_to_me(t) || unassigned(t))
177+ .collect();
178+ if filter_on {
179+ // Les miennes en premier, puis les sans-assigné (stable).
180+ sel.sort_by_key(|t| !assigned_to_me(t));
181+ }
182+ sel.into_iter().map(|t| card(t)).collect()
183+ };
184+
185+ let has_doing = rows.iter().any(|t| t.state_id == STATE_IN_PROGRESS);
186+ let has_open = rows
187+ .iter()
188+ .any(|t| t.state_id == STATE_TODO && t.activation_date <= today);
189+ let doing = select(STATE_IN_PROGRESS, true, doing_on);
190+ let open = select(STATE_TODO, true, open_on);
191+ let later = select(STATE_TODO, false, false);
192+
193+ let shell = shell(&state, &ctx, "Tâches", "todos", toast_of(q.ok.as_deref())).await?;
194+ Ok(HtmlTemplate(TodosPage {
195+ csrf: ctx.user.csrf_token(&state),
196+ empty: rows.is_empty(),
197+ has_doing,
198+ has_open,
199+ doing,
200+ open,
201+ later,
202+ doing_filter_on: doing_on,
203+ open_filter_on: open_on,
204+ doing_toggle_href: list_href(!doing_on, open_on),
205+ open_toggle_href: list_href(doing_on, !open_on),
206+ shell,
207+ }))
208+}
209+
210+/// ─── Création / édition ───
211+
212+pub struct CatChip {
213+ pub id: Uuid,
214+ pub name: String,
215+ pub color: &'static str,
216+ pub selected: bool,
217+}
218+
219+pub struct MemberChip {
220+ pub id: Uuid,
221+ pub name: String,
222+ pub selected: bool,
223+}
224+
225+#[derive(Template)]
226+#[template(path = "todo_form.html")]
227+pub struct TodoFormPage {
228+ pub shell: Shell,
229+ pub csrf: String,
230+ /// None = création, Some(id) = édition (action + bouton supprimer).
231+ pub edit_id: Option<Uuid>,
232+ pub name: String,
233+ pub cats: Vec<CatChip>,
234+ pub date: NaiveDate,
235+ pub date_label: String,
236+ pub members: Vec<MemberChip>,
237+ pub error: Option<&'static str>,
238+}
239+
240+pub async fn todo_new(
241+ State(state): State<AppState>,
242+ ctx: ActiveTeam,
243+) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
244+ form_page(&state, &ctx, None, None).await
245+}
246+
247+pub async fn todo_edit(
248+ State(state): State<AppState>,
249+ ctx: ActiveTeam,
250+ Path(id): Path<Uuid>,
251+) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
252+ let todo = todos::get_editable(&state.pool, ctx.team_id, ctx.user.id, id)
253+ .await?
254+ .ok_or(AppError::NotFound)?;
255+ let assigned = todos::assignees_of(&state.pool, id).await?;
256+ form_page(&state, &ctx, Some(todo), Some(assigned)).await
257+}
258+
259+async fn form_page(
260+ state: &AppState,
261+ ctx: &ActiveTeam,
262+ todo: Option<TodoRow>,
263+ assigned: Option<Vec<Uuid>>,
264+) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
265+ let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
266+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
267+ let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today);
268+ let selected_cat = todo.as_ref().map(|t| t.category_id);
269+ let assigned = assigned.unwrap_or_default();
270+ let title = if todo.is_some() { "Modifier la tâche" } else { "Nouvelle tâche" };
271+ let shell = shell(state, ctx, title, "todos", None).await?;
272+ Ok(HtmlTemplate(TodoFormPage {
273+ csrf: ctx.user.csrf_token(state),
274+ edit_id: todo.as_ref().map(|t| t.id),
275+ name: todo.as_ref().map(|t| t.name.clone()).unwrap_or_default(),
276+ cats: all
277+ .iter()
278+ .filter(|c| !c.archived || selected_cat == Some(c.id))
279+ .map(|c| CatChip {
280+ id: c.id,
281+ name: c.name.clone(),
282+ color: colors.get(&c.id).copied().unwrap_or("#a19786"),
283+ selected: selected_cat == Some(c.id),
284+ })
285+ .collect(),
286+ date,
287+ date_label: day_label(date),
288+ members: teams::members(&state.pool, ctx.team_id)
289+ .await?
290+ .into_iter()
291+ .map(|m| MemberChip {
292+ selected: assigned.contains(&m.id),
293+ name: if m.id == ctx.user.id { format!("{} (toi)", m.username) } else { m.username },
294+ id: m.id,
295+ })
296+ .collect(),
297+ error: None,
298+ shell,
299+ }))
300+}
301+
302+/// Champs des forms création/édition — clés répétées `assigne` pour les
303+/// cases à cocher, d'où le Vec<(clé, valeur)> plutôt qu'une struct serde.
304+struct TodoForm {
305+ csrf: String,
306+ name: String,
307+ category: Option<Uuid>,
308+ date: Option<NaiveDate>,
309+ assignees: Vec<Uuid>,
310+}
311+
312+fn parse_form(pairs: &[(String, String)]) -> TodoForm {
313+ let mut f = TodoForm {
314+ csrf: String::new(),
315+ name: String::new(),
316+ category: None,
317+ date: None,
318+ assignees: Vec::new(),
319+ };
320+ for (k, v) in pairs {
321+ match k.as_str() {
322+ "csrf" => f.csrf = v.clone(),
323+ "nom" => f.name = v.trim().to_string(),
324+ "categorie" => f.category = v.parse().ok(),
325+ "date" => f.date = v.parse().ok(),
326+ "assigne" => {
327+ if let Ok(id) = v.parse() {
328+ f.assignees.push(id);
329+ }
330+ }
331+ _ => {}
332+ }
333+ }
334+ f
335+}
336+
337+pub async fn todo_create(
338+ State(state): State<AppState>,
339+ ctx: ActiveTeam,
340+ Form(pairs): Form<Vec<(String, String)>>,
341+) -> Result<Redirect, AppError> {
342+ let f = parse_form(&pairs);
343+ if !ctx.user.check_csrf(&state, &f.csrf) {
344+ return Err(AppError::Csrf);
345+ }
346+ let (Some(cat), Some(date)) = (f.category, f.date) else {
347+ return Ok(Redirect::to("/taches/nouvelle?err=incomplet"));
348+ };
349+ if f.name.is_empty() {
350+ return Ok(Redirect::to("/taches/nouvelle?err=incomplet"));
351+ }
352+ match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? {
353+ 0 => Err(AppError::BadRequest("catégorie inconnue".into())),
354+ _ => Ok(Redirect::to("/taches?ok=creee")),
355+ }
356+}
357+
358+pub async fn todo_update(
359+ State(state): State<AppState>,
360+ ctx: ActiveTeam,
361+ Path(id): Path<Uuid>,
362+ Form(pairs): Form<Vec<(String, String)>>,
363+) -> Result<Redirect, AppError> {
364+ let f = parse_form(&pairs);
365+ if !ctx.user.check_csrf(&state, &f.csrf) {
366+ return Err(AppError::Csrf);
367+ }
368+ let (Some(cat), Some(date)) = (f.category, f.date) else {
369+ return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet")));
370+ };
371+ if f.name.is_empty() {
372+ return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet")));
373+ }
374+ match todos::update(&state.pool, ctx.team_id, ctx.user.id, id, cat, &f.name, date, &f.assignees).await? {
375+ 0 => Err(AppError::NotFound),
376+ _ => Ok(Redirect::to("/taches?ok=modifiee")),
377+ }
378+}
379+
380+#[derive(Deserialize)]
381+pub struct CsrfOnly {
382+ csrf: String,
383+}
384+
385+pub async fn todo_delete(
386+ State(state): State<AppState>,
387+ ctx: ActiveTeam,
388+ Path(id): Path<Uuid>,
389+ Form(form): Form<CsrfOnly>,
390+) -> Result<Redirect, AppError> {
391+ if !ctx.user.check_csrf(&state, &form.csrf) {
392+ return Err(AppError::Csrf);
393+ }
394+ match todos::delete(&state.pool, ctx.team_id, ctx.user.id, id).await? {
395+ 0 => Err(AppError::NotFound),
396+ _ => Ok(Redirect::to("/taches?ok=supprimee")),
397+ }
398+}
399+
400+pub async fn todo_take(
401+ State(state): State<AppState>,
402+ ctx: ActiveTeam,
403+ Path(id): Path<Uuid>,
404+ Form(form): Form<CsrfOnly>,
405+) -> Result<Redirect, AppError> {
406+ if !ctx.user.check_csrf(&state, &form.csrf) {
407+ return Err(AppError::Csrf);
408+ }
409+ match todos::take(&state.pool, ctx.team_id, ctx.user.id, id).await? {
410+ 0 => Err(AppError::NotFound),
411+ _ => Ok(Redirect::to("/taches?ok=prise")),
412+ }
413+}
414+
415+pub async fn todo_release(
416+ State(state): State<AppState>,
417+ ctx: ActiveTeam,
418+ Path(id): Path<Uuid>,
419+ Form(form): Form<CsrfOnly>,
420+) -> Result<Redirect, AppError> {
421+ if !ctx.user.check_csrf(&state, &form.csrf) {
422+ return Err(AppError::Csrf);
423+ }
424+ match todos::release(&state.pool, ctx.team_id, ctx.user.id, id).await? {
425+ 0 => Err(AppError::NotFound),
426+ _ => Ok(Redirect::to("/taches?ok=relachee")),
427+ }
428+}
429+
430+/// ─── Flux « effectuée » (page unique, un seul POST) ───
431+
432+pub struct DonePreset {
433+ pub label: &'static str,
434+ pub href: String,
435+ pub minutes: i32,
436+ pub selected: bool,
437+}
438+
439+#[derive(Template)]
440+#[template(path = "todo_done.html")]
441+pub struct TodoDonePage {
442+ pub shell: Shell,
443+ pub csrf: String,
444+ pub id: Uuid,
445+ pub name: String,
446+ pub color: &'static str,
447+ pub minutes: i32,
448+ pub minutes_pretty: String,
449+ pub minus_href: String,
450+ pub plus_href: String,
451+ pub presets: Vec<DonePreset>,
452+ pub date: NaiveDate,
453+ pub day_prev: String,
454+ /// None = aujourd'hui (flèche désactivée).
455+ pub day_next: Option<String>,
456+ pub next: NaiveDate,
457+ pub next_label: String,
458+ pub next_prev: String,
459+ pub next_next: String,
460+ /// « Rythme constaté : tous les ~7 jours » / « Pas encore de rythme… »
461+ pub rhythm: String,
462+}
463+
464+#[derive(Deserialize)]
465+pub struct DoneQuery {
466+ minutes: Option<i32>,
467+ date: Option<NaiveDate>,
468+ prochaine: Option<NaiveDate>,
469+}
470+
471+fn done_href(id: Uuid, minutes: i32, date: NaiveDate, next: NaiveDate) -> String {
472+ format!("/taches/{id}/terminer?minutes={minutes}&date={date}&prochaine={next}")
473+}
474+
475+pub async fn todo_done_page(
476+ State(state): State<AppState>,
477+ ctx: ActiveTeam,
478+ Path(id): Path<Uuid>,
479+ Query(q): Query<DoneQuery>,
480+) -> Result<HtmlTemplate<TodoDonePage>, AppError> {
481+ let todo = todos::get_finishable(&state.pool, ctx.team_id, ctx.user.id, id)
482+ .await?
483+ .ok_or(AppError::NotFound)?;
484+ let today = paris_today();
485+ let dones: Vec<NaiveDate> = todos::done_at(&state.pool, id)
486+ .await?
487+ .into_iter()
488+ .map(|at| at.with_timezone(&Paris).date_naive())
489+ .collect();
490+ let proposal = schedule::propose(
491+ todo.created_at.with_timezone(&Paris).date_naive(),
492+ todo.activation_date,
493+ &dones,
494+ today,
495+ );
496+ let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
497+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
498+
499+ let minutes = q.minutes.unwrap_or(30).max(1);
500+ let date = q.date.unwrap_or(today).min(today);
501+ let next = q.prochaine.unwrap_or(proposal.date);
502+ let rhythm = match proposal.rhythm_days {
503+ Some(d) => format!("Rythme constaté : tous les ~{d} jour{}", if d > 1 { "s" } else { "" }),
504+ None => format!("Pas encore de rythme constaté — proposition à {} jours.", proposal.days),
505+ };
506+
507+ let shell = shell(&state, &ctx, "Tâche effectuée", "todos", None).await?;
508+ Ok(HtmlTemplate(TodoDonePage {
509+ csrf: ctx.user.csrf_token(&state),
510+ id,
511+ name: todo.name.clone(),
512+ color: colors.get(&todo.category_id).copied().unwrap_or("#a19786"),
513+ minutes,
514+ minutes_pretty: fmt_minutes(minutes as i64),
515+ minus_href: done_href(id, (minutes - 5).max(1), date, next),
516+ plus_href: done_href(id, minutes + 5, date, next),
517+ presets: [5, 15, 30, 45, 60]
518+ .into_iter()
519+ .map(|m| DonePreset {
520+ label: match m {
521+ 5 => "5 min",
522+ 15 => "15 min",
523+ 30 => "30 min",
524+ 45 => "45 min",
525+ _ => "1 h",
526+ },
527+ href: done_href(id, m, date, next),
528+ minutes: m,
529+ selected: m == minutes,
530+ })
531+ .collect(),
532+ date,
533+ day_prev: done_href(id, minutes, date - Duration::days(1), next),
534+ day_next: (date < today).then(|| done_href(id, minutes, date + Duration::days(1), next)),
535+ next,
536+ next_label: day_label(next),
537+ next_prev: done_href(id, minutes, date, next - Duration::days(1)),
538+ next_next: done_href(id, minutes, date, next + Duration::days(1)),
539+ rhythm,
540+ shell,
541+ }))
542+}
543+
544+#[derive(Deserialize)]
545+pub struct DoneForm {
546+ csrf: String,
547+ minutes: Option<i32>,
548+ date: Option<NaiveDate>,
549+ /// Toggle « Enregistrer un temps » (absent = décoché).
550+ avec_temps: Option<String>,
551+ prochaine: Option<NaiveDate>,
552+ /// Toggle « Reprogrammer » (absent = décoché).
553+ reprogrammer: Option<String>,
554+}
555+
556+pub async fn todo_finish(
557+ State(state): State<AppState>,
558+ ctx: ActiveTeam,
559+ Path(id): Path<Uuid>,
560+ Form(form): Form<DoneForm>,
561+) -> Result<Redirect, AppError> {
562+ if !ctx.user.check_csrf(&state, &form.csrf) {
563+ return Err(AppError::Csrf);
564+ }
565+ let today = paris_today();
566+ let time = if form.avec_temps.is_some() {
567+ let minutes = form.minutes.unwrap_or(0);
568+ if minutes < 1 {
569+ return Ok(Redirect::to(&format!("/taches/{id}/terminer?err=duree")));
570+ }
571+ Some((minutes, form.date.unwrap_or(today).min(today)))
572+ } else {
573+ None
574+ };
575+ let next = if form.reprogrammer.is_some() {
576+ match form.prochaine {
577+ Some(d) => Some(d),
578+ None => return Ok(Redirect::to(&format!("/taches/{id}/terminer?err=date"))),
579+ }
580+ } else {
581+ None
582+ };
583+ let reprogrammed = next.is_some();
584+ match todos::finish(&state.pool, ctx.team_id, ctx.user.id, id, time, next).await? {
585+ 0 => Err(AppError::NotFound),
586+ _ => Ok(Redirect::to(if reprogrammed {
587+ "/taches?ok=faite_reprog"
588+ } else {
589+ "/taches?ok=faite"
590+ })),
591+ }
592+}
new file mode 100644
@@ -0,0 +1,592 @@
1+use std::collections::HashMap;
2+
3+use askama::Template;
4+use axum::Form;
5+use axum::extract::{Path, Query, State};
6+use axum::response::Redirect;
7+use chrono::{Duration, NaiveDate};
8+use chrono_tz::Europe::Paris;
9+use serde::Deserialize;
10+use uuid::Uuid;
11+
12+use crate::charts::{color_map, fmt_minutes};
13+use crate::db::categories;
14+use crate::db::teams;
15+use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow};
16+use crate::error::AppError;
17+use crate::period::{day_label, day_month, paris_today};
18+use crate::render::{HtmlTemplate, Shell};
19+use crate::routes::shell;
20+use crate::schedule;
21+use crate::state::AppState;
22+use crate::team::ActiveTeam;
23+
24+/// ─── Écran liste ───
25+
26+pub struct TodoCard {
27+ pub id: Uuid,
28+ pub name: String,
29+ pub color: &'static str,
30+ /// « pris par julien · 5 août », « depuis le 3 août », « à partir du… »
31+ pub meta: String,
32+ /// Mise en évidence du retard (à faire uniquement).
33+ pub late: bool,
34+ /// Bloc en cours : je suis le preneur (actions visibles).
35+ pub mine: bool,
36+ /// Bloc en cours : prise par quelqu'un d'autre (carte estompée).
37+ pub other: bool,
38+ /// « pour blandine et gisèle » (les autres assignés, jamais moi) — vide si rien à dire.
39+ pub assignee_line: String,
40+ /// Je suis assigné → liseré + ligne d'assignés en évidence.
41+ pub for_me: bool,
42+}
43+
44+#[derive(Template)]
45+#[template(path = "todos.html")]
46+pub struct TodosPage {
47+ pub shell: Shell,
48+ pub csrf: String,
49+ pub doing: Vec<TodoCard>,
50+ pub open: Vec<TodoCard>,
51+ pub later: Vec<TodoCard>,
52+ /// Le bloc existe (même si le filtre le vide).
53+ pub has_doing: bool,
54+ pub has_open: bool,
55+ pub empty: bool,
56+ /// Toggles « Mes tâches » : état + lien qui inverse (en préservant l'autre).
57+ pub doing_filter_on: bool,
58+ pub open_filter_on: bool,
59+ pub doing_toggle_href: String,
60+ pub open_toggle_href: String,
61+}
62+
63+#[derive(Deserialize)]
64+pub struct ListQuery {
65+ encours: Option<String>,
66+ afaire: Option<String>,
67+ ok: Option<String>,
68+}
69+
70+fn toast_of(ok: Option<&str>) -> Option<String> {
71+ Some(
72+ match ok? {
73+ "creee" => "Tâche créée ✓",
74+ "modifiee" => "Tâche modifiée ✓",
75+ "supprimee" => "Tâche supprimée",
76+ "prise" => "Tâche prise ✓",
77+ "relachee" => "Tâche relâchée",
78+ "faite" => "Tâche faite ✓",
79+ "faite_reprog" => "Faite et reprogrammée ✓",
80+ _ => return None,
81+ }
82+ .to_string(),
83+ )
84+}
85+
86+fn list_href(doing_on: bool, open_on: bool) -> String {
87+ match (doing_on, open_on) {
88+ (false, false) => "/taches".to_string(),
89+ (true, false) => "/taches?encours=moi".to_string(),
90+ (false, true) => "/taches?afaire=moi".to_string(),
91+ (true, true) => "/taches?encours=moi&afaire=moi".to_string(),
92+ }
93+}
94+
95+/// « pour blandine », « pour blandine et gisèle » — moi exclu (le liseré dit
96+/// déjà que c'est pour moi).
97+fn assignee_line(names: &[&str]) -> String {
98+ match names {
99+ [] => String::new(),
100+ [a] => format!("pour {a}"),
101+ [rest @ .., last] => format!("pour {} et {last}", rest.join(", ")),
102+ }
103+}
104+
105+pub async fn todos_page(
106+ State(state): State<AppState>,
107+ ctx: ActiveTeam,
108+ Query(q): Query<ListQuery>,
109+) -> Result<HtmlTemplate<TodosPage>, AppError> {
110+ let today = paris_today();
111+ let me = ctx.user.id;
112+ let doing_on = q.encours.as_deref() == Some("moi");
113+ let open_on = q.afaire.as_deref() == Some("moi");
114+
115+ let rows = todos::list_open(&state.pool, ctx.team_id).await?;
116+ let mut by_todo: HashMap<Uuid, Vec<(Uuid, String)>> = HashMap::new();
117+ for a in todos::assignees_open(&state.pool, ctx.team_id).await? {
118+ by_todo.entry(a.todo_id).or_default().push((a.user_id, a.username));
119+ }
120+ let all_categories = categories::list_by_creation(&state.pool, ctx.team_id).await?;
121+ let colors = color_map(&all_categories.iter().map(|c| c.id).collect::<Vec<_>>());
122+
123+ let card = |t: &TodoRow| {
124+ let assignees = by_todo.get(&t.id).map(Vec::as_slice).unwrap_or(&[]);
125+ let for_me = assignees.iter().any(|(id, _)| *id == me);
126+ let others: Vec<&str> = assignees
127+ .iter()
128+ .filter(|(id, _)| *id != me)
129+ .map(|(_, n)| n.as_str())
130+ .collect();
131+ let mine = t.taker_id == Some(me);
132+ let late = t.state_id == STATE_TODO && t.activation_date < today;
133+ let meta = if t.state_id == STATE_IN_PROGRESS {
134+ let on = t
135+ .taken_at
136+ .map(|at| day_month(at.with_timezone(&Paris).date_naive()))
137+ .unwrap_or_default();
138+ format!("pris par {} · {on}", t.taker_name.as_deref().unwrap_or("?"))
139+ } else if late {
140+ format!("depuis le {}", day_month(t.activation_date))
141+ } else if t.activation_date == today {
142+ "activée aujourd'hui".to_string()
143+ } else {
144+ format!("à partir du {}", day_month(t.activation_date))
145+ };
146+ TodoCard {
147+ id: t.id,
148+ name: t.name.clone(),
149+ color: colors.get(&t.category_id).copied().unwrap_or("#a19786"),
150+ meta,
151+ late,
152+ mine,
153+ other: t.state_id == STATE_IN_PROGRESS && !mine,
154+ assignee_line: assignee_line(&others),
155+ for_me,
156+ }
157+ };
158+
159+ let assigned_to_me = |t: &TodoRow| {
160+ by_todo
161+ .get(&t.id)
162+ .is_some_and(|a| a.iter().any(|(id, _)| *id == me))
163+ };
164+ let unassigned = |t: &TodoRow| by_todo.get(&t.id).is_none_or(Vec::is_empty);
165+
166+ // Tri : date d'activation croissante puis, à date égale, les miennes
167+ // d'abord (list_open trie déjà par date + id ; on raffine en stable).
168+ let mut base: Vec<&TodoRow> = rows.iter().collect();
169+ base.sort_by_key(|t| (t.activation_date, !assigned_to_me(t)));
170+
171+ let select = |state_wanted: i16, active: bool, filter_on: bool| -> Vec<TodoCard> {
172+ let mut sel: Vec<&&TodoRow> = base
173+ .iter()
174+ .filter(|t| t.state_id == state_wanted)
175+ .filter(|t| (t.activation_date <= today) == active)
176+ .filter(|t| !filter_on || assigned_to_me(t) || unassigned(t))
177+ .collect();
178+ if filter_on {
179+ // Les miennes en premier, puis les sans-assigné (stable).
180+ sel.sort_by_key(|t| !assigned_to_me(t));
181+ }
182+ sel.into_iter().map(|t| card(t)).collect()
183+ };
184+
185+ let has_doing = rows.iter().any(|t| t.state_id == STATE_IN_PROGRESS);
186+ let has_open = rows
187+ .iter()
188+ .any(|t| t.state_id == STATE_TODO && t.activation_date <= today);
189+ let doing = select(STATE_IN_PROGRESS, true, doing_on);
190+ let open = select(STATE_TODO, true, open_on);
191+ let later = select(STATE_TODO, false, false);
192+
193+ let shell = shell(&state, &ctx, "Tâches", "todos", toast_of(q.ok.as_deref())).await?;
194+ Ok(HtmlTemplate(TodosPage {
195+ csrf: ctx.user.csrf_token(&state),
196+ empty: rows.is_empty(),
197+ has_doing,
198+ has_open,
199+ doing,
200+ open,
201+ later,
202+ doing_filter_on: doing_on,
203+ open_filter_on: open_on,
204+ doing_toggle_href: list_href(!doing_on, open_on),
205+ open_toggle_href: list_href(doing_on, !open_on),
206+ shell,
207+ }))
208+}
209+
210+/// ─── Création / édition ───
211+
212+pub struct CatChip {
213+ pub id: Uuid,
214+ pub name: String,
215+ pub color: &'static str,
216+ pub selected: bool,
217+}
218+
219+pub struct MemberChip {
220+ pub id: Uuid,
221+ pub name: String,
222+ pub selected: bool,
223+}
224+
225+#[derive(Template)]
226+#[template(path = "todo_form.html")]
227+pub struct TodoFormPage {
228+ pub shell: Shell,
229+ pub csrf: String,
230+ /// None = création, Some(id) = édition (action + bouton supprimer).
231+ pub edit_id: Option<Uuid>,
232+ pub name: String,
233+ pub cats: Vec<CatChip>,
234+ pub date: NaiveDate,
235+ pub date_label: String,
236+ pub members: Vec<MemberChip>,
237+ pub error: Option<&'static str>,
238+}
239+
240+pub async fn todo_new(
241+ State(state): State<AppState>,
242+ ctx: ActiveTeam,
243+) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
244+ form_page(&state, &ctx, None, None).await
245+}
246+
247+pub async fn todo_edit(
248+ State(state): State<AppState>,
249+ ctx: ActiveTeam,
250+ Path(id): Path<Uuid>,
251+) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
252+ let todo = todos::get_editable(&state.pool, ctx.team_id, ctx.user.id, id)
253+ .await?
254+ .ok_or(AppError::NotFound)?;
255+ let assigned = todos::assignees_of(&state.pool, id).await?;
256+ form_page(&state, &ctx, Some(todo), Some(assigned)).await
257+}
258+
259+async fn form_page(
260+ state: &AppState,
261+ ctx: &ActiveTeam,
262+ todo: Option<TodoRow>,
263+ assigned: Option<Vec<Uuid>>,
264+) -> Result<HtmlTemplate<TodoFormPage>, AppError> {
265+ let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
266+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
267+ let date = todo.as_ref().map(|t| t.activation_date).unwrap_or_else(paris_today);
268+ let selected_cat = todo.as_ref().map(|t| t.category_id);
269+ let assigned = assigned.unwrap_or_default();
270+ let title = if todo.is_some() { "Modifier la tâche" } else { "Nouvelle tâche" };
271+ let shell = shell(state, ctx, title, "todos", None).await?;
272+ Ok(HtmlTemplate(TodoFormPage {
273+ csrf: ctx.user.csrf_token(state),
274+ edit_id: todo.as_ref().map(|t| t.id),
275+ name: todo.as_ref().map(|t| t.name.clone()).unwrap_or_default(),
276+ cats: all
277+ .iter()
278+ .filter(|c| !c.archived || selected_cat == Some(c.id))
279+ .map(|c| CatChip {
280+ id: c.id,
281+ name: c.name.clone(),
282+ color: colors.get(&c.id).copied().unwrap_or("#a19786"),
283+ selected: selected_cat == Some(c.id),
284+ })
285+ .collect(),
286+ date,
287+ date_label: day_label(date),
288+ members: teams::members(&state.pool, ctx.team_id)
289+ .await?
290+ .into_iter()
291+ .map(|m| MemberChip {
292+ selected: assigned.contains(&m.id),
293+ name: if m.id == ctx.user.id { format!("{} (toi)", m.username) } else { m.username },
294+ id: m.id,
295+ })
296+ .collect(),
297+ error: None,
298+ shell,
299+ }))
300+}
301+
302+/// Champs des forms création/édition — clés répétées `assigne` pour les
303+/// cases à cocher, d'où le Vec<(clé, valeur)> plutôt qu'une struct serde.
304+struct TodoForm {
305+ csrf: String,
306+ name: String,
307+ category: Option<Uuid>,
308+ date: Option<NaiveDate>,
309+ assignees: Vec<Uuid>,
310+}
311+
312+fn parse_form(pairs: &[(String, String)]) -> TodoForm {
313+ let mut f = TodoForm {
314+ csrf: String::new(),
315+ name: String::new(),
316+ category: None,
317+ date: None,
318+ assignees: Vec::new(),
319+ };
320+ for (k, v) in pairs {
321+ match k.as_str() {
322+ "csrf" => f.csrf = v.clone(),
323+ "nom" => f.name = v.trim().to_string(),
324+ "categorie" => f.category = v.parse().ok(),
325+ "date" => f.date = v.parse().ok(),
326+ "assigne" => {
327+ if let Ok(id) = v.parse() {
328+ f.assignees.push(id);
329+ }
330+ }
331+ _ => {}
332+ }
333+ }
334+ f
335+}
336+
337+pub async fn todo_create(
338+ State(state): State<AppState>,
339+ ctx: ActiveTeam,
340+ Form(pairs): Form<Vec<(String, String)>>,
341+) -> Result<Redirect, AppError> {
342+ let f = parse_form(&pairs);
343+ if !ctx.user.check_csrf(&state, &f.csrf) {
344+ return Err(AppError::Csrf);
345+ }
346+ let (Some(cat), Some(date)) = (f.category, f.date) else {
347+ return Ok(Redirect::to("/taches/nouvelle?err=incomplet"));
348+ };
349+ if f.name.is_empty() {
350+ return Ok(Redirect::to("/taches/nouvelle?err=incomplet"));
351+ }
352+ match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? {
353+ 0 => Err(AppError::BadRequest("catégorie inconnue".into())),
354+ _ => Ok(Redirect::to("/taches?ok=creee")),
355+ }
356+}
357+
358+pub async fn todo_update(
359+ State(state): State<AppState>,
360+ ctx: ActiveTeam,
361+ Path(id): Path<Uuid>,
362+ Form(pairs): Form<Vec<(String, String)>>,
363+) -> Result<Redirect, AppError> {
364+ let f = parse_form(&pairs);
365+ if !ctx.user.check_csrf(&state, &f.csrf) {
366+ return Err(AppError::Csrf);
367+ }
368+ let (Some(cat), Some(date)) = (f.category, f.date) else {
369+ return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet")));
370+ };
371+ if f.name.is_empty() {
372+ return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet")));
373+ }
374+ match todos::update(&state.pool, ctx.team_id, ctx.user.id, id, cat, &f.name, date, &f.assignees).await? {
375+ 0 => Err(AppError::NotFound),
376+ _ => Ok(Redirect::to("/taches?ok=modifiee")),
377+ }
378+}
379+
380+#[derive(Deserialize)]
381+pub struct CsrfOnly {
382+ csrf: String,
383+}
384+
385+pub async fn todo_delete(
386+ State(state): State<AppState>,
387+ ctx: ActiveTeam,
388+ Path(id): Path<Uuid>,
389+ Form(form): Form<CsrfOnly>,
390+) -> Result<Redirect, AppError> {
391+ if !ctx.user.check_csrf(&state, &form.csrf) {
392+ return Err(AppError::Csrf);
393+ }
394+ match todos::delete(&state.pool, ctx.team_id, ctx.user.id, id).await? {
395+ 0 => Err(AppError::NotFound),
396+ _ => Ok(Redirect::to("/taches?ok=supprimee")),
397+ }
398+}
399+
400+pub async fn todo_take(
401+ State(state): State<AppState>,
402+ ctx: ActiveTeam,
403+ Path(id): Path<Uuid>,
404+ Form(form): Form<CsrfOnly>,
405+) -> Result<Redirect, AppError> {
406+ if !ctx.user.check_csrf(&state, &form.csrf) {
407+ return Err(AppError::Csrf);
408+ }
409+ match todos::take(&state.pool, ctx.team_id, ctx.user.id, id).await? {
410+ 0 => Err(AppError::NotFound),
411+ _ => Ok(Redirect::to("/taches?ok=prise")),
412+ }
413+}
414+
415+pub async fn todo_release(
416+ State(state): State<AppState>,
417+ ctx: ActiveTeam,
418+ Path(id): Path<Uuid>,
419+ Form(form): Form<CsrfOnly>,
420+) -> Result<Redirect, AppError> {
421+ if !ctx.user.check_csrf(&state, &form.csrf) {
422+ return Err(AppError::Csrf);
423+ }
424+ match todos::release(&state.pool, ctx.team_id, ctx.user.id, id).await? {
425+ 0 => Err(AppError::NotFound),
426+ _ => Ok(Redirect::to("/taches?ok=relachee")),
427+ }
428+}
429+
430+/// ─── Flux « effectuée » (page unique, un seul POST) ───
431+
432+pub struct DonePreset {
433+ pub label: &'static str,
434+ pub href: String,
435+ pub minutes: i32,
436+ pub selected: bool,
437+}
438+
439+#[derive(Template)]
440+#[template(path = "todo_done.html")]
441+pub struct TodoDonePage {
442+ pub shell: Shell,
443+ pub csrf: String,
444+ pub id: Uuid,
445+ pub name: String,
446+ pub color: &'static str,
447+ pub minutes: i32,
448+ pub minutes_pretty: String,
449+ pub minus_href: String,
450+ pub plus_href: String,
451+ pub presets: Vec<DonePreset>,
452+ pub date: NaiveDate,
453+ pub day_prev: String,
454+ /// None = aujourd'hui (flèche désactivée).
455+ pub day_next: Option<String>,
456+ pub next: NaiveDate,
457+ pub next_label: String,
458+ pub next_prev: String,
459+ pub next_next: String,
460+ /// « Rythme constaté : tous les ~7 jours » / « Pas encore de rythme… »
461+ pub rhythm: String,
462+}
463+
464+#[derive(Deserialize)]
465+pub struct DoneQuery {
466+ minutes: Option<i32>,
467+ date: Option<NaiveDate>,
468+ prochaine: Option<NaiveDate>,
469+}
470+
471+fn done_href(id: Uuid, minutes: i32, date: NaiveDate, next: NaiveDate) -> String {
472+ format!("/taches/{id}/terminer?minutes={minutes}&date={date}&prochaine={next}")
473+}
474+
475+pub async fn todo_done_page(
476+ State(state): State<AppState>,
477+ ctx: ActiveTeam,
478+ Path(id): Path<Uuid>,
479+ Query(q): Query<DoneQuery>,
480+) -> Result<HtmlTemplate<TodoDonePage>, AppError> {
481+ let todo = todos::get_finishable(&state.pool, ctx.team_id, ctx.user.id, id)
482+ .await?
483+ .ok_or(AppError::NotFound)?;
484+ let today = paris_today();
485+ let dones: Vec<NaiveDate> = todos::done_at(&state.pool, id)
486+ .await?
487+ .into_iter()
488+ .map(|at| at.with_timezone(&Paris).date_naive())
489+ .collect();
490+ let proposal = schedule::propose(
491+ todo.created_at.with_timezone(&Paris).date_naive(),
492+ todo.activation_date,
493+ &dones,
494+ today,
495+ );
496+ let all = categories::list_by_creation(&state.pool, ctx.team_id).await?;
497+ let colors = color_map(&all.iter().map(|c| c.id).collect::<Vec<_>>());
498+
499+ let minutes = q.minutes.unwrap_or(30).max(1);
500+ let date = q.date.unwrap_or(today).min(today);
501+ let next = q.prochaine.unwrap_or(proposal.date);
502+ let rhythm = match proposal.rhythm_days {
503+ Some(d) => format!("Rythme constaté : tous les ~{d} jour{}", if d > 1 { "s" } else { "" }),
504+ None => format!("Pas encore de rythme constaté — proposition à {} jours.", proposal.days),
505+ };
506+
507+ let shell = shell(&state, &ctx, "Tâche effectuée", "todos", None).await?;
508+ Ok(HtmlTemplate(TodoDonePage {
509+ csrf: ctx.user.csrf_token(&state),
510+ id,
511+ name: todo.name.clone(),
512+ color: colors.get(&todo.category_id).copied().unwrap_or("#a19786"),
513+ minutes,
514+ minutes_pretty: fmt_minutes(minutes as i64),
515+ minus_href: done_href(id, (minutes - 5).max(1), date, next),
516+ plus_href: done_href(id, minutes + 5, date, next),
517+ presets: [5, 15, 30, 45, 60]
518+ .into_iter()
519+ .map(|m| DonePreset {
520+ label: match m {
521+ 5 => "5 min",
522+ 15 => "15 min",
523+ 30 => "30 min",
524+ 45 => "45 min",
525+ _ => "1 h",
526+ },
527+ href: done_href(id, m, date, next),
528+ minutes: m,
529+ selected: m == minutes,
530+ })
531+ .collect(),
532+ date,
533+ day_prev: done_href(id, minutes, date - Duration::days(1), next),
534+ day_next: (date < today).then(|| done_href(id, minutes, date + Duration::days(1), next)),
535+ next,
536+ next_label: day_label(next),
537+ next_prev: done_href(id, minutes, date, next - Duration::days(1)),
538+ next_next: done_href(id, minutes, date, next + Duration::days(1)),
539+ rhythm,
540+ shell,
541+ }))
542+}
543+
544+#[derive(Deserialize)]
545+pub struct DoneForm {
546+ csrf: String,
547+ minutes: Option<i32>,
548+ date: Option<NaiveDate>,
549+ /// Toggle « Enregistrer un temps » (absent = décoché).
550+ avec_temps: Option<String>,
551+ prochaine: Option<NaiveDate>,
552+ /// Toggle « Reprogrammer » (absent = décoché).
553+ reprogrammer: Option<String>,
554+}
555+
556+pub async fn todo_finish(
557+ State(state): State<AppState>,
558+ ctx: ActiveTeam,
559+ Path(id): Path<Uuid>,
560+ Form(form): Form<DoneForm>,
561+) -> Result<Redirect, AppError> {
562+ if !ctx.user.check_csrf(&state, &form.csrf) {
563+ return Err(AppError::Csrf);
564+ }
565+ let today = paris_today();
566+ let time = if form.avec_temps.is_some() {
567+ let minutes = form.minutes.unwrap_or(0);
568+ if minutes < 1 {
569+ return Ok(Redirect::to(&format!("/taches/{id}/terminer?err=duree")));
570+ }
571+ Some((minutes, form.date.unwrap_or(today).min(today)))
572+ } else {
573+ None
574+ };
575+ let next = if form.reprogrammer.is_some() {
576+ match form.prochaine {
577+ Some(d) => Some(d),
578+ None => return Ok(Redirect::to(&format!("/taches/{id}/terminer?err=date"))),
579+ }
580+ } else {
581+ None
582+ };
583+ let reprogrammed = next.is_some();
584+ match todos::finish(&state.pool, ctx.team_id, ctx.user.id, id, time, next).await? {
585+ 0 => Err(AppError::NotFound),
586+ _ => Ok(Redirect::to(if reprogrammed {
587+ "/taches?ok=faite_reprog"
588+ } else {
589+ "/taches?ok=faite"
590+ })),
591+ }
592+}
added src/schedule.rs +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+use chrono::NaiveDate;
2+
3+/// Proposition de reprogrammation d'une todo au moment de sa complétion.
4+pub struct Proposal {
5+ /// Date proposée (aujourd'hui + rythme).
6+ pub date: NaiveDate,
7+ /// Rythme constaté en jours (médiane) — None tant qu'il n'y a aucune
8+ /// complétion passée (première fois : proxy création → activation).
9+ pub rhythm_days: Option<i64>,
10+ /// Intervalle effectivement appliqué, pour le libellé.
11+ pub days: i64,
12+}
13+
14+/// La complétion en cours (aujourd'hui) compte comme une occurrence : dès la
15+/// deuxième complétion on a un intervalle réel. Médiane (robuste aux pauses),
16+/// jamais < 1 jour. Première complétion : proxy `activation − création`,
17+/// plancher 7 jours.
18+pub fn propose(
19+ created: NaiveDate,
20+ activation: NaiveDate,
21+ past_dones: &[NaiveDate],
22+ today: NaiveDate,
23+) -> Proposal {
24+ let mut dones: Vec<NaiveDate> = past_dones.to_vec();
25+ dones.sort();
26+ dones.push(today);
27+ let mut intervals: Vec<i64> = dones
28+ .windows(2)
29+ .map(|w| (w[1] - w[0]).num_days().max(1))
30+ .collect();
31+ if intervals.is_empty() {
32+ let proxy = (activation - created).num_days().max(7);
33+ return Proposal {
34+ date: today + chrono::Days::new(proxy as u64),
35+ rhythm_days: None,
36+ days: proxy,
37+ };
38+ }
39+ intervals.sort();
40+ let mid = intervals.len() / 2;
41+ let median = if intervals.len() % 2 == 1 {
42+ intervals[mid]
43+ } else {
44+ (intervals[mid - 1] + intervals[mid]) / 2
45+ }
46+ .max(1);
47+ Proposal {
48+ date: today + chrono::Days::new(median as u64),
49+ rhythm_days: Some(median),
50+ days: median,
51+ }
52+}
53+
54+#[cfg(test)]
55+mod tests {
56+ use super::*;
57+
58+ fn d(s: &str) -> NaiveDate {
59+ s.parse().unwrap()
60+ }
61+
62+ #[test]
63+ fn first_completion_uses_creation_activation_proxy() {
64+ // Créée 10 jours avant son activation → proxy 10 jours.
65+ let p = propose(d("2026-08-01"), d("2026-08-11"), &[], d("2026-08-12"));
66+ assert_eq!(p.rhythm_days, None);
67+ assert_eq!(p.days, 10);
68+ assert_eq!(p.date, d("2026-08-22"));
69+ }
70+
71+ #[test]
72+ fn first_completion_floors_at_seven_days() {
73+ // Créée le jour même (proxy 0) → plancher 7 jours.
74+ let p = propose(d("2026-08-08"), d("2026-08-08"), &[], d("2026-08-08"));
75+ assert_eq!(p.days, 7);
76+ assert_eq!(p.date, d("2026-08-15"));
77+ }
78+
79+ #[test]
80+ fn second_completion_uses_real_gap() {
81+ // Une complétion passée il y a 5 jours → rythme 5 jours dès la 2e fois.
82+ let p = propose(d("2026-07-01"), d("2026-07-01"), &[d("2026-08-03")], d("2026-08-08"));
83+ assert_eq!(p.rhythm_days, Some(5));
84+ assert_eq!(p.date, d("2026-08-13"));
85+ }
86+
87+ #[test]
88+ fn median_resists_long_pause() {
89+ // Cadence hebdo avec une pause de 60 jours : la médiane reste ~7.
90+ let dones = [d("2026-05-01"), d("2026-05-08"), d("2026-05-15"), d("2026-07-14")];
91+ let p = propose(d("2026-04-01"), d("2026-04-01"), &dones, d("2026-07-21"));
92+ assert_eq!(p.rhythm_days, Some(7));
93+ }
94+
95+ #[test]
96+ fn same_day_completions_never_zero() {
97+ let p = propose(d("2026-08-01"), d("2026-08-01"), &[d("2026-08-08")], d("2026-08-08"));
98+ assert_eq!(p.rhythm_days, Some(1));
99+ }
100+
101+ #[test]
102+ fn even_interval_count_takes_middle_average() {
103+ // Intervalles 4 et 8 → médiane (4+8)/2 = 6.
104+ let dones = [d("2026-08-01"), d("2026-08-05")];
105+ let p = propose(d("2026-07-01"), d("2026-07-01"), &dones, d("2026-08-13"));
106+ assert_eq!(p.rhythm_days, Some(6));
107+ }
108+}
new file mode 100644
@@ -0,0 +1,108 @@
1+use chrono::NaiveDate;
2+
3+/// Proposition de reprogrammation d'une todo au moment de sa complétion.
4+pub struct Proposal {
5+ /// Date proposée (aujourd'hui + rythme).
6+ pub date: NaiveDate,
7+ /// Rythme constaté en jours (médiane) — None tant qu'il n'y a aucune
8+ /// complétion passée (première fois : proxy création → activation).
9+ pub rhythm_days: Option<i64>,
10+ /// Intervalle effectivement appliqué, pour le libellé.
11+ pub days: i64,
12+}
13+
14+/// La complétion en cours (aujourd'hui) compte comme une occurrence : dès la
15+/// deuxième complétion on a un intervalle réel. Médiane (robuste aux pauses),
16+/// jamais < 1 jour. Première complétion : proxy `activation − création`,
17+/// plancher 7 jours.
18+pub fn propose(
19+ created: NaiveDate,
20+ activation: NaiveDate,
21+ past_dones: &[NaiveDate],
22+ today: NaiveDate,
23+) -> Proposal {
24+ let mut dones: Vec<NaiveDate> = past_dones.to_vec();
25+ dones.sort();
26+ dones.push(today);
27+ let mut intervals: Vec<i64> = dones
28+ .windows(2)
29+ .map(|w| (w[1] - w[0]).num_days().max(1))
30+ .collect();
31+ if intervals.is_empty() {
32+ let proxy = (activation - created).num_days().max(7);
33+ return Proposal {
34+ date: today + chrono::Days::new(proxy as u64),
35+ rhythm_days: None,
36+ days: proxy,
37+ };
38+ }
39+ intervals.sort();
40+ let mid = intervals.len() / 2;
41+ let median = if intervals.len() % 2 == 1 {
42+ intervals[mid]
43+ } else {
44+ (intervals[mid - 1] + intervals[mid]) / 2
45+ }
46+ .max(1);
47+ Proposal {
48+ date: today + chrono::Days::new(median as u64),
49+ rhythm_days: Some(median),
50+ days: median,
51+ }
52+}
53+
54+#[cfg(test)]
55+mod tests {
56+ use super::*;
57+
58+ fn d(s: &str) -> NaiveDate {
59+ s.parse().unwrap()
60+ }
61+
62+ #[test]
63+ fn first_completion_uses_creation_activation_proxy() {
64+ // Créée 10 jours avant son activation → proxy 10 jours.
65+ let p = propose(d("2026-08-01"), d("2026-08-11"), &[], d("2026-08-12"));
66+ assert_eq!(p.rhythm_days, None);
67+ assert_eq!(p.days, 10);
68+ assert_eq!(p.date, d("2026-08-22"));
69+ }
70+
71+ #[test]
72+ fn first_completion_floors_at_seven_days() {
73+ // Créée le jour même (proxy 0) → plancher 7 jours.
74+ let p = propose(d("2026-08-08"), d("2026-08-08"), &[], d("2026-08-08"));
75+ assert_eq!(p.days, 7);
76+ assert_eq!(p.date, d("2026-08-15"));
77+ }
78+
79+ #[test]
80+ fn second_completion_uses_real_gap() {
81+ // Une complétion passée il y a 5 jours → rythme 5 jours dès la 2e fois.
82+ let p = propose(d("2026-07-01"), d("2026-07-01"), &[d("2026-08-03")], d("2026-08-08"));
83+ assert_eq!(p.rhythm_days, Some(5));
84+ assert_eq!(p.date, d("2026-08-13"));
85+ }
86+
87+ #[test]
88+ fn median_resists_long_pause() {
89+ // Cadence hebdo avec une pause de 60 jours : la médiane reste ~7.
90+ let dones = [d("2026-05-01"), d("2026-05-08"), d("2026-05-15"), d("2026-07-14")];
91+ let p = propose(d("2026-04-01"), d("2026-04-01"), &dones, d("2026-07-21"));
92+ assert_eq!(p.rhythm_days, Some(7));
93+ }
94+
95+ #[test]
96+ fn same_day_completions_never_zero() {
97+ let p = propose(d("2026-08-01"), d("2026-08-01"), &[d("2026-08-08")], d("2026-08-08"));
98+ assert_eq!(p.rhythm_days, Some(1));
99+ }
100+
101+ #[test]
102+ fn even_interval_count_takes_middle_average() {
103+ // Intervalles 4 et 8 → médiane (4+8)/2 = 6.
104+ let dones = [d("2026-08-01"), d("2026-08-05")];
105+ let p = propose(d("2026-07-01"), d("2026-07-01"), &dones, d("2026-08-13"));
106+ assert_eq!(p.rhythm_days, Some(6));
107+ }
108+}
modified templates/app.html +6 -3
@@ -97,9 +97,12 @@
9797 <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>
9898 <span>Stats</span>
9999 </a>
100- <a class="tab" href="/temps" {% if shell.tab == "times" %}aria-current="page"{% endif %}>
101- <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>
102- <span>Mes temps</span>
100+ <a class="tab" href="/taches" {% if shell.tab == "todos" %}aria-current="page"{% endif %}>
101+ <span class="tab-ico">
102+ <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="M11 6h10M11 12h10M11 18h10"></path><path d="M3 6.5l1.6 1.6L7.5 5"></path><path d="M3 12.5l1.6 1.6L7.5 11"></path><path d="M3 18.5l1.6 1.6L7.5 17"></path></svg>
103+ {% if shell.todo_count > 0 %}<span class="tab-badge">{{ shell.todo_count }}</span>{% endif %}
104+ </span>
105+ <span>Tâches</span>
103106 </a>
104107 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>
105108 <span class="tab-ico">
@@ -97,9 +97,12 @@
97 <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>97 <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>
98 <span>Stats</span>98 <span>Stats</span>
99 </a>99 </a>
100- <a class="tab" href="/temps" {% if shell.tab == "times" %}aria-current="page"{% endif %}>100+ <a class="tab" href="/taches" {% if shell.tab == "todos" %}aria-current="page"{% endif %}>
101- <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>101+ <span class="tab-ico">
102- <span>Mes temps</span>102+ <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="M11 6h10M11 12h10M11 18h10"></path><path d="M3 6.5l1.6 1.6L7.5 5"></path><path d="M3 12.5l1.6 1.6L7.5 11"></path><path d="M3 18.5l1.6 1.6L7.5 17"></path></svg>
103+ {% if shell.todo_count > 0 %}<span class="tab-badge">{{ shell.todo_count }}</span>{% endif %}
104+ </span>
105+ <span>Tâches</span>
103 </a>106 </a>
104 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>107 <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}>
105 <span class="tab-ico">108 <span class="tab-ico">
modified templates/settings.html +9 -1
@@ -21,12 +21,20 @@
2121 </div>
2222 {% endif %}
2323
24-<div class="card" style="margin-bottom:var(--space-6)">
24+<div class="card" style="margin-bottom:var(--space-3)">
2525 <div class="card-kicker">Compte</div>
2626 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>
2727 <p class="card-body">{{ account_line }}</p>
2828 </div>
2929
30+<a class="card times-link" href="/temps" style="margin-bottom:var(--space-6)">
31+ <span style="flex:1">
32+ <span style="display:block;font-family:var(--font-heading);font-size:17px">Mes saisies</span>
33+ <span style="display:block;font-size:13px;opacity:.7">Consulter et corriger les temps que j'ai enregistrés.</span>
34+ </span>
35+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg>
36+</a>
37+
3038 <form method="post" action="/equipes/renommer">
3139 <input type="hidden" name="csrf" value="{{ csrf }}">
3240 <div class="field" style="margin-bottom:var(--space-2)">
@@ -21,12 +21,20 @@
21 </div>21 </div>
22 {% endif %}22 {% endif %}
23 23
24-<div class="card" style="margin-bottom:var(--space-6)">24+<div class="card" style="margin-bottom:var(--space-3)">
25 <div class="card-kicker">Compte</div>25 <div class="card-kicker">Compte</div>
26 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>26 <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div>
27 <p class="card-body">{{ account_line }}</p>27 <p class="card-body">{{ account_line }}</p>
28 </div>28 </div>
29 29
30+<a class="card times-link" href="/temps" style="margin-bottom:var(--space-6)">
31+ <span style="flex:1">
32+ <span style="display:block;font-family:var(--font-heading);font-size:17px">Mes saisies</span>
33+ <span style="display:block;font-size:13px;opacity:.7">Consulter et corriger les temps que j'ai enregistrés.</span>
34+ </span>
35+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg>
36+</a>
37+
30 <form method="post" action="/equipes/renommer">38 <form method="post" action="/equipes/renommer">
31 <input type="hidden" name="csrf" value="{{ csrf }}">39 <input type="hidden" name="csrf" value="{{ csrf }}">
32 <div class="field" style="margin-bottom:var(--space-2)">40 <div class="field" style="margin-bottom:var(--space-2)">
modified templates/stats.html +1 -0
@@ -1,5 +1,6 @@
11 {% extends "app.html" %}
22 {% block content %}
3+<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0;font-size:13px" href="/temps">Mes saisies →</a>
34 {% if !legend.is_empty() %}
45 <ul class="legend">
56 {% for item in legend %}
@@ -1,5 +1,6 @@
1 {% extends "app.html" %}1 {% extends "app.html" %}
2 {% block content %}2 {% block content %}
3+<a class="btn btn-ghost" style="margin:0 0 var(--space-3);padding-left:0;font-size:13px" href="/temps">Mes saisies →</a>
3 {% if !legend.is_empty() %}4 {% if !legend.is_empty() %}
4 <ul class="legend">5 <ul class="legend">
5 {% for item in legend %}6 {% for item in legend %}
added templates/todo_done.html +126 -0
new file mode 100644
@@ -0,0 +1,126 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a>
4+
5+<div class="done-title">
6+ <span class="done-check" aria-hidden="true">
7+ <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="M20 6L9 17l-5-5"></path></svg>
8+ </span>
9+ <span class="team-dot" style="background:{{ color }}"></span>
10+ <span class="done-name">{{ name }}</span>
11+</div>
12+
13+<form method="post" action="/taches/{{ id }}/terminer">
14+ <input type="hidden" name="csrf" value="{{ csrf }}">
15+
16+ <section class="done-sec">
17+ <div class="done-head">
18+ <h2>Durée passée</h2>
19+ <label class="swt-check">
20+ Enregistrer un temps
21+ <input type="checkbox" name="avec_temps" value="1" checked>
22+ <span class="swt"><span class="knob"></span></span>
23+ </label>
24+ </div>
25+ <div class="done-body">
26+ <div class="duration-row">
27+ <div class="stepper">
28+ <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="Cinq minutes de moins">
29+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg>
30+ </a>
31+ <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric"
32+ min="1" required value="{{ minutes }}" aria-label="Minutes passées">
33+ <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus">
34+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg>
35+ </a>
36+ </div>
37+ <div>
38+ <div class="unit">minutes</div>
39+ <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div>
40+ </div>
41+ </div>
42+ <div class="presets">
43+ {% for p in presets %}
44+ <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a>
45+ {% endfor %}
46+ </div>
47+ <label class="mini-label" for="done-date" style="margin-top:var(--space-3)">Date du temps</label>
48+ <div class="date-row">
49+ <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="Jour précédent">
50+ <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>
51+ </a>
52+ <input class="input" id="done-date" name="date" type="date" value="{{ date }}" required>
53+ {% if let Some(next_href) = day_next %}
54+ <a class="btn btn-secondary btn-icon" href="{{ next_href }}" aria-label="Jour suivant">
55+ <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>
56+ </a>
57+ {% else %}
58+ <span class="btn btn-secondary btn-icon" aria-disabled="true">
59+ <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>
60+ </span>
61+ {% endif %}
62+ </div>
63+ </div>
64+ </section>
65+
66+ <section class="done-sec" style="margin-top:var(--space-8)">
67+ <div class="done-head">
68+ <h2>Reprogrammer</h2>
69+ <label class="swt-check">
70+ Reprogrammer
71+ <input type="checkbox" name="reprogrammer" value="1" checked>
72+ <span class="swt"><span class="knob"></span></span>
73+ </label>
74+ </div>
75+ <div class="done-body">
76+ <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p>
77+ <div class="date-row">
78+ <a class="btn btn-secondary btn-icon" href="{{ next_prev }}" aria-label="Jour précédent">
79+ <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>
80+ </a>
81+ <input class="input" name="prochaine" type="date" value="{{ next }}" required aria-label="Prochaine activation">
82+ <a class="btn btn-secondary btn-icon" href="{{ next_next }}" aria-label="Jour suivant">
83+ <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>
84+ </a>
85+ </div>
86+ <p style="font-size:13px;opacity:.7;margin:var(--space-2) 0 0">{{ next_label }}</p>
87+ </div>
88+ </section>
89+
90+ <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px;margin-top:var(--space-8)">Valider</button>
91+</form>
92+
93+<script>
94+// Amélioration progressive : ±5 min et presets sans recharger (mêmes liens
95+// href en secours sans JS, comme l'étape 2 d'ajout de temps).
96+(function () {
97+ var input = document.getElementById('minutes');
98+ var pretty = document.getElementById('minutes-pretty');
99+ function dur(m) {
100+ if (!m || m < 1) return '0 min';
101+ var h = Math.floor(m / 60), r = m % 60;
102+ if (!h) return r + ' min';
103+ return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
104+ }
105+ function refresh() {
106+ var v = Math.round(+input.value) || 0;
107+ pretty.textContent = dur(v);
108+ document.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
109+ if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); }
110+ else { p.removeAttribute('aria-current'); }
111+ });
112+ }
113+ document.querySelectorAll('.stepper-btn[data-step]').forEach(function (btn) {
114+ btn.addEventListener('click', function (e) {
115+ e.preventDefault();
116+ input.value = Math.max(5, (Math.round(+input.value) || 0) + (+btn.dataset.step));
117+ refresh();
118+ });
119+ });
120+ document.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
121+ p.addEventListener('click', function (e) { e.preventDefault(); input.value = +p.dataset.minutes; refresh(); });
122+ });
123+ input.addEventListener('input', refresh);
124+})();
125+</script>
126+{% endblock %}
new file mode 100644
@@ -0,0 +1,126 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a>
4+
5+<div class="done-title">
6+ <span class="done-check" aria-hidden="true">
7+ <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="M20 6L9 17l-5-5"></path></svg>
8+ </span>
9+ <span class="team-dot" style="background:{{ color }}"></span>
10+ <span class="done-name">{{ name }}</span>
11+</div>
12+
13+<form method="post" action="/taches/{{ id }}/terminer">
14+ <input type="hidden" name="csrf" value="{{ csrf }}">
15+
16+ <section class="done-sec">
17+ <div class="done-head">
18+ <h2>Durée passée</h2>
19+ <label class="swt-check">
20+ Enregistrer un temps
21+ <input type="checkbox" name="avec_temps" value="1" checked>
22+ <span class="swt"><span class="knob"></span></span>
23+ </label>
24+ </div>
25+ <div class="done-body">
26+ <div class="duration-row">
27+ <div class="stepper">
28+ <a class="stepper-btn" href="{{ minus_href }}" data-step="-5" aria-label="Cinq minutes de moins">
29+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg>
30+ </a>
31+ <input class="input minutes-input" id="minutes" name="minutes" type="number" inputmode="numeric"
32+ min="1" required value="{{ minutes }}" aria-label="Minutes passées">
33+ <a class="stepper-btn" href="{{ plus_href }}" data-step="5" aria-label="Cinq minutes de plus">
34+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg>
35+ </a>
36+ </div>
37+ <div>
38+ <div class="unit">minutes</div>
39+ <div class="pretty" id="minutes-pretty">{{ minutes_pretty }}</div>
40+ </div>
41+ </div>
42+ <div class="presets">
43+ {% for p in presets %}
44+ <a class="preset" href="{{ p.href }}" data-minutes="{{ p.minutes }}" {% if p.selected %}aria-current="true"{% endif %}>{{ p.label }}</a>
45+ {% endfor %}
46+ </div>
47+ <label class="mini-label" for="done-date" style="margin-top:var(--space-3)">Date du temps</label>
48+ <div class="date-row">
49+ <a class="btn btn-secondary btn-icon" href="{{ day_prev }}" aria-label="Jour précédent">
50+ <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>
51+ </a>
52+ <input class="input" id="done-date" name="date" type="date" value="{{ date }}" required>
53+ {% if let Some(next_href) = day_next %}
54+ <a class="btn btn-secondary btn-icon" href="{{ next_href }}" aria-label="Jour suivant">
55+ <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>
56+ </a>
57+ {% else %}
58+ <span class="btn btn-secondary btn-icon" aria-disabled="true">
59+ <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>
60+ </span>
61+ {% endif %}
62+ </div>
63+ </div>
64+ </section>
65+
66+ <section class="done-sec" style="margin-top:var(--space-8)">
67+ <div class="done-head">
68+ <h2>Reprogrammer</h2>
69+ <label class="swt-check">
70+ Reprogrammer
71+ <input type="checkbox" name="reprogrammer" value="1" checked>
72+ <span class="swt"><span class="knob"></span></span>
73+ </label>
74+ </div>
75+ <div class="done-body">
76+ <p style="font-size:13px;opacity:.7;margin:0 0 var(--space-3)">{{ rhythm }}</p>
77+ <div class="date-row">
78+ <a class="btn btn-secondary btn-icon" href="{{ next_prev }}" aria-label="Jour précédent">
79+ <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>
80+ </a>
81+ <input class="input" name="prochaine" type="date" value="{{ next }}" required aria-label="Prochaine activation">
82+ <a class="btn btn-secondary btn-icon" href="{{ next_next }}" aria-label="Jour suivant">
83+ <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>
84+ </a>
85+ </div>
86+ <p style="font-size:13px;opacity:.7;margin:var(--space-2) 0 0">{{ next_label }}</p>
87+ </div>
88+ </section>
89+
90+ <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px;margin-top:var(--space-8)">Valider</button>
91+</form>
92+
93+<script>
94+// Amélioration progressive : ±5 min et presets sans recharger (mêmes liens
95+// href en secours sans JS, comme l'étape 2 d'ajout de temps).
96+(function () {
97+ var input = document.getElementById('minutes');
98+ var pretty = document.getElementById('minutes-pretty');
99+ function dur(m) {
100+ if (!m || m < 1) return '0 min';
101+ var h = Math.floor(m / 60), r = m % 60;
102+ if (!h) return r + ' min';
103+ return r ? h + ' h ' + (r < 10 ? '0' + r : r) : h + ' h';
104+ }
105+ function refresh() {
106+ var v = Math.round(+input.value) || 0;
107+ pretty.textContent = dur(v);
108+ document.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
109+ if (+p.dataset.minutes === v) { p.setAttribute('aria-current', 'true'); }
110+ else { p.removeAttribute('aria-current'); }
111+ });
112+ }
113+ document.querySelectorAll('.stepper-btn[data-step]').forEach(function (btn) {
114+ btn.addEventListener('click', function (e) {
115+ e.preventDefault();
116+ input.value = Math.max(5, (Math.round(+input.value) || 0) + (+btn.dataset.step));
117+ refresh();
118+ });
119+ });
120+ document.querySelectorAll('.preset[data-minutes]').forEach(function (p) {
121+ p.addEventListener('click', function (e) { e.preventDefault(); input.value = +p.dataset.minutes; refresh(); });
122+ });
123+ input.addEventListener('input', refresh);
124+})();
125+</script>
126+{% endblock %}
added templates/todo_form.html +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a>
4+
5+{% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
6+
7+<form method="post" action="{% if let Some(id) = edit_id %}/taches/{{ id }}{% else %}/taches{% endif %}">
8+ <input type="hidden" name="csrf" value="{{ csrf }}">
9+
10+ <div class="field" style="margin-bottom:var(--space-4)">
11+ <label for="todo-nom">Nom de la tâche</label>
12+ <input class="input" id="todo-nom" name="nom" value="{{ name }}" required
13+ placeholder="Nettoyer la salle de bain" style="min-height:46px">
14+ </div>
15+
16+ <div class="field" style="margin-bottom:var(--space-2)">
17+ <label id="todo-cat-label">Catégorie</label>
18+ </div>
19+ <div class="chips" role="radiogroup" aria-labelledby="todo-cat-label" style="margin-bottom:var(--space-6)">
20+ {% for c in cats %}
21+ <label class="chip">
22+ <input type="radio" name="categorie" value="{{ c.id }}" required {% if c.selected %}checked{% endif %}>
23+ <span class="cdot" style="background:{{ c.color }}"></span>{{ c.name }}
24+ </label>
25+ {% endfor %}
26+ </div>
27+
28+ <div class="field" style="margin-bottom:var(--space-2)">
29+ <label for="todo-date">Date d'activation</label>
30+ <div style="display:flex;gap:var(--space-2);align-items:center">
31+ <button type="button" class="btn btn-secondary btn-icon" data-day="-1" aria-label="Jour précédent" hidden>
32+ <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>
33+ </button>
34+ <input class="input" id="todo-date" name="date" type="date" value="{{ date }}" required
35+ style="flex:1;min-height:46px;text-align:center">
36+ <button type="button" class="btn btn-secondary btn-icon" data-day="1" aria-label="Jour suivant" hidden>
37+ <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>
38+ </button>
39+ </div>
40+ </div>
41+ <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p>
42+
43+ <div class="field" style="margin-bottom:var(--space-2)">
44+ <label id="todo-asg-label">Assigner à</label>
45+ </div>
46+ <div class="chips" role="group" aria-labelledby="todo-asg-label" style="margin-bottom:var(--space-2)">
47+ {% for m in members %}
48+ <label class="chip">
49+ <input type="checkbox" name="assigne" value="{{ m.id }}" {% if m.selected %}checked{% endif %}>
50+ {{ m.name }}
51+ </label>
52+ {% endfor %}
53+ </div>
54+ <p style="font-size:12px;opacity:.7;margin:0 0 var(--space-8)">Facultatif. Les autres membres peuvent quand même prendre la tâche.</p>
55+
56+ <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">
57+ {% if edit_id.is_some() %}Enregistrer{% else %}Créer la tâche{% endif %}
58+ </button>
59+</form>
60+
61+{% if let Some(id) = edit_id %}
62+<form method="post" action="/taches/{{ id }}/supprimer" style="margin-top:var(--space-3)">
63+ <input type="hidden" name="csrf" value="{{ csrf }}">
64+ <button type="submit" class="btn btn-secondary btn-block" style="min-height:48px">Supprimer la tâche</button>
65+</form>
66+{% endif %}
67+
68+<script>
69+// Amélioration progressive : steppers ± 1 jour (sans JS, l'input date suffit).
70+(function () {
71+ var input = document.getElementById('todo-date');
72+ document.querySelectorAll('button[data-day]').forEach(function (b) {
73+ b.hidden = false;
74+ b.addEventListener('click', function () {
75+ var d = input.valueAsDate || new Date();
76+ d.setDate(d.getDate() + (+b.dataset.day));
77+ input.valueAsDate = d;
78+ });
79+ });
80+})();
81+</script>
82+{% endblock %}
new file mode 100644
@@ -0,0 +1,82 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<a class="btn btn-ghost" style="margin:0 0 var(--space-4);padding-left:0" href="/taches">← Tâches</a>
4+
5+{% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %}
6+
7+<form method="post" action="{% if let Some(id) = edit_id %}/taches/{{ id }}{% else %}/taches{% endif %}">
8+ <input type="hidden" name="csrf" value="{{ csrf }}">
9+
10+ <div class="field" style="margin-bottom:var(--space-4)">
11+ <label for="todo-nom">Nom de la tâche</label>
12+ <input class="input" id="todo-nom" name="nom" value="{{ name }}" required
13+ placeholder="Nettoyer la salle de bain" style="min-height:46px">
14+ </div>
15+
16+ <div class="field" style="margin-bottom:var(--space-2)">
17+ <label id="todo-cat-label">Catégorie</label>
18+ </div>
19+ <div class="chips" role="radiogroup" aria-labelledby="todo-cat-label" style="margin-bottom:var(--space-6)">
20+ {% for c in cats %}
21+ <label class="chip">
22+ <input type="radio" name="categorie" value="{{ c.id }}" required {% if c.selected %}checked{% endif %}>
23+ <span class="cdot" style="background:{{ c.color }}"></span>{{ c.name }}
24+ </label>
25+ {% endfor %}
26+ </div>
27+
28+ <div class="field" style="margin-bottom:var(--space-2)">
29+ <label for="todo-date">Date d'activation</label>
30+ <div style="display:flex;gap:var(--space-2);align-items:center">
31+ <button type="button" class="btn btn-secondary btn-icon" data-day="-1" aria-label="Jour précédent" hidden>
32+ <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>
33+ </button>
34+ <input class="input" id="todo-date" name="date" type="date" value="{{ date }}" required
35+ style="flex:1;min-height:46px;text-align:center">
36+ <button type="button" class="btn btn-secondary btn-icon" data-day="1" aria-label="Jour suivant" hidden>
37+ <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>
38+ </button>
39+ </div>
40+ </div>
41+ <p id="todo-date-label" style="font-size:13px;opacity:.7;margin:0 0 var(--space-6)">{{ date_label }}</p>
42+
43+ <div class="field" style="margin-bottom:var(--space-2)">
44+ <label id="todo-asg-label">Assigner à</label>
45+ </div>
46+ <div class="chips" role="group" aria-labelledby="todo-asg-label" style="margin-bottom:var(--space-2)">
47+ {% for m in members %}
48+ <label class="chip">
49+ <input type="checkbox" name="assigne" value="{{ m.id }}" {% if m.selected %}checked{% endif %}>
50+ {{ m.name }}
51+ </label>
52+ {% endfor %}
53+ </div>
54+ <p style="font-size:12px;opacity:.7;margin:0 0 var(--space-8)">Facultatif. Les autres membres peuvent quand même prendre la tâche.</p>
55+
56+ <button type="submit" class="btn btn-primary btn-block" style="min-height:52px;font-size:16px">
57+ {% if edit_id.is_some() %}Enregistrer{% else %}Créer la tâche{% endif %}
58+ </button>
59+</form>
60+
61+{% if let Some(id) = edit_id %}
62+<form method="post" action="/taches/{{ id }}/supprimer" style="margin-top:var(--space-3)">
63+ <input type="hidden" name="csrf" value="{{ csrf }}">
64+ <button type="submit" class="btn btn-secondary btn-block" style="min-height:48px">Supprimer la tâche</button>
65+</form>
66+{% endif %}
67+
68+<script>
69+// Amélioration progressive : steppers ± 1 jour (sans JS, l'input date suffit).
70+(function () {
71+ var input = document.getElementById('todo-date');
72+ document.querySelectorAll('button[data-day]').forEach(function (b) {
73+ b.hidden = false;
74+ b.addEventListener('click', function () {
75+ var d = input.valueAsDate || new Date();
76+ d.setDate(d.getDate() + (+b.dataset.day));
77+ input.valueAsDate = d;
78+ });
79+ });
80+})();
81+</script>
82+{% endblock %}
added templates/todos.html +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<div class="todo-toolbar">
4+ <a class="btn btn-primary" style="min-height:40px" href="/taches/nouvelle">+ Nouvelle tâche</a>
5+</div>
6+
7+{% if empty %}
8+<div class="todo-empty">
9+ <p class="te-title">Aucune tâche pour l'instant</p>
10+ <p class="te-body">Ajoute ce qu'il y a à faire dans {{ shell.team_name }} : chacun pourra la prendre, la faire, et le temps passé ira dans les stats.</p>
11+</div>
12+{% endif %}
13+
14+{% if has_doing %}
15+<div class="blk-head">
16+ <h2>En cours</h2>
17+ <a class="blk-toggle" role="switch" aria-checked="{{ doing_filter_on }}" href="{{ doing_toggle_href }}">
18+ Mes tâches
19+ <span class="swt{% if doing_filter_on %} on{% endif %}"><span class="knob"></span></span>
20+ </a>
21+</div>
22+{% if doing.is_empty() %}
23+<p class="blk-empty">Aucune tâche en cours ne t'est assignée.</p>
24+{% else %}
25+<div class="todo-grid">
26+ {% for t in doing %}
27+ <div class="todo-card{% if t.other %} other{% endif %}{% if t.for_me %} forme{% endif %}">
28+ <div class="todo-main">
29+ <span class="team-dot" style="background:{{ t.color }}"></span>
30+ <div style="flex:1;min-width:0">
31+ <div class="todo-name">{{ t.name }}</div>
32+ <div class="todo-meta">{{ t.meta }}</div>
33+ {% if !t.assignee_line.is_empty() %}<div class="todo-for{% if t.for_me %} forme{% endif %}">{{ t.assignee_line }}</div>{% endif %}
34+ </div>
35+ {% if t.mine %}
36+ <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}">
37+ <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>
38+ </a>
39+ {% endif %}
40+ </div>
41+ {% if t.mine %}
42+ <div class="todo-actions">
43+ <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a>
44+ <form method="post" action="/taches/{{ t.id }}/relacher">
45+ <input type="hidden" name="csrf" value="{{ csrf }}">
46+ <button type="submit" class="btn btn-secondary" style="flex:0 1 auto">Relâcher</button>
47+ </form>
48+ </div>
49+ {% endif %}
50+ </div>
51+ {% endfor %}
52+</div>
53+{% endif %}
54+{% endif %}
55+
56+{% if has_open %}
57+<div class="blk-head">
58+ <h2>À faire</h2>
59+ <a class="blk-toggle" role="switch" aria-checked="{{ open_filter_on }}" href="{{ open_toggle_href }}">
60+ Mes tâches
61+ <span class="swt{% if open_filter_on %} on{% endif %}"><span class="knob"></span></span>
62+ </a>
63+</div>
64+{% if open.is_empty() %}
65+<p class="blk-empty">Aucune tâche à faire ne t'est assignée.</p>
66+{% else %}
67+<div class="todo-grid">
68+ {% for t in open %}
69+ <div class="todo-card{% if t.for_me %} forme{% endif %}">
70+ <div class="todo-main">
71+ <span class="team-dot" style="background:{{ t.color }}"></span>
72+ <div style="flex:1;min-width:0">
73+ <div class="todo-name">{{ t.name }}</div>
74+ <div class="todo-meta{% if t.late %} late{% endif %}">{{ t.meta }}</div>
75+ {% if !t.assignee_line.is_empty() %}<div class="todo-for{% if t.for_me %} forme{% endif %}">{{ t.assignee_line }}</div>{% endif %}
76+ </div>
77+ <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}">
78+ <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>
79+ </a>
80+ </div>
81+ <div class="todo-actions">
82+ <form method="post" action="/taches/{{ t.id }}/prendre">
83+ <input type="hidden" name="csrf" value="{{ csrf }}">
84+ <button type="submit" class="btn btn-secondary">Prendre</button>
85+ </form>
86+ <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a>
87+ </div>
88+ </div>
89+ {% endfor %}
90+</div>
91+{% endif %}
92+{% endif %}
93+
94+{% if !later.is_empty() %}
95+<details class="later">
96+ <summary>À venir · {{ later.len() }}</summary>
97+ <div style="margin-top:var(--space-3)">
98+ {% for t in later %}
99+ <div class="later-row">
100+ <span class="team-dot" style="background:{{ t.color }}"></span>
101+ <div style="flex:1;min-width:0">
102+ <div style="font-size:15px">{{ t.name }}</div>
103+ <div class="todo-meta">{{ t.meta }}</div>
104+ {% if !t.assignee_line.is_empty() %}<div class="todo-for{% if t.for_me %} forme{% endif %}">{{ t.assignee_line }}</div>{% endif %}
105+ </div>
106+ <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}">
107+ <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>
108+ </a>
109+ </div>
110+ {% endfor %}
111+ </div>
112+</details>
113+{% endif %}
114+{% endblock %}
new file mode 100644
@@ -0,0 +1,114 @@
1+{% extends "app.html" %}
2+{% block content %}
3+<div class="todo-toolbar">
4+ <a class="btn btn-primary" style="min-height:40px" href="/taches/nouvelle">+ Nouvelle tâche</a>
5+</div>
6+
7+{% if empty %}
8+<div class="todo-empty">
9+ <p class="te-title">Aucune tâche pour l'instant</p>
10+ <p class="te-body">Ajoute ce qu'il y a à faire dans {{ shell.team_name }} : chacun pourra la prendre, la faire, et le temps passé ira dans les stats.</p>
11+</div>
12+{% endif %}
13+
14+{% if has_doing %}
15+<div class="blk-head">
16+ <h2>En cours</h2>
17+ <a class="blk-toggle" role="switch" aria-checked="{{ doing_filter_on }}" href="{{ doing_toggle_href }}">
18+ Mes tâches
19+ <span class="swt{% if doing_filter_on %} on{% endif %}"><span class="knob"></span></span>
20+ </a>
21+</div>
22+{% if doing.is_empty() %}
23+<p class="blk-empty">Aucune tâche en cours ne t'est assignée.</p>
24+{% else %}
25+<div class="todo-grid">
26+ {% for t in doing %}
27+ <div class="todo-card{% if t.other %} other{% endif %}{% if t.for_me %} forme{% endif %}">
28+ <div class="todo-main">
29+ <span class="team-dot" style="background:{{ t.color }}"></span>
30+ <div style="flex:1;min-width:0">
31+ <div class="todo-name">{{ t.name }}</div>
32+ <div class="todo-meta">{{ t.meta }}</div>
33+ {% if !t.assignee_line.is_empty() %}<div class="todo-for{% if t.for_me %} forme{% endif %}">{{ t.assignee_line }}</div>{% endif %}
34+ </div>
35+ {% if t.mine %}
36+ <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}">
37+ <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>
38+ </a>
39+ {% endif %}
40+ </div>
41+ {% if t.mine %}
42+ <div class="todo-actions">
43+ <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a>
44+ <form method="post" action="/taches/{{ t.id }}/relacher">
45+ <input type="hidden" name="csrf" value="{{ csrf }}">
46+ <button type="submit" class="btn btn-secondary" style="flex:0 1 auto">Relâcher</button>
47+ </form>
48+ </div>
49+ {% endif %}
50+ </div>
51+ {% endfor %}
52+</div>
53+{% endif %}
54+{% endif %}
55+
56+{% if has_open %}
57+<div class="blk-head">
58+ <h2>À faire</h2>
59+ <a class="blk-toggle" role="switch" aria-checked="{{ open_filter_on }}" href="{{ open_toggle_href }}">
60+ Mes tâches
61+ <span class="swt{% if open_filter_on %} on{% endif %}"><span class="knob"></span></span>
62+ </a>
63+</div>
64+{% if open.is_empty() %}
65+<p class="blk-empty">Aucune tâche à faire ne t'est assignée.</p>
66+{% else %}
67+<div class="todo-grid">
68+ {% for t in open %}
69+ <div class="todo-card{% if t.for_me %} forme{% endif %}">
70+ <div class="todo-main">
71+ <span class="team-dot" style="background:{{ t.color }}"></span>
72+ <div style="flex:1;min-width:0">
73+ <div class="todo-name">{{ t.name }}</div>
74+ <div class="todo-meta{% if t.late %} late{% endif %}">{{ t.meta }}</div>
75+ {% if !t.assignee_line.is_empty() %}<div class="todo-for{% if t.for_me %} forme{% endif %}">{{ t.assignee_line }}</div>{% endif %}
76+ </div>
77+ <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}">
78+ <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>
79+ </a>
80+ </div>
81+ <div class="todo-actions">
82+ <form method="post" action="/taches/{{ t.id }}/prendre">
83+ <input type="hidden" name="csrf" value="{{ csrf }}">
84+ <button type="submit" class="btn btn-secondary">Prendre</button>
85+ </form>
86+ <a class="btn btn-primary" href="/taches/{{ t.id }}/terminer">Fait ✓</a>
87+ </div>
88+ </div>
89+ {% endfor %}
90+</div>
91+{% endif %}
92+{% endif %}
93+
94+{% if !later.is_empty() %}
95+<details class="later">
96+ <summary>À venir · {{ later.len() }}</summary>
97+ <div style="margin-top:var(--space-3)">
98+ {% for t in later %}
99+ <div class="later-row">
100+ <span class="team-dot" style="background:{{ t.color }}"></span>
101+ <div style="flex:1;min-width:0">
102+ <div style="font-size:15px">{{ t.name }}</div>
103+ <div class="todo-meta">{{ t.meta }}</div>
104+ {% if !t.assignee_line.is_empty() %}<div class="todo-for{% if t.for_me %} forme{% endif %}">{{ t.assignee_line }}</div>{% endif %}
105+ </div>
106+ <a class="btn btn-secondary btn-icon" style="flex:none" href="/taches/{{ t.id }}/modifier" aria-label="Modifier la tâche {{ t.name }}">
107+ <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>
108+ </a>
109+ </div>
110+ {% endfor %}
111+ </div>
112+</details>
113+{% endif %}
114+{% endblock %}