DEV Community

Dev du Sud
Dev du Sud

Posted on

Mes 200 tâches tiennent dans un fichier SQLite de 192 Ko

J'ai une centaine de tâches vivantes : un site à réparer, un client à relancer, un article à
finir, une facture à vérifier. Pendant des années j'ai utilisé un outil en ligne, avec des
colonnes, des étiquettes, des glisser-déposer et une application mobile.

Le problème n'était pas l'outil. Le problème, c'est que je passais plus de temps à organiser
mes tâches qu'à les faire, et qu'aucune de ces tâches ne laissait de trace vérifiable une fois
« archivée ».

Aujourd'hui, tout tient dans un fichier de 192 Ko et une commande en ligne.

Ce que j'ai réellement besoin de stocker

Quatre choses, pas plus :

  • quoi (un titre),
  • pour quel projet,
  • pour quand (une échéance, souvent absente),
  • et où j'en suis (un statut).

Plus deux champs qui ne viennent pas des outils classiques : le type (est-ce moi qui dois
le faire, ou un agent/une machine ?) et la preuve.

CREATE TABLE missions (
  id          TEXT PRIMARY KEY,
  projet      TEXT,
  titre       TEXT NOT NULL,
  statut      TEXT NOT NULL DEFAULT 'todo',
  type        TEXT NOT NULL DEFAULT 'agent',
  due_date    TEXT,
  owner       TEXT,
  preuve      TEXT,
  note        TEXT,
  cree_le     TEXT,
  maj_le      TEXT
);

CREATE INDEX idx_due ON missions(due_date, statut);
Enter fullscreen mode Exit fullscreen mode

statut vaut todo, doing, done ou blocked. Pas de table de statuts, pas de
métadonnées imbriquées : quatre chaînes et une contrainte applicative.

Total : 190 lignes et 192 Ko pour une centaine de tâches et leur historique. La base entière
tient dans une pièce jointe.

La colonne qui a tout changé : preuve

J'ai eu un problème répété : des tâches marquées « faites » qui ne l'étaient pas. Un serveur
« installé » qui n'existait pas. Un site « livré » dont personne ne retrouvait l'URL. Dans les
deux cas, la case était cochée par quelqu'un de bonne foi, pressé, qui avait fait presque
le travail.

J'ai ajouté une règle, puis une colonne pour la rendre mécanique : une tâche n'est terminée
que si elle peut citer un artefact vérifiable.

-- la contrainte, côté applicatif : pas de preuve, pas de clôture
UPDATE missions
   SET statut = 'done',
       preuve = :chemin,          -- /home/moi/livrables/rapport.pdf
       maj_le = datetime('now')
 WHERE id = :id AND :preuve IS NOT NULL AND :preuve <> '';
Enter fullscreen mode Exit fullscreen mode

Trois formes de preuve acceptées, et trois seulement :

  1. un chemin de fichier qui existe et qui a été produit après la création de la tâche ;
  2. une URL qui répond HTTP 200 testée au moment de la clôture ;
  3. un hash de commit.
python3 pilotage.py done m-42 --preuve "/home/moi/livrables/rapport.pdf"
Enter fullscreen mode Exit fullscreen mode

Et le contrôle qui va avec, parce qu'une règle qu'on ne vérifie pas n'est pas une règle :

test -f "$(sqlite3 missions.db "SELECT preuve FROM missions WHERE id='m-42';")" \
  && echo "preuve OK"
Enter fullscreen mode Exit fullscreen mode

Depuis, la question « où en est ce chantier ? » a une réponse en une requête, et non plus en
une conversation.

Les quatre requêtes qui servent tous les jours

Ce qui est en retard — la seule requête que je regarde le matin :

SELECT id, titre, due_date, projet
  FROM missions
 WHERE statut <> 'done'
   AND due_date IS NOT NULL
   AND date(due_date) <= date('now', '+14 day')
 ORDER BY date(due_date) ASC;
Enter fullscreen mode Exit fullscreen mode

Ce que je dois faire moi (par opposition à ce que les scripts font) :

SELECT id, titre, projet FROM missions WHERE type = 'ludo' AND statut = 'todo';
Enter fullscreen mode Exit fullscreen mode

Cette séparation est plus utile qu'elle n'en a l'air : elle évite de demander à une machine
un geste qu'elle n'a pas le droit de faire (payer, signer, téléphoner), et elle évite de
s'étonner qu'un chantier n'avance pas alors qu'il attend un humain.

La charge par projet :

SELECT projet,
       SUM(statut = 'todo')    AS a_faire,
       SUM(statut = 'doing')   AS en_cours,
       SUM(statut = 'blocked') AS bloque
  FROM missions
 GROUP BY projet
 ORDER BY a_faire DESC;
Enter fullscreen mode Exit fullscreen mode

Ce qui est bloqué depuis trop longtemps :

SELECT id, titre, ROUND(julianday('now') - julianday(maj_le)) AS jours
  FROM missions
 WHERE statut = 'blocked'
   AND julianday('now') - julianday(maj_le) > 14
 ORDER BY jours DESC;
Enter fullscreen mode Exit fullscreen mode

La commande, en 120 lignes d'argparse

SQLite sans interface, c'est vite pénible. Un petit CLI suffit :

import argparse, sqlite3, subprocess, datetime

def db():
    con = sqlite3.connect("missions.db")
    con.row_factory = sqlite3.Row
    return con

p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="cmd", required=True)

d = sub.add_parser("due");    d.add_argument("--jours", type=int, default=14)
l = sub.add_parser("list");   l.add_argument("--statut", default="todo")
c = sub.add_parser("done");   c.add_argument("id"); c.add_argument("--preuve", required=True)

a = p.parse_args()

if a.cmd == "due":
    for r in db().execute(
        "SELECT id, titre, due_date FROM missions WHERE statut<>'done' "
        "AND due_date IS NOT NULL AND date(due_date)<=date('now',?) ORDER BY due_date",
        (f"+{a.jours} day",)):
        print(f"{r['due_date']}  {r['id']:<12} {r['titre'][:60]}")
Enter fullscreen mode Exit fullscreen mode

En pratique je n'utilise que trois commandes : due, list, done. Tout le reste, c'est du
SQL tapé une fois par mois.

Sauvegarde : cp ne suffit pas

Le premier réflexe — cp missions.db backup/ — est faux si un job écrit pendant la copie :
on récupère un fichier dont la moitié des pages est incohérente. Trois réglages et une
commande règlent le problème.

PRAGMA journal_mode = WAL;      -- lectures concurrentes sans blocage
PRAGMA busy_timeout = 5000;     -- attendre 5 s plutôt que planter
PRAGMA synchronous = NORMAL;    -- bon compromis sur un poste de travail
Enter fullscreen mode Exit fullscreen mode
sqlite3 missions.db ".backup '/home/moi/backups/missions-$(date +%F).db'"
Enter fullscreen mode Exit fullscreen mode

.backup utilise l'API de sauvegarde en ligne : cohérent, même si une écriture est en cours.
Une ligne dans le cron, et le sujet est clos.

Ce que SQLite ne fait pas

Je préfère le dire clairement, parce que la mode du « tout dans un fichier » produit aussi des
désillusions :

  • Pas de multi-utilisateur concurrent. Un écrivain à la fois. Pour une équipe de trois qui écrit toute la journée, ça devient un sujet. Pour un poste de travail et quelques scripts, c'est un non-problème.
  • Pas de synchronisation mobile. Un fichier sur un disque local ne suit pas en déplacement. Si vous avez besoin de cocher des tâches dans le train, SQLite n'est pas la réponse.
  • Pas de vue partagée. Dès qu'un tiers doit voir l'état, il faut exporter (un fichier markdown, un JSON, un petit serveur) — pas donner accès au fichier.
  • Pas de sauvegarde automatique. Le fichier est unique : sa perte est totale. Une sauvegarde quotidienne n'est pas optionnelle.

Et une limite plus discrète : les schémas évoluent. Ajouter une colonne est trivial
(ALTER TABLE ... ADD COLUMN), la renommer ou la supprimer ne l'est pas. Je garde donc les
migrations dans un dossier migrations/ numéroté, avec la version courante dans
PRAGMA user_version. Dix lignes de code, et plus de mauvaise surprise.

Pourquoi je ne reviens pas en arrière

Parce que ma base de tâches est maintenant une donnée, pas une interface. Je peux la lire
avec sqlite3, l'exporter en CSV, la versionner, l'interroger depuis un script Python, la
sauvegarder par un simple .backup. Aucune de ces quatre choses n'était possible avec mon
outil en ligne, qui me laissait au mieux un export JSON à la mise en forme douteuse.

Et puis il y a le silence. Pas de notification, pas de rappel, pas d'e-mail « vous n'avez pas
ouvert votre tableau de bord cette semaine ». Une commande, une réponse, rien d'autre.

Deux cents tâches, cent quatre-vingt-douze kilo-octets, zéro euro par mois. Ce n'est pas une
prouesse technique. C'est juste un outil qui fait le travail et se tait.

Top comments (0)