diff --git a/README.md b/README.md index da9efb7..5793894 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,11 @@ Pas besoin de Python, de venv ni de terminal. Les [releases GitHub](https://github.com/SKOHscripts/Kairos/releases) proposent un exécutable autonome par OS (`kairos-linux-x86_64`, `kairos-windows-x86_64.exe`) et un APK Android (`kairos-android-arm64.apk`). Télécharge, double-clique (sous Linux, rends d'abord le -fichier exécutable avec `chmod +x kairos-linux-x86_64`), et le navigateur s'ouvre tout -seul sur Kairos. Les réglages et la base de tâches vivent dans le dossier de données +fichier exécutable avec `chmod +x kairos-linux-x86_64`), et une fenêtre s'ouvre toute +seule sur Kairos — sans barre d'adresse ni onglets, le ressenti d'une vraie application +de bureau (si un navigateur de la famille Chromium — Chrome, Edge, Brave, Vivaldi... — +est installé ; sinon repli automatique sur un onglet du navigateur par défaut, sans rien +à configurer). Les réglages et la base de tâches vivent dans le dossier de données standard de ton système, entièrement éditables depuis la page **Réglages**. Aucun fichier `.env` à copier ou à éditer à la main. @@ -108,6 +111,17 @@ make test # venv + suite de tests complète ## Fonctionnalités +### Notes (capture GTD) +Une page dédiée (`/kairos/notes`, entre Accueil et Jour dans la navigation) pour se +décharger l'esprit sans réfléchir à la structure : une seule zone de texte libre, aucune +priorité ni échéance à choisir sur le moment (Ctrl/Cmd+Entrée pour capturer sans lâcher +le clavier). Chaque note capturée apparaît immédiatement dans la liste, sans rechargement +de page. Une fois qu'une idée est prête à devenir actionnable, un clic sur **« → Tâche »** +la convertit en tâche titre-seul, qui atterrit directement dans la boîte de réception « +À traiter » de la vue Jour — la note d'origine est archivée (jamais supprimée) avec un +lien vers la tâche créée. Une note peut aussi être éditée sur place ou classée sans suite +(archivée) si elle ne mène nulle part. + ### Gestion des tâches - **Création rapide** en une ligne (le titre seul suffit). Édition complète ensuite : titre, description, priorité 0-2 (P0 = la plus forte), échéance, date programmée, diff --git a/app/desktop_browser.py b/app/desktop_browser.py new file mode 100644 index 0000000..66e4397 --- /dev/null +++ b/app/desktop_browser.py @@ -0,0 +1,153 @@ +"""Détection d'un navigateur Chromium et ouverture en « fenêtre d'application ». + +`webbrowser.open` (utilisé par défaut dans `app/launcher.py`) ouvre un onglet +dans le navigateur système par défaut — barre d'adresse, onglets, tout +l'attirail d'un navigateur généraliste, alors que Kairos se veut ressenti +comme une application de bureau à part entière (voir `docs/spec/ +packaging-lancement.md`). Les navigateurs de la famille Chromium (Chrome, +Chromium, Edge, Brave, Vivaldi — tous basés sur le même moteur et acceptant +les mêmes indicateurs de ligne de commande) savent s'ouvrir en **fenêtre +d'application** via `--app=URL` : pas de barre d'adresse, pas d'onglets, un +ressenti de « web app installée ». Firefox et Safari n'ont pas d'équivalent +strict à cet indicateur — ce module ne cible donc que la famille Chromium. + +Ce module reste volontairement séparé de `app/launcher.py` : sa logique +(détection d'un binaire, construction des arguments) est pure et se teste +sans toucher à uvicorn, aux threads ou au fichier de verrou. `app/launcher.py` +l'appelle depuis `_open_browser`, avec un repli automatique et silencieux vers +`webbrowser.open` si la détection ou le lancement échoue — voir le +commentaire à l'appel pour le détail de cette décision. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys + +from app.settings_store import data_dir +from app.subprocess_env import external_process_env + +# Ordre de préférence indicatif seulement (le premier trouvé gagne) — pas de +# hiérarchie qualitative entre ces navigateurs, juste une liste stable pour +# un comportement déterministe d'un poste à l'autre. +_LINUX_BROWSER_NAMES = ( + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + "brave-browser", + "microsoft-edge", + "microsoft-edge-stable", + "vivaldi-stable", + "vivaldi", +) + +# Chemins relatifs sous chacun des dossiers de base Windows testés (l'ordre des +# bases ci-dessous est : Program Files, Program Files (x86), puis LocalAppData +# — un même navigateur peut atterrir sous l'une ou l'autre selon qu'il a été +# installé pour tous les utilisateurs ou seulement l'utilisateur courant ; on +# ne présume pas laquelle pour ne pas rater une installation valide). +_WINDOWS_BROWSER_RELATIVE_PATHS = ( + r"Google\Chrome\Application\chrome.exe", + r"Microsoft\Edge\Application\msedge.exe", + r"BraveSoftware\Brave-Browser\Application\brave.exe", + r"Vivaldi\Application\vivaldi.exe", +) + +_BROWSER_PROFILE_DIRNAME = "browser-profile" + + +def _windows_base_dirs() -> list[str]: + bases = [] + for env_var in ("ProgramFiles", "ProgramFiles(x86)", "LocalAppData"): + base = os.environ.get(env_var) + if base: + bases.append(base) + return bases + + +def find_app_capable_browser() -> str | None: + """Cherche un navigateur de la famille Chromium installé sur ce poste. + + Fonction pure (aucun effet de bord, pas d'impression) : ne fait que des + vérifications sur le système de fichiers / l'environnement, pour rester + facilement testable en monkeypatchant `shutil.which`, `os.environ` et + `os.path.exists`. + """ + # `KAIROS_BROWSER` : échappatoire explicite pour les tests/CI (imposer un + # binaire précis sans dépendre de ce qui est réellement installé), et pour + # un utilisateur avancé qui voudrait forcer un navigateur particulier — + # prioritaire sur toute détection automatique. + override = os.environ.get("KAIROS_BROWSER") + if override: + if os.path.isfile(override) and os.access(override, os.X_OK): + return override + resolved = shutil.which(override) + if resolved: + return resolved + return None + + if sys.platform == "linux": + for name in _LINUX_BROWSER_NAMES: + found = shutil.which(name) + if found: + return found + return None + + if sys.platform == "win32": + base_dirs = _windows_base_dirs() + for relative_path in _WINDOWS_BROWSER_RELATIVE_PATHS: + for base_dir in base_dirs: + candidate = os.path.join(base_dir, relative_path) + if os.path.isfile(candidate): + return candidate + return None + + # macOS (et tout autre OS) : hors périmètre de Kairos, voir + # `docs/spec/packaging-lancement.md` § Hors périmètre. Pas de détection + # dédiée, repli automatique vers `webbrowser.open` côté appelant. + return None + + +def launch_app_window(browser_path: str, url: str) -> bool: + """Lance ``browser_path`` en fenêtre d'application sur ``url``. + + Retourne `True` si le processus a bien été lancé (pas de garantie que la + fenêtre s'affiche effectivement — fonctionnalité de confort, jamais + bloquante), `False` sur tout échec. + """ + # Profil dédié, séparé du profil personnel de l'utilisateur : la fenêtre + # d'application ne doit pas se mêler à ses onglets/extensions/sessions du + # navigateur habituel, et un profil Chromium ne peut de toute façon pas + # être ouvert deux fois simultanément par deux processus distincts. + profile_dir = str(data_dir() / _BROWSER_PROFILE_DIRNAME) + argv = [browser_path, f"--user-data-dir={profile_dir}", f"--app={url}"] + + kwargs: dict = {} + if sys.platform == "win32" and hasattr(subprocess, "DETACHED_PROCESS"): + kwargs["creationflags"] = subprocess.DETACHED_PROCESS + + try: + # `external_process_env()` (pas la version context manager, + # `Popen` accepte un `env=` explicite) : évite qu'un navigateur lancé + # depuis l'exécutable PyInstaller onefile hérite du `LD_LIBRARY_PATH` + # détourné vers les bibliothèques embarquées — voir + # `app/subprocess_env.py`. + subprocess.Popen( + argv, + env=external_process_env(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + **kwargs, + ) + except Exception: + # Fonctionnalité de confort en arrière-plan : un binaire manquant + # malgré la détection, un droit refusé, ou toute autre surprise ne + # doit jamais faire planter ni bloquer le lancement de Kairos — + # l'appelant retombe sur `webbrowser.open`. + return False + return True diff --git a/app/launcher.py b/app/launcher.py index eb16d91..2a5ae28 100644 --- a/app/launcher.py +++ b/app/launcher.py @@ -43,6 +43,7 @@ # fonctionnent dans les deux cas (script figé et `pip install -e .`), tant que # la racine du dépôt est sur `sys.path` (c'est le cas ici : `pathex` du spec, # ou le `.pth` du mode editable). +from app.desktop_browser import find_app_capable_browser, launch_app_window from app.main import app from app.settings_store import data_dir from app.subprocess_env import external_process_environ @@ -71,6 +72,16 @@ def _open_browser(url: str) -> None: # (processus fantôme sur un runner CI, effets de bord imprévisibles). if os.environ.get("KAIROS_NO_BROWSER"): return + # Fenêtre d'application (Chromium `--app=URL`, voir `app/desktop_browser.py`) + # imposée par défaut, sans réglage utilisateur : c'est le ressenti recherché + # pour l'exécutable de bureau (pas un onglet de navigateur généraliste), et + # ça se dégrade tout seul vers l'ancien comportement (onglet du navigateur + # par défaut) si aucun navigateur Chromium n'est trouvé ou si son lancement + # échoue pour n'importe quelle raison — jamais d'erreur remontée à + # l'utilisateur pour cette fonctionnalité de confort. + browser_path = find_app_capable_browser() + if browser_path and launch_app_window(browser_path, url): + return # `external_process_environ()` : voir `app/subprocess_env.py` — évite # qu'un navigateur/`xdg-open` lancé par PyInstaller (mode onefile) hérite # du `LD_LIBRARY_PATH` détourné vers ses bibliothèques embarquées. diff --git a/app/main.py b/app/main.py index 8741334..e36b215 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,7 @@ ) from .tasks_models import ( FIBONACCI_SCALE, + Note, Task, TaskDependency, TimeBlock, @@ -1292,3 +1293,143 @@ def delete_manual_block(request: Request) -> RedirectResponse: tasks_session.commit() return RedirectResponse("/kairos", status_code=303) + +# -------------------------------------------------------------------------- +# Notes (capture GTD, en amont de l'inbox de la vue Jour) — voir +# docs/spec/notes-capture.md. Même patron de rendu que « Kairos » (fragment +# AJAX négocié sur `X-Requested-With: fetch`), mirroré ici avec son propre +# contenu (`_notes_list.html`, id `#mj-notes-content`) plutôt que réutilisé. +# -------------------------------------------------------------------------- + + +def _build_notes_context(request: Request, tasks_session: Session) -> dict: + """Contexte de rendu de la page Notes : notes ouvertes (capture active) et + notes archivées (converties ou classées sans suite), les deux triées de la + plus récente à la plus ancienne — symétrique de `_build_kairos_context`.""" + open_notes = list( + tasks_session.scalars( + select(Note).where(Note.status == "open").order_by(Note.created_at.desc()) + ) + ) + archived_notes = list( + tasks_session.scalars( + select(Note).where(Note.status == "archived").order_by(Note.created_at.desc()) + ) + ) + return { + "page": "notes", + "open_notes": open_notes, + "archived_notes": archived_notes, + } + + +def render_notes_response(request: Request, *, fragment: bool) -> Response: + """Point d'entrée unique de rendu de la page Notes, même contrat que + `render_kairos_response` : `fragment=False` rend la page pleine + (`notes.html`), `fragment=True` rend seulement `_notes_list.html` + (id `#mj-notes-content`), utilisé par les handlers d'action pour + l'amélioration progressive AJAX. Les handlers doivent avoir committé et + fermé leur propre session avant d'appeler cette fonction, qui rouvre une + session fraîche — jamais de session imbriquée (même invariant que Kairos).""" + with _request_session(get_tasks_session) as tasks_session: + context = _build_notes_context(request, tasks_session) + template_name = "_notes_list.html" if fragment else "notes.html" + return templates.TemplateResponse(request, template_name, context) + + +@app.get("/kairos/notes") +def notes_page(request: Request) -> HTMLResponse: + return render_notes_response(request, fragment=False) + + +def _notes_action_response(request: Request) -> Response: + """Réponse commune des handlers d'action Notes : mêmes règles que + `_kairos_action_response` (fragment AJAX si `X-Requested-With: fetch`, + sinon redirection 303 complète — repli sans JS pour la WebView Android et + l'accessibilité).""" + if request.headers.get("X-Requested-With") == "fetch": + return render_notes_response(request, fragment=True) + return RedirectResponse("/kairos/notes", status_code=303) + + +@app.post("/kairos/notes") +async def create_note(request: Request) -> Response: + """Capture rapide : un corps de texte libre, rien d'autre — pas de priorité, + pas de points, pas d'échéance (c'est tout l'intérêt par rapport à la boîte + de réception de la vue Jour, qui porte déjà ces champs sur `Task`).""" + form = await request.form() + body = str(form.get("body", "")).strip() + with _request_session(get_tasks_session) as tasks_session: + if body: + tasks_session.add(Note(body=body)) + tasks_session.commit() + return _notes_action_response(request) + + +@app.post("/kairos/notes/{note_id:int}/edit") +async def edit_note(request: Request) -> Response: + """Édite le corps d'une note existante. Note disparue entre-temps → no-op + silencieux (même tolérance que les handlers d'action de la vue Jour).""" + form = await request.form() + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None: + note.body = str(form.get("body", "")).strip() + tasks_session.commit() + return _notes_action_response(request) + + +def _note_title_from_body(body: str) -> str: + """Titre de la tâche créée par conversion : première ligne non vide du corps + de la note, tronquée à 200 caractères (cohérent avec `Task.title`, + `String(512)`, mais une capture rapide n'a pas besoin d'approcher cette + limite).""" + first_line = next((line.strip() for line in body.splitlines() if line.strip()), "") + return first_line[:200] + + +@app.post("/kairos/notes/{note_id:int}/convert") +def convert_note_to_task(request: Request) -> Response: + """Le moment clé du flux : la note devient une tâche titre-seul (elle atterrit + dans l'inbox « À traiter » de la vue Jour, à qualifier comme n'importe quelle + autre capture), la note elle-même est **archivée et liée**, jamais supprimée — + préserve l'historique de la capture d'origine (voir `Note.converted_task_id`, + sans contrainte FK, cohérent avec le reste du schéma).""" + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None and note.status == "open": + title = _note_title_from_body(note.body) + if title: + task = Task(title=title, source="native") + tasks_session.add(task) + tasks_session.flush() # attribue l'id avant de le référencer + note.status = "archived" + note.converted_task_id = task.id + tasks_session.commit() + return _notes_action_response(request) + + +@app.post("/kairos/notes/{note_id:int}/archive") +def archive_note(request: Request) -> Response: + """Classe une note sans suite : retirée de la liste de capture active, jamais + supprimée (même sémantique que la conversion, sans `converted_task_id`).""" + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None: + note.status = "archived" + tasks_session.commit() + return _notes_action_response(request) + + +@app.post("/kairos/notes/{note_id:int}/delete") +def delete_note(request: Request) -> Response: + """Suppression définitive — à la différence de l'archivage, retire la ligne en + base (une note n'a pas d'historique de priorisation à préserver, contrairement + à une tâche).""" + with _request_session(get_tasks_session) as tasks_session: + note = tasks_session.get(Note, request.path_params["note_id"]) + if note is not None: + tasks_session.delete(note) + tasks_session.commit() + return _notes_action_response(request) + diff --git a/app/tasks_models.py b/app/tasks_models.py index 42a034c..1925ce7 100644 --- a/app/tasks_models.py +++ b/app/tasks_models.py @@ -185,6 +185,35 @@ class WorkSession(TasksBase): created_at: Mapped[datetime] = mapped_column(DateTime, default=_now) +class Note(TasksBase): + """Note libre (« brain dump » GTD), capturée sans friction avant qualification. + + Étape de **capture** en amont de la boîte de réception de la vue Jour (qui ne + reçoit que des `Task` titre-seul, avec priorité/points à poser) : une note n'a + ni priorité, ni points, ni échéance — juste un corps de texte libre, posé le + plus vite possible, décidé plus tard. `converted_task_id` (nullable, **sans + contrainte FK**, même parti pris que `Task.parent_id`/`linked_ticket_id` — voir + docs/spec/modele-donnees.md) trace la tâche créée lors d'une conversion + note → tâche, en lecture seule (aucune synchro retour). `status='archived'` est + la façon dont une note convertie (ou classée sans suite) sort de la liste de + capture active **sans jamais être supprimée** : mêmes principes de non-perte + que `Task.status='archived'`. + """ + + __tablename__ = "note" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + body: Mapped[str] = mapped_column(Text, default="") + # 'open' | 'archived'. + status: Mapped[str] = mapped_column(String(16), default="open", index=True) + # Id local de la tâche créée par conversion, sans contrainte FK — référence + # « molle », cohérente avec le reste du schéma. None tant que la note n'a pas + # été convertie. + converted_task_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now) + + class TaskSyncMeta(TasksBase): """Méta du dernier fetch réussi par source (mirror de `GitLabRefreshMeta`). diff --git a/app/tasks_seed.py b/app/tasks_seed.py index 062466e..a6edfd9 100644 --- a/app/tasks_seed.py +++ b/app/tasks_seed.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session -from .tasks_models import Task, TaskDependency, TimeBlock +from .tasks_models import Note, Task, TaskDependency, TimeBlock EXAMPLE_PROJECT_TAG = "Exemple" @@ -179,3 +179,16 @@ def add_task(**kwargs) -> Task: ] ) session.flush() + + # Note d'exemple (capture GTD) : illustre l'étape en amont de l'inbox « À + # traiter » (page Notes) — une idée jetée sans friction, pas encore une tâche. + session.add( + Note( + body=( + "[Exemple] Idée en vrac : revoir le découpage des sprints ?\n" + "À développer avant d'en faire une tâche — ou à archiver si " + "ça ne mène nulle part." + ) + ) + ) + session.flush() diff --git a/docs/spec/accueil-navigation.md b/docs/spec/accueil-navigation.md index 77d26e4..f8a3a45 100644 --- a/docs/spec/accueil-navigation.md +++ b/docs/spec/accueil-navigation.md @@ -12,7 +12,8 @@ ici que ce qui est commun à toutes les pages (topnav) ou spécifique à l'accue ### Objectif / problème -Kairos comporte plusieurs vues (Accueil, Jour, Semaine, Statistiques, Réglages) qui +Kairos comporte plusieurs vues (Accueil, Notes, Jour, Semaine, Statistiques, +Réglages) qui doivent partager une identité visuelle et une navigation cohérentes, sans dupliquer le HTML de la barre de navigation dans chaque template. Il faut aussi une page d'accueil qui explique ce que fait l'outil à un nouvel utilisateur (collègue @@ -34,9 +35,11 @@ est pénible sur une longue liste. - Une barre de navigation horizontale, fixe en haut de l'écran (sticky), présente sur toutes les pages : logo/nom Kairos (lien vers l'accueil), puis les entrées Accueil, - Jour, Semaine, Statistiques, Réglages. L'entrée correspondant à la page affichée - est mise en évidence. -- **Exception, APK Android uniquement** : les cinq entrées sont déplacées vers une + Notes, Jour, Semaine, Statistiques, Réglages. L'entrée correspondant à la page + affichée est mise en évidence. « Notes » est placée entre Accueil et Jour : la + capture (page Notes) précède la triage/exécution (vue Jour) dans le flux GTD — + voir `docs/spec/notes-capture.md`. +- **Exception, APK Android uniquement** : les six entrées sont déplacées vers une barre de navigation basse fixe (icône + libellé, cible tactile ≥ 44px), le logo Kairos restant seul dans la barre du haut. Cette bottom nav n'apparaît **jamais** sur un navigateur (dev, service, exécutable de bureau), quelle que soit la largeur @@ -93,6 +96,9 @@ est pénible sur une longue liste. au profit de la topnav horizontale. - Contenu détaillé de la vue Jour/GTD (filtres, backlog, progression du jour...) : `docs/spec/vue-jour-gtd.md`. +- Contenu détaillé de la page Notes (capture, conversion en tâche, archivage) : + `docs/spec/notes-capture.md` — cette spec ne couvre que l'entrée de navigation + elle-même (icône, position, condition `active`). - Authentification/comptes multiples : Kairos reste mono-utilisateur, la navigation n'a pas de notion de session utilisateur. @@ -115,9 +121,17 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). #### `templates/base.html` — gabarit commun - **``** : titre par bloc (`{% block title %}Kairos{% endblock %}`), favicon - SVG (`/static/favicon.svg?v={{ asset_version }}`), polices Google Fonts (IBM Plex - Sans 400/500/600/700 + Newsreader italique 500, voir `docs/DESIGN_SYSTEM.md`), - feuille de style unique `/static/style.css?v={{ asset_version }}`. Le suffixe + SVG (`/static/favicon.svg?v={{ asset_version }}`), manifest PWA + (``) et icônes PNG + (192/512, `apple-touch-icon`, `?v={{ asset_version }}` sur les trois comme sur + `style.css`) + `` — le contenu de ces + fichiers `static/` eux-mêmes (manifest, PNG) est hors périmètre de ce document + (propriété/contenu d'un autre chantier), seules les balises ``/`` de + `base.html` y sont couvertes ; le favicon SVG existant reste inchangé en plus de + ces icônes PNG (navigateurs qui préfèrent le SVG le gardent). Polices Google Fonts + (IBM Plex Sans 400/500/600/700 + Newsreader italique 500, voir + `docs/DESIGN_SYSTEM.md`), feuille de style unique + `/static/style.css?v={{ asset_version }}`. Le suffixe `?v=` (posé par `templates.env.globals["asset_version"]` dans `app/main.py`, valeur = horodatage de modification de `style.css`, ou `0` si illisible) est un anti-cache navigateur : sans lui, un navigateur peut continuer à servir un vieux @@ -131,17 +145,18 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). couleurs terracotta d'origine conservées volontairement — voir `docs/DESIGN_SYSTEM.md` § Identité) + nom « Kairos » + sous-titre « le bon moment, la bonne tâche » (masqué sous 720px, voir § Invariants). - - `.tn-nav` : cinq entrées (Accueil `/`, Jour `/kairos?view=day`, Semaine - `/kairos?view=week`, Statistiques `/kairos/stats`, Réglages - `/kairos/settings`), chacune avec une icône (`icon('home')`, `icon('clock')`, - `icon('calendar')`, `icon('trending_up')`, `icon('gear')`) et un libellé texte. + - `.tn-nav` : six entrées (Accueil `/`, Notes `/kairos/notes`, Jour + `/kairos?view=day`, Semaine `/kairos?view=week`, Statistiques + `/kairos/stats`, Réglages `/kairos/settings`), chacune avec une icône + (`icon('home')`, `icon('notes')`, `icon('clock')`, `icon('calendar')`, + `icon('trending_up')`, `icon('gear')`) et un libellé texte. - **Mise en évidence de l'entrée active** : classe `active` conditionnée sur les variables de contexte passées par chaque route — `page == 'home'`, - `page == 'kairos' and (view is not defined or view != 'week')` (Jour, y compris - quand `view` n'est pas défini — défaut jour), `page == 'kairos' and view == - 'week'` (Semaine), `page == 'kairos_stats'`, `page == 'settings'`. Ces variables - (`page`, `view`) sont posées par chaque route dans le contexte de rendu, pas - déduites de l'URL côté template. + `page == 'notes'` (Notes), `page == 'kairos' and (view is not defined or view + != 'week')` (Jour, y compris quand `view` n'est pas défini — défaut jour), + `page == 'kairos' and view == 'week'` (Semaine), `page == 'kairos_stats'`, + `page == 'settings'`. Ces variables (`page`, `view`) sont posées par chaque + route dans le contexte de rendu, pas déduites de l'URL côté template. - **Bouton Quitter (`.tn-quit`)** : bloc conditionné par `{% if is_frozen %}`. `is_frozen` est une variable globale Jinja2 posée une fois au chargement du module (`templates.env.globals["is_frozen"] = getattr(sys, "frozen", False)`, @@ -155,7 +170,7 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). is_android %}is-android{% endif %}">` porte la classe `is-android` sur la racine du gabarit ; un second bloc `{% if is_android %}` (dernier enfant de `.layout`, après `
`) reprend - les cinq mêmes entrées et conditions `active` que `.tn-nav` (icône + libellé, + les six mêmes entrées et conditions `active` que `.tn-nav` (icône + libellé, cette fois visible — contrairement à `.tn-item .ico`), sans dupliquer `.tn-brand`/`.tn-quit`. - `is_android` : variable globale Jinja2 posée une fois au chargement du module @@ -178,9 +193,11 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). `static/style.css`. - `.bn-item { min-width: 0; }` : sans ce reset, le `min-width: auto` implicite d'un enfant flex (`flex: 1`) borne le rétrécissement à la taille de son - contenu (icône + libellé) — sur cinq entrées à largeur égale, la barre + contenu (icône + libellé) — sur six entrées à largeur égale, la barre déborderait du viewport sur un libellé un peu long (constaté avec - « Réglages » en développement de ce correctif). + « Réglages » en développement de ce correctif ; le nombre d'entrées est + passé de cinq à six avec l'ajout de « Notes », sans remettre en cause ce + correctif — voir `docs/spec/notes-capture.md`). - Voir § Décisions et pièges tracés pour la justification du déclenchement serveur plutôt que CSS. - **Topbar (`.topbar`)** : sous la topnav, dans `
`. Titre par @@ -223,7 +240,12 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). pencil, save, trash, file_text, grid, share, blocked, comment, arrow_left, arrow_up_right, trending_up, chevron_down, chevron_right, export_up, import_down, dot, dot_empty, clock, calendar, layers, dashboard, gitlab, chevron_left, search, - home, gear — défaut : un simple cercle si `name` ne correspond à rien). + home, gear, notes — défaut : un simple cercle si `name` ne correspond à rien). + `notes` (rectangle arrondi + trois traits horizontaux, glyphe de bloc-notes) sert + à la sixième entrée de navigation, « Notes » (`docs/spec/notes-capture.md`) — + choisie plutôt que `file_text`/`clipboard` (existantes mais inutilisées ailleurs + dans l'app à ce jour) pour un glyphe visuellement distinct d'un document/d'un + presse-papier, plus proche d'un carnet de capture. - Accessibilité : `aria-hidden="true"` par défaut ; si `title` est fourni, `role="img" aria-label="{{ title }}"` à la place — jamais les deux, jamais aucun des deux. @@ -367,7 +389,9 @@ dans le template via le contexte de la route `/` (`app/main.py::home`). bottom nav mobile », consignée dans `CLAUDE.md`/`docs/DESIGN_SYSTEM.md` § Navigation & mobile, mise à jour dans le même changement) : la nav horizontale qui passait sur deux lignes sous ~400px de large consommait jusqu'à ~25% de la - hauteur d'écran avant tout contenu, sur exactement les cinq destinations où + hauteur d'écran avant tout contenu, sur les cinq destinations d'alors (« Notes » + a rejoint la navigation ensuite, docs/spec/notes-capture.md, portant le total à + six sans remettre en cause ce choix) — exactement la fourchette où Material Design recommande une bottom nav. Un déclenchement purement CSS (`@media (max-width: 720px)`) aurait aussi affiché la bottom nav sur un navigateur desktop simplement rétréci sous ce seuil — comportement jugé diff --git a/docs/spec/modele-donnees.md b/docs/spec/modele-donnees.md index ba7357d..a32db8b 100644 --- a/docs/spec/modele-donnees.md +++ b/docs/spec/modele-donnees.md @@ -80,8 +80,8 @@ Android (Chaquopy + WebView). Il lui faut un stockage : Deux fichiers forment le socle de persistance : - `app/tasks_models.py` définit `TasksBase` (une `DeclarativeBase` SQLAlchemy 2 - dédiée) et les cinq tables : `Task`, `TimeBlock`, `TaskDependency`, - `WorkSession`, `TaskSyncMeta`, plus la constante `FIBONACCI_SCALE`. + dédiée) et les six tables : `Task`, `TimeBlock`, `TaskDependency`, + `WorkSession`, `TaskSyncMeta`, `Note`, plus la constante `FIBONACCI_SCALE`. - `app/tasks_db.py` crée le moteur (`tasks_engine`) et la fabrique de sessions (`TasksSessionLocal`) pointant sur `Settings.tasks_database_url` (`sqlite:///{tasks_database_path}`), gère la migration additive légère @@ -301,6 +301,31 @@ pas de scheduler en tâche de fond) et à l'affichage d'un avertissement quand l dernière synchro a échoué. La source historique `'superproductivity'` est purgée par la migration de retrait de l'intégration (voir plus bas). +**`Note`** — table `note`. Note libre (« brain dump » GTD), capture en amont de +la boîte de réception de la vue Jour — voir `docs/spec/notes-capture.md` pour +le besoin métier et les routes ; ce document ne couvre que la forme des +données. + +- `id: int` — clé primaire. +- `body: str` (`Text`, défaut `""`) — corps de texte libre, multi-ligne. +- `status: str` (`String(16)`, défaut `"open"`, **indexé**) — `'open'` | + `'archived'`. `'archived'` signifie que la note a été convertie en tâche + (voir `converted_task_id`) ou classée sans suite ; comme pour `Task.status`, + une note **n'est jamais supprimée** par ce passage à `'archived'` — seule + la route `DELETE`-like `POST /kairos/notes/{id}/delete` supprime réellement + la ligne. +- `converted_task_id: int | None` (`Integer`, nullable) — référence locale + vers `Task.id`, posée uniquement par la conversion note → tâche, **sans + contrainte `ForeignKey`** déclarée : même parti pris que `Task.parent_id`/ + `TaskDependency.blocker_id`/`Task.linked_ticket_id` (« référence molle », + cohérente avec le reste du schéma — voir § Décisions et pièges tracés). + `None` tant que la note n'a pas été convertie. +- `created_at: datetime` (`DateTime`, défaut `_now`). +- `updated_at: datetime` (`DateTime`, défaut `_now`, `onupdate=_now`). + +Pas de `UniqueConstraint` sur `Note` : aucune notion de doublon appliquée côté +base, une capture rapide peut légitimement dupliquer une idée déjà notée. + #### `app/tasks_db.py` **Engine et session** — `tasks_engine = create_engine(_settings. @@ -346,7 +371,11 @@ Note : les colonnes présentes **dès la création initiale** de `Task` (`title`…`external_id`, `created_at`, `updated_at`) et de `TimeBlock` (`title`…`external_id`, `created_at`) n'apparaissent **pas** dans ce dictionnaire — seules les colonnes ajoutées *après coup* y figurent, car `create_all()` ne -modifie jamais une table existante (seulement les tables absentes). +modifie jamais une table existante (seulement les tables absentes). Une table +**entièrement nouvelle** (ex. `note`, ajoutée avec `Note`) n'a besoin d'aucune +entrée non plus, pour la même raison à l'envers : `create_all()` la crée dans +son intégralité (toutes ses colonnes déjà à jour) sur toute base où elle est +absente, qu'elle soit vierge ou déjà peuplée par d'autres tables. **`_ensure_tasks_columns()`** — pour chaque table du dictionnaire présente dans la base (`inspector.get_table_names()`), calcule l'ensemble des colonnes @@ -477,6 +506,12 @@ Trois créneaux (`TimeBlock`), ajoutés par un unique `session.add_all([...])` : `session.flush()` — le `flush()` est nécessaire pour obtenir l'`id` attribué avant de le référencer en `parent_id` (sous-tâches) ou dans un `TaskDependency`. +Une **note** d'exemple (`Note`, préfixée `[Exemple]` comme le reste) est +ajoutée à la suite, hors de `add_task` (elle n'a ni `source` ni `project_tag` — +ces champs n'existent pas sur `Note`) : illustre l'étape de capture GTD en +amont de la boîte de réception, pour qu'un nouvel utilisateur découvre aussi la +page Notes dès le premier lancement. + ### Décisions et pièges tracés - **`external_id` nullable et jamais `""` pour une tâche native.** Piège déjà diff --git a/docs/spec/notes-capture.md b/docs/spec/notes-capture.md new file mode 100644 index 0000000..da94f32 --- /dev/null +++ b/docs/spec/notes-capture.md @@ -0,0 +1,307 @@ +# Notes (capture GTD) + +_Rôle : une page de capture libre (« brain dump »), en amont de la boîte de +réception de la vue Jour — jeter une idée par écrit sans avoir à décider tout de +suite si c'est une tâche. Fichiers couverts : `app/tasks_models.py::Note`, +`app/main.py` (`_build_notes_context`, `render_notes_response`, +`_notes_action_response`, `notes_page`, `create_note`, `edit_note`, +`convert_note_to_task`, `archive_note`, `delete_note`), `templates/notes.html`, +`templates/_notes_list.html`. Le sixième item de navigation (icône `notes`, +`templates/_icons.html`) et son entrée topnav/bottom-nav sont documentés dans +`docs/spec/accueil-navigation.md` — repris ici seulement pour mémoire. Le schéma +`Note` est documenté en détail (tous les champs) dans +`docs/spec/modele-donnees.md`._ + +**Hors de cette spec** : la boîte de réception de la vue Jour (`Task` sans +priorité ni points), le flux GTD complet côté tâches → `docs/spec/vue-jour-gtd.md` +(cette spec ne fait qu'y pointer, à l'étape qui précède). Le schéma exhaustif de +`Note` (tous les champs, migration) → `docs/spec/modele-donnees.md`. La +navigation/le gabarit commun (`base.html`, topnav, bottom nav) → +`docs/spec/accueil-navigation.md`. + +--- + +## 1. Besoin métier (cahier des charges) + +### Objectif / problème + +Kairos ne capturait jusqu'ici que des **tâches** : la boîte de réception de la +vue Jour (`docs/spec/vue-jour-gtd.md` § 2) accueille bien des `Task` sans +priorité ni points Fibonacci, mais une `Task` reste une tâche — un objet qu'on +s'engage, plus ou moins, à faire. Or l'étape amont du cycle GTD (*Getting Things +Done*) est la **capture** au sens le plus large : une idée, un rappel, un « il +faudrait que… » qui n'est pas encore tranché comme actionnable. Forcer un titre +de tâche à ce stade impose une décision prématurée (« est-ce que c'est vraiment +une tâche ? quelle priorité ? ») qui freine la capture — exactement ce que le +principe GTD « capturer sans friction » (déjà cité dans `vue-jour-gtd.md` pour la +capture de tâche titre-seul) cherche à éviter, mais poussé un cran plus loin en +amont. + +### Comportement attendu (utilisateur) + +- Une page dédiée, `/kairos/notes` (« Notes » dans la navigation), avec un + encart de capture toujours visible en tête, jamais replié : un seul champ, + le corps de la note (texte libre, plusieurs lignes possibles), un bouton + « Capturer ». Aucun autre champ à ce stade (pas de priorité, pas de points, + pas d'échéance) — c'est la différence avec la capture de tâche de la vue + Jour. +- Juste sous la capture, la liste des notes non traitées (« Notes »), la plus + récente en tête, jamais masquée derrière un `
` — état vide explicite + (« Rien en attente ») si la liste est vide, même principe que la boîte de + réception de la vue Jour. +- Chaque note affiche trois actions : + - **→ Tâche** : convertit la note en tâche titre-seul (elle atterrit dans la + boîte de réception de la vue Jour, à qualifier comme n'importe quelle autre + capture) ; la note elle-même est retirée de la liste active mais **jamais + supprimée** — retrouvable dans « Traité / archivé », avec un lien vers la + tâche créée. + - **Archiver** : classe la note sans suite (pas de tâche créée), même sort + que ci-dessus côté visibilité (retirée de la liste active, conservée en + historique). + - **Supprimer** : suppression définitive, avec confirmation. +- Une section repliée par défaut, « Traité / archivé (N) », liste les notes + converties ou classées sans suite — chacune avec son texte (atténué) et, si + elle a été convertie, un lien « → voir la tâche créée » vers la boîte de + réception de la vue Jour. +- La capture et les trois actions par note fonctionnent en amélioration + progressive (AJAX si JavaScript actif, requête POST classique + redirection + sinon) — même contrat que la vue Jour, JavaScript n'est jamais un prérequis. +- Ctrl/Cmd+Entrée dans le champ de capture soumet directement le formulaire, + sans avoir à atteindre le bouton à la souris. +- Une sixième entrée de navigation, « Notes », apparaît entre Accueil et Jour + (topnav desktop et bottom nav Android) — l'ordre reflète le flux GTD : + capturer avant de traiter. + +### Critères de succès + +- Une note créée (corps non vide) apparaît immédiatement dans la liste des + notes actives, sans rechargement de page si le JS est actif ; identique + après un rechargement complet sinon. +- Convertir une note crée une tâche dont le titre est la **première ligne non + vide** du corps de la note (les lignes suivantes, s'il y en a, ne sont + reprises nulle part dans le titre) ; cette tâche apparaît dans la boîte de + réception de la vue Jour (`GET /kairos`), sans priorité ni points, comme + toute capture titre-seul. +- Une note convertie ou archivée disparaît de la liste active et réapparaît + dans « Traité / archivé », jamais supprimée. +- Une note supprimée ne réapparaît nulle part, y compris dans l'historique + archivé. +- La page reste intégralement utilisable JavaScript désactivé. +- L'entrée « Notes » de la navigation est mise en évidence uniquement sur + `/kairos/notes`, sur aucune autre page. + +### Hors périmètre / différé + +- Édition du corps d'une note existante depuis l'UI (la route `POST + /kairos/notes/{id}/edit` existe côté serveur, prête à être branchée, mais + aucun formulaire de `_notes_list.html` ne l'appelle à ce stade — pas de + bouton « éditer » dans cette première version). +- Priorité, points Fibonacci, échéance, tag de projet sur une note : ces + champs n'existent que sur `Task`, posés **après** conversion, jamais avant. + Une note qui a besoin d'un de ces champs doit d'abord être convertie. +- Recherche, filtres, tri autre que « plus récent d'abord » : périmètre + minimal, symétrique de la boîte de réception (elle-même sans recherche + dédiée, juste un état trié par urgence). +- **Aucune synchronisation temps réel entre onglets, aucun polling, aucun + WebSocket, aucun push serveur.** Comme le reste de l'application — Kairos est + mono-utilisateur et local, la cohérence entre un swap AJAX et un rechargement + complet suffit (même contrainte que documentée pour la vue Jour, voir + `docs/spec/vue-jour-gtd.md` § Besoin métier : « l'app est mono-utilisateur et + locale : pas de sync temps réel entre onglets »). Deux onglets Notes ouverts + simultanément peuvent afficher un état divergent jusqu'au prochain + rechargement de chacun — ce n'est pas un défaut à corriger. +- Notification, rappel, ou tout mécanisme qui pousserait une note non traitée + vers l'utilisateur : la page Notes est un lieu qu'on consulte, pas un canal + qui sollicite. + +--- + +## 2. Solution technique + +### Modèle — `Note` (`app/tasks_models.py`) + +Table `note`, cinq champs propres (voir `docs/spec/modele-donnees.md` pour le +détail exhaustif type par type) : + +- `id`, `body` (`Text`, défaut `""`), +- `status` (`'open' | 'archived'`, défaut `'open'`, indexé), +- `converted_task_id` (`int | None`, **sans contrainte `ForeignKey`** — même + parti pris que `Task.parent_id`/`Task.linked_ticket_id`), +- `created_at`/`updated_at` (mêmes `_now`/`onupdate=_now` que `Task`). + +Nouvelle table : `TasksBase.metadata.create_all()` la crée sur toute base +existante sans migration additive dédiée — vérifié dans +`app/tasks_db.py::init_tasks_db` (`_TASKS_MIGRATION_COLUMNS` ne référence que +des colonnes ajoutées à une table **déjà existante** ; une table entièrement +nouvelle n'a besoin d'aucune entrée). Une note d'exemple est ajoutée par +`app/tasks_seed.py::seed_example_data` (même garde-fou de première utilisation +que le reste des exemples : posée une seule fois, sur base vierge). + +### Architecture de rendu (mirror du patron « Kairos ») + +Même contrat que `_build_kairos_context`/`render_kairos_response`/ +`_kairos_action_response` (`docs/spec/vue-jour-gtd.md` § Architecture de +rendu), avec son propre jeu de fonctions, **jamais réutilisé/étendu** — deux +patrons parallèles plutôt qu'un patron générique paramétré, pour rester lisible +sans abstraction prématurée sur deux domaines qui n'ont en commun que la forme +du contrat, pas les données : + +- `_build_notes_context(request, tasks_session)` — construit `open_notes` + (`status == 'open'`, triées `created_at` décroissant) et `archived_notes` + (`status == 'archived'`, même tri), plus `"page": "notes"`. +- `render_notes_response(request, *, fragment)` — ouvre sa **propre** session + tâches via `_request_session(get_tasks_session)`, construit le contexte, + rend `notes.html` (`fragment=False`) ou `_notes_list.html` + (`fragment=True`). +- `_notes_action_response(request)` — si `X-Requested-With: fetch`, renvoie + `render_notes_response(request, fragment=True)` ; sinon + `RedirectResponse("/kairos/notes", status_code=303)`. +- `templates/notes.html` étend `base.html`, contient l'encart de capture + (`
`) **hors** de `
{% include "_notes_list.html" %}
` — même + principe d'enveloppe unique que `#mj-day-content` + (`docs/spec/vue-jour-gtd.md`) : `#mj-notes-content` n'existe qu'à cet unique + endroit dans toute l'app, jamais posé par `_notes_list.html` lui-même (qui + doit rester rendable tel quel, en fragment autonome, par + `render_notes_response(fragment=True)`). + +### Routes (`app/main.py`) + +| Route | Handler | Effet | +| --- | --- | --- | +| `GET /kairos/notes` | `notes_page` | Page pleine (`render_notes_response(fragment=False)`). | +| `POST /kairos/notes` | `create_note` | Corps strippé ; si non vide, crée une `Note`. Corps vide → no-op silencieux (pas d'erreur, juste rien créé), même tolérance que `create_native_task` sur un titre vide. | +| `POST /kairos/notes/{id}/edit` | `edit_note` | Remplace `body`. Note disparue → no-op. Pas de formulaire client à ce stade (voir Hors périmètre). | +| `POST /kairos/notes/{id}/convert` | `convert_note_to_task` | Si la note est `open` et que sa première ligne non vide n'est pas vide : crée `Task(title=, source="native")`, `tasks_session.flush()` pour obtenir l'id, puis `note.status = "archived"` et `note.converted_task_id = task.id`. Une note déjà `archived` ou introuvable → no-op. | +| `POST /kairos/notes/{id}/archive` | `archive_note` | `status = "archived"`, sans toucher à `converted_task_id` (reste `None`). | +| `POST /kairos/notes/{id}/delete` | `delete_note` | Suppression définitive de la ligne — à la différence de `delete_task` (qui archive une tâche non native), une note n'a **aucun** historique de priorisation à préserver : la suppression est donc toujours dure, jamais un archivage déguisé. | + +Chaque handler POST ouvre sa **propre** session (`_request_session +(get_tasks_session)`), committe, la ferme (fin du bloc `with`) avant d'appeler +`_notes_action_response` — même invariant « jamais de session imbriquée » que +la vue Jour. + +`_note_title_from_body(body)` — fonction pure : première ligne dont +`.strip()` est non vide (une note commençant par des lignes blanches ne donne +donc pas un titre vide tant qu'une ligne non blanche suit), tronquée à 200 +caractères. Choix de la limite : `Task.title` est `String(512)`, mais une +capture rapide n'a structurellement pas besoin d'en approcher le quart — 200 +caractères couvre largement une phrase de titre sans jamais tronquer +silencieusement un texte court. + +### Templates + +**`templates/notes.html`** — `{% extends "base.html" %}`, `{% block +topbar_title %}Notes{% endblock %}`. Encart de capture en `.panel` (padding +intégré, pas besoin d'une classe dédiée) : ` +
+ +
+ + + +
+ +
+
+ +
+ + + {% endfor %} + + {% endif %} + + +{# Traité / archivé : notes converties (avec lien vers la tâche créée) ou classées + sans suite, repliées par défaut — même motif que le Backlog/les sections + secondaires de la vue Jour (`
` + `.collapser`). #} +{% if archived_notes %} +
+ {{ icon('chevron_right', '') }} Traité / archivé ({{ archived_notes|length }}) + +
+{% endif %} diff --git a/templates/base.html b/templates/base.html index d33a435..3eb7d9c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,6 +5,11 @@ {% block title %}Kairos{% endblock %} + + + + + @@ -31,6 +36,8 @@