La contrainte de départ
Il n'y a pas d'étape de compilation. Les fichiers déposés sont exactement ceux qui tournent dans le navigateur : pas de bundler, pas de transpilation, pas de node_modules en production. Ouvrir index.html sur le disque suffit à faire tourner l'application.
Ce n'est pas de la nostalgie, c'est un calcul. Une application de foyer développée par une personne doit pouvoir être reprise, déboguée depuis un téléphone, déployée sans chaîne d'outils à réparer. Le prix est réel et il est payé plus bas — pas de vérification de types, pas de découpage automatique des paquets — mais tout ce qui reste tient dans un navigateur et un éditeur de texte.
Ce que ça impose
- Les scripts se chargent dans un ordre décidé à la main dans
index.html; les fonctions sont globales. - Le cache se casse à la main : chaque
<script>et chaque<link>porte un?v=Nqu'on incrémente à chaque déploiement — 47 occurrences dans le document. - Les modifications de code sont appliquées par des scripts de correctif qui exigent une ancre unique dans le fichier, puis vérifiées par
node --check. C'est ce qui remplace le compilateur.
La forme
Trois morceaux : le navigateur, une bibliothèque cliente, et Postgres. Il n'y a pas de serveur applicatif entre les deux — donc pas d'endroit où poser une règle de sécurité « en passant ». Elles sont toutes dans la base.
Le modèle de données
Trente-neuf tables, regroupées par ce qu'elles servent. Tout ce qui appartient à un foyer porte une colonne household_id — c'est la clé de tout le modèle de sécurité, et c'est aussi ce qui permet de basculer d'un espace à l'autre sans mélanger quoi que ce soit.
Le foyer et les gens
- households
- members
- user_prefs
- contacts
- notifications
- messages
- support_messages
Le quotidien
- tasks
- backlog_items
- lists
- list_items
- notes
- task_history
- categories
Les projets
- projects
- project_waves
- project_tasks
- project_hints
- project_drafts
Les modèles
- templates
- template_tasks
- template_hints
- template_feedback
- catalog_templates
- catalog_template_tasks
- catalog_template_hints
Les notes
- note_cards
- note_blocks
Entreprises et produits
- companies
- company_members
- products
- product_events
La plateforme
- platform_admins
- platform_settings
Les commandites
- zones
- placements
Les ponts vers l'extérieur
- agenda_ics
- google_links
- google_events
Deux distinctions qui portent tout le reste
- Une note n'est pas une note.
notescontient les commentaires datés posés sur une tâche ou une liste ;note_cardsetnote_blockscontiennent les notes qui tiennent toutes seules, dans leur propre onglet. Deux objets différents qui portent le même mot en français. - Une commandite n'est pas une propriété du produit. C'est un contrat —
placements— entre une entreprise, un produit et une zone, avec son prix au clic et son forfait. Le même produit peut donc être poussé par deux commerces différents dans deux villes différentes, à deux prix. - Une tâche d'agenda n'est pas une tâche de projet.
tasksa une date et vit dans la journée ;project_tasksvit dans un projet et peut y rester sans date. Un liensource_task_idrelie celle qui a été posée à l'agenda depuis un projet, et c'est lui qui permet de déplacer un projet d'espace en emportant ses dates.
Qui voit quoi
Aucune donnée d'un foyer n'est lisible par un autre, et ce n'est pas l'interface qui en décide. Chaque table porte ses propres politiques : ce que l'écran cache, la base le refuse aussi.
Le problème de la récursion
Une politique sur members qui interroge members pour savoir si vous avez le droit de lire members boucle indéfiniment. La sortie est une fonction en security definer : elle s'exécute avec les droits de son propriétaire, donc sans repasser par les politiques, et son résultat sert de réponse à toutes les autres.
-- La fonction sur laquelle repose presque toute la sécurité. create or replace function my_household_ids() returns setof uuid language sql security definer stable set search_path = public as $$ select household_id from members where user_id = auth.uid() $$;
Empêcher de se donner des droits
Le fondateur d'un espace peut confier la gestion à quelqu'un d'autre : c'est la colonne members.can_manage. Une politique de mise à jour ne suffit pas à la protéger — un membre a le droit de modifier sa propre ligne (son nom, sa couleur), et rien ne l'empêcherait de glisser can_manage: true dans le même appel. La colonne est donc retirée du rôle applicatif, et seule une fonction dédiée peut l'écrire.
-- La ligne reste modifiable ; cette colonne-là, non. revoke update (can_manage) on members from authenticated; -- Le seul chemin, et il vérifie qui appelle. create or replace function definir_gestionnaire(p_member uuid, p_peut boolean) returns void language plpgsql security definer as $$ … $$;
| Ce qu'on essaie | La base répond | Pourquoi |
|---|---|---|
| select * from tasks | Les miennes | La politique remplace la requête par « celles de mes espaces ». |
| select * from tasks where household_id = <autre> | 0 ligne | Aucune erreur, aucune fuite : la table paraît vide. |
| update members set can_manage = true | 42501 | Le droit d'écriture sur la colonne n'existe pas. |
| rpc definir_gestionnaire(…) | Si fondateur | La fonction vérifie l'appelant avant d'écrire. |
Le temps réel
Vingt-quatre tables sont publiées en postgres_changes. Chaque appareil s'abonne aux tables de l'espace actif, filtrées sur household_id quand la colonne existe. Une case cochée sur un téléphone apparaît sur l'autre sans rafraîchissement.
- Les politiques s'appliquent aussi aux abonnements. On ne reçoit que les événements des lignes qu'on aurait le droit de lire.
- Les suppressions sont particulières. Un événement
DELETEne transporte que la clé primaire : la base ne peut pas évaluer une politique sur des valeurs qui n'existent plus. Le code recharge alors plutôt que de deviner. - Un canal personnel en plus. Une invitation vient d'un foyer dont on n'est pas encore membre — donc d'un espace auquel on n'est pas abonné. Un second canal écoute les lignes
membersqui portent votre identifiant ou votre courriel, avec un regroupement de 400 ms pour ne pas tout recharger trois fois.
Le moteur de récurrence
C'est le morceau le plus difficile de l'application, et celui qui doit être le plus invisible. Il traduit une phrase — « un mardi sur deux », « le 1er et le 15 », « trois dates par année » — en dates réelles posées dans le calendrier.
Tout est en UTC, et c'est délibéré
Une date d'agenda est un jour, pas un instant. Faire l'arithmétique en heure locale fait dériver « une semaine sur deux » d'une heure au changement d'heure, et un jour au bout de quelques mois. Le moteur ne manipule que des chaînes AAAA-MM-JJ et des millisecondes UTC ; aucun fuseau n'entre jamais dans le calcul.
La phase s'ancre sur le lundi
Le piège n'est pas évident. Avec deux jours cochés — mardi et vendredi, une semaine sur deux — compter les semaines depuis le jour d'ancrage fait dériver le second d'une semaine par rapport au premier : ils tomberaient en alternance au lieu d'être dans la même semaine. C'est la semaine qui porte la phase, pas le jour.
// Le lundi de la semaine d'une date : c'est lui qui donne la phase d'un « une // semaine sur deux » quand plusieurs jours sont cochés — compter à partir du // premier jour coché ferait dériver les autres. function rytLundiDe(iso){ return rytPlusJours(iso, -((rytJourSemaine(iso) + 6) % 7)); } const semaineAncre = rytLundiDe(ancre); for(let iso = deISO; iso <= aISO; iso = rytPlusJours(iso, 1)){ if(!r.weekdays.includes(rytJourSemaine(iso))) continue; const semaines = Math.round((rytUtc(rytLundiDe(iso)) - rytUtc(semaineAncre)) / 604800000); if(((semaines % r.interval) + r.interval) % r.interval === 0) garder(iso); }
Les cinq formes
| Forme | kind | Ce qu'elle règle |
|---|---|---|
| Aux N jours | daily | Un pas fixe depuis l'ancre. Le seul qui ne s'aligne sur rien. |
| Jours de semaine | weekly | Plusieurs jours cochés, une semaine sur N, phase au lundi. |
| Jours du mois | monthly | Plusieurs dates par mois, un mois sur N. Le 0 vaut « dernier jour ». |
| Une date | date | Une seule échéance, éventuellement chaque année. |
| Plusieurs dates | dates | Une liste explicite — les trois versements de taxes. |
Les occurrences ne sont pas calculées à la volée à l'affichage : elles sont posées dans l'agenda jusqu'à un horizon, et une colonne recur_generated_until retient jusqu'où, pour ne jamais réécrire deux fois la même.
Important, mais pas encore
Le filtre de la bande rouge était important && !done — sans jamais regarder la
date. Une tâche du vendredi marquée importante le mardi criait donc une urgence qui n'existait pas
pendant trois jours, et prenait la place de ce qui pressait vraiment. À force, on cesse de regarder la
bande, et c'est tout le dispositif qui se vide.
Marquer important voulait dire « en haut, maintenant », alors que ce qu'on veut dire la plupart du temps, c'est « ne me laisse pas l'oublier — rappelle-le-moi à temps ». D'où un délai : combien de temps avant la date elle doit remonter.
Deux colonnes, important_avance et important_unite, plutôt qu'une durée en
jours. « Deux semaines » et « quatorze jours » ne se relisent pas pareil, et celui qui a écrit « un mois »
doit retrouver « un mois ». On garde ce qui a été choisi ; le calcul se fait à l'affichage.
NULL veut dire « tout de suite », c'est-à-dire le comportement d'avant. Une migration ne
change pas sous les pieds ce que quelqu'un avait déjà réglé.
Le piège des fins de mois
Reculer d'un mois avec setMonth(mois - 1) depuis le 31 mars vise le 31 février, que
JavaScript fait déborder sur le 3 mars. Le délai d'un mois devenait 28 jours, sans rien dire.
Mesuré, pas deviné.
// On pose le jour 1 avant de reculer, puis on borne le quantième // au dernier jour du mois visé. d.setDate(1); d.setMonth(d.getMonth() - nb); const dernier = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); d.setDate(Math.min(j, dernier));
Un mois avant le 31 mars donne maintenant le 28 février — le 29 en année bissextile.
Où va la tâche en attendant
Nulle part de spécial : elle redevient une tâche ordinaire. Invisible sur l'écran du jour, présente dans l'agenda de sa journée, exactement comme une tâche datée non importante. Rien ne se perd — c'est ce qui rend le silence acceptable.
Les projets par étapes
Un projet progressif est une suite d'étapes (project_waves) dont une seule est courante. Chaque étape porte sa règle de fin, et c'est cette règle qui fait avancer le projet sans que personne n'appuie sur rien.
| Règle | advance_kind | L'étape se ferme |
|---|---|---|
| Tout coché | complet | Quand la dernière case de l'étape est cochée. |
| Un délai après | apres_complet | N jours, semaines ou mois après cette dernière case — completed_on retient le jour. |
| Une date | date | Le jour dit, cochée ou non. |
Le rattrapage se fait à l'ouverture de l'application : elle avance d'autant d'étapes qu'il en faut pour arriver là où on devrait être. Rien n'attend un serveur de tâches planifiées, parce qu'il n'y en a pas.
Deux déclencheurs en base gardent la structure cohérente : une tâche ne peut pas appartenir à une étape d'un autre projet, et une étape ne peut exister que sur un projet progressif.
La commandite, sans serveur
Un produit peut être mis de l'avant par un commerce, dans une zone, à un prix au clic négocié. Trois questions se posent, et aucune n'a de réponse évidente quand il n'y a ni serveur ni fournisseur de paiement.
Un genre, plusieurs noms
Rien de tout cela ne sert si le produit ne se fait pas trouver. Un article écrit à la main —
« acheter un portable » — doit rejoindre une fiche. Le mot de la fiche, product_type,
ne suffit pas : personne n'écrit le même. « laptop », « portable », « ordinateur portable »
désignent la même chose, et celui qui n'écrit pas le mot exact ne recevait aucune suggestion —
y compris pour un produit dont le placement était payé.
Même solution que les villes d'une zone : une liste de noms qui mènent au même genre.
products.appellations est un text[] indexé en GIN,
rempli par le commerçant dans sa fiche. Le premier nom reste product_type, celui qu'on
affiche ; les autres ne servent qu'à retrouver — et à regrouper les produits comparables, sans
quoi « Laptop » et « Portable » seraient deux rayons séparés du même magasin et personne n'y verrait
ses concurrents.
La correspondance se fait par inclusion dans les deux sens, sur la forme normalisée. C'est délibérément
large — « acheter un portable pour Léa » trouve la fiche — et le prix de cette largeur est réel :
une appellation courte attrape trop. Avec colle, « décoller le tapis » propose la fiche.
Une zone est une région, pas une ville
C'est la distinction qui porte tout le ciblage, et la première version se trompait : les zones y étaient
des villes. Une zone est une région administrative — les dix-sept du Québec sont dans la table, et
on coche celles qu'on vend. Une ville, elle, est une clé : la colonne villes d'une zone
porte les noms qui y mènent, sous forme normalisée — minuscules, sans accent, sans trait d'union. Ainsi
« Trois-Rivières », « trois rivieres » et « TROIS-RIVIERES » aboutissent tous à la Mauricie.
Mélanger les deux crée des chevauchements — « Trois-Rivières » et « Mauricie » revendiquant le même acheteur — et rend le ciblage ambigu au moment de vendre. Quand plusieurs zones réclament une même ville, la plus précise gagne.
Où habite l'acheteur
L'adresse vit dans user_prefs, dont la politique est user_id = auth.uid() : personne d'autre que le compte ne peut la lire — ni les membres de son espace, ni le fondateur. La zone est calculée à l'enregistrement, à partir de la ville, contre une liste de noms que porte chaque zone : « Cap-de-la-Madeleine » mène à Trois-Rivières sans que personne ait à le savoir.
Quel contrat gagne
Deux règles, dans cet ordre. Le local bat le national — un commerce qui a payé pour une ville passe devant celui qui a payé pour partout, c'est ce qu'il achète. Puis, à portée égale, la rotation pondérée par le prix : à 6 ¢ contre 2 ¢, on reçoit trois fois plus de clics. Ce n'est pas une enchère — le prix est au contrat — mais l'argent compte, et le petit commerce continue d'exister au lieu d'être écrasé.
Le tirage est retenu pour la durée de la session. Sans ça, l'ordre des produits sauterait à chaque redessin de la liste.
Qui a le droit de toucher au compteur
Personne, depuis le navigateur. Les colonnes du solde, du statut et du prix sont retirées du rôle applicatif :
revoke update (clics_utilises, statut, prix_par_clic, clics_achetes, approuve_par, approuve_le) on placements from authenticated;
Une seule fonction incrémente, et elle facture un clic par personne, par contrat, par jour. Sans serveur, on ne peut pas empêcher quelqu'un de cliquer cent fois pour vider le forfait d'un concurrent ; on peut refuser de le facturer cent fois. L'événement est quand même inscrit — la statistique doit montrer l'intérêt réel, même quand la facture ne bouge pas.
L'argent, lui, reste dehors : l'application tient le registre — commandé, consommé, restant — et la facturation se fait à part. Encaisser une carte demanderait un fournisseur de paiement et du code côté serveur, c'est-à-dire abandonner la contrainte du premier chapitre.
Qui passe en premier
Un défaut resté longtemps invisible : deux produits commandités étaient à égalité dans le tri, puis départagés à la note puis à l'alphabet. Celui qui payait six cents le clic passait donc derrière celui qui en payait un, parce que son nom commençait par un A. Le prix négocié ne servait à rien.
L'ordre a maintenant quatre rangs, et le prix tranche à l'intérieur du deuxième :
| Rang | Ce que c'est | Qui le décide |
|---|---|---|
| 0 | Épinglé | Le back office — une décision de placement, invisible pour qui lit |
| 1 | Commandité | Le contrat, et le prix au clic départage |
| 2 | Recommandé | Notre avis, affiché par une étoile |
| 3 | Le reste | La note, puis l'alphabet |
L'épingle et la recommandation disent deux choses différentes, et les garder séparées est délibéré : l'une est un avis qu'on assume publiquement, l'autre un placement qu'on ne montre pas. Les confondre viderait « recommandé » de son sens.
Épingler est un placement gratuit : si un commerce pouvait se l'accorder, la commandite n'aurait plus de prix. La colonne est donc retirée au rôle applicatif — même mécanisme que le compteur de clics — et le seul chemin est une fonction qui vérifie qui appelle.
Des services, et d'où ils viennent
Une bonne partie de ce qui allège un projet ne s'achète pas en magasin : un notaire dans « Achat d'une maison », un déménageur, un inspecteur en bâtiment. Ces entreprises ont la même raison d'être là — et de payer pour y être — qu'un commerce qui vend une poussette.
On n'a pas créé de table à côté. Les appellations qui font retrouver, le ciblage par zone, les
contrats, la modération et le compteur de clics travaillent tous sur products. Une deuxième
table aurait doublé la sécurité, le back office, le bureau d'entreprise et toute la couche commandite,
pour une différence qui tient dans un mot. Un service est un produit d'un autre genre :
products.kind.
Voir chez qui on achète, sans voir le reste
« Où aller le chercher » demande de savoir qui vend, donc de lire la fiche de l'entreprise. Mais la Row
Level Security travaille par ligne, pas par colonne : ouvrir companies aux foyers leur
donnerait aussi le courriel du propriétaire, son téléphone, son numéro d'entreprise et le motif d'un refus.
Une vue règle exactement ça — elle choisit les colonnes :
create or replace view commerces_publics as select id, name, description, website, adresse, ville, ville_normalisee, se_deplace, type_compte from companies where status = 'approved' and deleted_at is null;
Elle n'a pas security_invoker, donc elle lit la table avec les droits de son propriétaire et
n'est pas arrêtée par la RLS. C'est voulu, et sans danger : elle ne contient que la vitrine et ne montre
que les entreprises approuvées.
Trois cas d'affichage, et il faut les trois — sinon on envoie quelqu'un faire la route pour rien, ou on cache un commerce qui serait venu de lui-même : dans ta région, ailleurs, ou se déplace. Le dernier compte pour un déménageur, qu'on écarterait autrement parce que son bureau est à trente minutes.
Le travailleur autonome
Un notaire seul ne clique pas sur « compte entreprise », et lui demander une raison sociale et un NEQ le
faisait renoncer à la première ligne. companies.type_compte distingue les deux, et le
formulaire change de mots — le champ « personne responsable » disparaît, puisque son nom en tient lieu.
Ce que la colonne ne fait pas encore, et c'est assumé : rien ne traite un autonome différemment au-delà des mots. Ce qu'on lui demande et ce qu'on lui facture reste à décider. La colonne existe pour que la décision soit possible plus tard sans avoir à retrouver qui était quoi.
Une note écrite une fois, lue partout
Le fil de commentaires d'une tâche et le carnet du foyer sont la même table, notes,
distinguée par parent_type. Une contrainte en tient la liste :
task, backlog, list, project, project_task.
Le fil vit donc sous une tâche d'agenda, un item du backlog, une liste, un projet ou une tâche de projet,
sans une table par endroit.
Reste le cas qui revient : la même information sert à plusieurs endroits. Un carnet « rendez-vous
médical » porte l'adresse, le numéro de dossier, ce qu'il faut apporter — et trois tâches y renvoient.
La recopier, c'est trois copies qui divergent. La colonne notes.carnet_id pointe donc vers
le carnet plutôt que d'en reprendre le contenu : le fil porte un lien, et le lien ouvre la note réelle.
alter table notes add column if not exists carnet_id uuid references note_cards(id) on delete cascade;
Le on delete cascade dit ce qu'on veut : un carnet supprimé emporte les liens qui y menaient,
plutôt que de laisser des renvois vers rien.
Plier sans casser
Le schéma évolue plus vite que la base d'un utilisateur qui n'a pas encore passé la dernière migration. Plutôt que d'échouer, l'application lit le code d'erreur et éteint la fonctionnalité concernée en gardant tout le reste.
| Code | Ce que ça veut dire | Ce que fait l'app |
|---|---|---|
| 42703 | Colonne inconnue | Retire le champ et réessaie sans lui. |
| PGRST204 | Colonne absente du cache de schéma | Même repli — c'est la même cause, vue de l'API. |
| PGRST205 | Table absente du cache de schéma | L'onglet reste vide au lieu de crier. |
| 42P01 | Table inexistante | Idem : la fonctionnalité attend sa migration. |
| 42501 | Droit insuffisant | Pas une colonne manquante : c'est un refus, on le dit. |
// Une colonne pas encore en place ne doit pas faire échouer l'écriture. let { error } = await sb.from('note_cards').update(patch).eq('id', noteId); if(error && ['PGRST204', '42703'].includes(error.code)){ delete patch.updated_by; ({ error } = await sb.from('note_cards').update(patch).eq('id', noteId)); }
La distinction du bas du tableau a coûté cher à trouver : 42501 ressemble à une colonne manquante quand on le rencontre pour la première fois, et le traiter comme telle fait silencieusement disparaître une écriture refusée.
Les migrations
Cinquante-quatre sections numérotées, écrites pour être rejouables : passer le fichier deux fois ne casse rien. Une base en retard se rattrape en collant le fichier au complet dans l'éditeur SQL — il n'y a pas d'outil de migration, et il n'en faut pas un.
- Chaque ajout de colonne est
add column if not exists; chaque politique est précédée d'undrop policy if exists. - Les ajouts à la publication temps réel sont enveloppés dans un bloc qui rattrape
duplicate_object: Postgres n'a pas deadd table if not existspour les publications. - Les corps de fonctions sont délimités par
$fn$plutôt que par le classique$$. Ce n'est pas un caprice : quand le SQL est inséré par un script JavaScript,$$dans une chaîne de remplacement devient$, et la fonction arrive tronquée dans la base.
Le front
Vingt-huit fichiers de script, découpés par écran ou par domaine plutôt qu'en couches. Il n'y a pas de framework : le rendu est du innerHTML reconstruit à partir de l'état, et l'état est rechargé depuis la base après chaque écriture.
| Fichier | Lignes | Ce qu'il tient |
|---|---|---|
| js/db.js | 3 265 | Toute la couche de données : lecture, écriture, temps réel, droits. |
| js/admin.js | 1 521 | Le back office : catalogue de modèles, comptes, produits. |
| js/app.js | 1 227 | La coquille : navigation, en-tête, espaces, clavier du téléphone. |
| js/projects.js | 1 156 | L'onglet Projets : la liste fusionnée, le tri par urgence. |
| js/creation.js | 791 | L'atelier : composer un projet par blocs empilés. |
| js/carnets.js | 555 | Les notes qui tiennent toutes seules. |
| js/hints.js | 522 | Les indications, et le moteur de glisser-déposer partagé. |
| js/rythme.js | 431 | Le moteur de récurrence et son interface. |
Deux conventions qui se remarquent
- Le code est en français. Noms de fonctions, variables, commentaires. L'interface l'est, le domaine l'est — traduire dans sa tête à chaque aller-retour entre l'écran et le code coûtait plus que ça ne rapportait.
- Les commentaires disent pourquoi, pas quoi. Ils documentent les pièges : la dérive du fuseau, la récursion des politiques, la zone morte temporelle d'un
const. Ce sont les endroits où quelqu'un — moi le mois prochain — se ferait reprendre deux fois.
Le service worker
Le document HTML passe toujours par le réseau d'abord, le cache ne servant que de secours. C'est délibéré : index.html porte les ?v=N de tous les scripts, et une version en cache chargerait d'anciens scripts avec de nouvelles données. Le reste (icônes, manifeste) est servi depuis le cache en premier.
Le déploiement
Un dépôt statique sur Vercel. Il n'y a rien à construire : le déploiement copie les fichiers. Une commande, une quarantaine de secondes, et l'alias public pointe sur la nouvelle version.
npx vercel deploy --prod --scope acme-dbac --yes
La base est hébergée par Supabase — Postgres, l'authentification Google, et la diffusion temps réel. Les jetons d'accès aux services externes ne sont jamais lisibles depuis le navigateur : ils restent du côté du serveur.
Ce qui n'est pas là
Un dossier technique qui ne dit que ce qui va bien ne sert à personne. Voici ce qui manque, en connaissance de cause.
- Aucun test automatisé. La vérification passe par
node --check, un contrôle d'équilibre des accolades CSS, et un banc d'essai qui rejoue l'application avec des données factices pour l'inspecter écran par écran. C'est la lacune la plus coûteuse à long terme. - Pas de vérification de types. C'est le prix direct de l'absence de compilation. Les erreurs de forme se rattrapent à l'exécution, ou pas.
- Le temps réel ne couvre pas tout. Les notes de l'onglet Notes ne sont pas abonnées : elles se relisent au chargement, au changement d'espace et à l'enregistrement.
- Hors ligne très partiel. Le service worker permet d'installer l'application et de garder la coquille ; les données, elles, exigent le réseau.
- Pas de paiement. Le registre des commandites sait ce qui a été commandé, consommé et facturable ; il ne prend pas d'argent. C'est un choix de calendrier, pas d'architecture — mais il faudra un serveur le jour où ça changera.
- Pas encore sous gestion de version. Le dépôt n'est pas un dépôt Git — c'est la prochaine chose à faire, et la plus urgente de cette liste.