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
225c26f parent: bb28d5f modified
a11y/audit.mjs +8 -3 | @@ -1,6 +1,6 @@ | ||
| 1 | 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 échoue | |
| 3 | +// Parcourt les 12 écrans de l'app (viewport mobile 390×844) et échoue | |
| 4 | 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; |
| 5 | 5 | // les violations moindres sont listées en avertissement. |
| 6 | 6 | // |
| @@ -57,13 +57,15 @@ if (page.url().includes('/login')) { | ||
| 57 | 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 | 61 | const ids = await page.evaluate(async () => { |
| 62 | 62 | const step1 = await fetch('/temps/nouveau').then((r) => r.text()); |
| 63 | 63 | const times = await fetch('/temps').then((r) => r.text()); |
| 64 | + const todos = await fetch('/taches').then((r) => r.text()); | |
| 64 | 65 | return { |
| 65 | 66 | categorie: step1.match(/categorie=([0-9a-f-]{36})/)?.[1] ?? null, |
| 66 | 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 | 79 | { name: 'settings', url: '/parametres' }, |
| 78 | 80 | { name: 'categories', url: '/parametres/categories' }, |
| 79 | 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 | 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 | 88 | let blocking = 0; |
| 84 | 89 | 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 échoue | 3 | +// 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); } | ||
| 443 | 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 | 532 | /* — toast — */ |
| 447 | 533 | /* En haut : le bas de l'écran est déjà occupé par la pill timer et la tab bar. */ |
| 448 | 534 | .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; | ||
| 3 | 3 | pub mod stats; |
| 4 | 4 | pub mod categories; |
| 5 | 5 | pub mod timers; |
| 6 | +pub mod todos; | |
| 6 | 7 | pub mod teams; |
| 7 | 8 | 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; | ||
| 7 | 7 | mod period; |
| 8 | 8 | mod render; |
| 9 | 9 | mod routes; |
| 10 | +mod schedule; | |
| 10 | 11 | mod state; |
| 11 | 12 | mod team; |
| 12 | 13 | |
| @@ -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 { | ||
| 54 | 54 | pub toast: Option<String>, |
| 55 | 55 | /// Invitations en attente — badge sur l'onglet Réglages. |
| 56 | 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 | 59 | /// Jeton des forms de la pill timer (démarrer / arrêter). |
| 58 | 60 | pub csrf: String, |
| 59 | 61 | /// 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; | ||
| 5 | 5 | mod stats; |
| 6 | 6 | mod teams; |
| 7 | 7 | mod timer; |
| 8 | +mod todos; | |
| 8 | 9 | |
| 9 | 10 | use axum::Router; |
| 10 | 11 | use axum::extract::Request; |
| @@ -15,7 +16,7 @@ use axum::routing::{get, post}; | ||
| 15 | 16 | |
| 16 | 17 | use crate::auth::extractor::AuthUser; |
| 17 | 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 | 20 | use crate::error::AppError; |
| 20 | 21 | use crate::render::{Shell, TeamChoiceView, TimerView}; |
| 21 | 22 | use crate::state::AppState; |
| @@ -46,6 +47,17 @@ pub fn router(state: AppState) -> Router { | ||
| 46 | 47 | "/parametres/mot-de-passe", |
| 47 | 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 | 61 | .route("/timer/demarrer", post(timer::timer_start)) |
| 50 | 62 | .route("/timer/arreter", post(timer::timer_stop)) |
| 51 | 63 | .route("/timer/abandonner", post(timer::timer_abandon)) |
| @@ -122,6 +134,8 @@ pub async fn shell( | ||
| 122 | 134 | .await? |
| 123 | 135 | .unwrap_or_default(); |
| 124 | 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 | 139 | let timer_row = timers::get(&state.pool, ctx.user.id).await?; |
| 126 | 140 | let badge = timer_row.is_some(); |
| 127 | 141 | let timer = timer_row.map(|t| { |
| @@ -163,9 +177,11 @@ pub async fn shell( | ||
| 163 | 177 | start_return: match tab { |
| 164 | 178 | "stats" => "/stats", |
| 165 | 179 | "times" => "/temps", |
| 180 | + "todos" => "/taches", | |
| 166 | 181 | "settings" => "/parametres", |
| 167 | 182 | _ => "/temps/nouveau", |
| 168 | 183 | }, |
| 184 | + todo_count, | |
| 169 | 185 | timer, |
| 170 | 186 | show_start: false, |
| 171 | 187 | 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( | ||
| 135 | 135 | } |
| 136 | 136 | // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté. |
| 137 | 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 | 142 | teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?; |
| 139 | 143 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. |
| 140 | 144 | 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 @@ | ||
| 97 | 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 | 98 | <span>Stats</span> |
| 99 | 99 | </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> | |
| 103 | 106 | </a> |
| 104 | 107 | <a class="tab" href="/parametres" {% if shell.tab == "settings" %}aria-current="page"{% endif %}> |
| 105 | 108 | <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 @@ | ||
| 21 | 21 | </div> |
| 22 | 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 | 25 | <div class="card-kicker">Compte</div> |
| 26 | 26 | <div style="font-size:19px;font-family:var(--font-heading)">{{ shell.username }}</div> |
| 27 | 27 | <p class="card-body">{{ account_line }}</p> |
| 28 | 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 | 38 | <form method="post" action="/equipes/renommer"> |
| 31 | 39 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 32 | 40 | <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 @@ | ||
| 1 | 1 | {% extends "app.html" %} |
| 2 | 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 | 4 | {% if !legend.is_empty() %} |
| 4 | 5 | <ul class="legend"> |
| 5 | 6 | {% 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 %} | ||