20 Architecture
Claude Agent edited this page 2026-09-25 00:44:14 +01:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Architecture technique

Vue d'ensemble

┌─────────────────────────────────────────────────────┐
│                   Nextcloud (PHP)                     │
│  ┌──────────┐ ┌──────────┐ ┌──────┐ ┌────────────┐  │
│  │ Calendar │ │ Contacts │ │ Talk │ │   Files    │  │
│  │ (CalDAV) │ │(CardDAV) │ │      │ │  + Photos  │  │
│  └────┬─────┘ └────┬─────┘ └──┬───┘ └─────┬──────┘  │
│       │             │          │           │          │
│       └──────────┬──┴──────────┴───────────┘          │
│                  │                                    │
│           ┌──────┴──────┐                             │
│           │   AppAPI    │ (OCS APIs + proxy)          │
│           └──────┬──────┘                             │
└──────────────────┼────────────────────────────────────┘
                   │ AppAPIAuth (shared secret)
                   │ HTTP (lifecycle + proxy)
┌──────────────────┼────────────────────────────────────┐
│                  ▼   ExApp (Docker)                    │
│  ┌───────────────────────────────────────────────┐   │
│  │         Node.js / TypeScript (Fastify)          │   │
│  │  ┌───────────┐ ┌──────────┐ ┌────────────────┐ │   │
│  │  │ Lifecycle │ │ OCS Client│ │  REST API      │ │   │
│  │  │ heartbeat │ │ (NC calls)│ │  (ExApp routes)│ │   │
│  │  │ init/enable│ │           │ │                │ │   │
│  │  └───────────┘ └──────────┘ └───────┬────────┘ │   │
│  └──────────────────────────────────────┼───────────┘   │
│                                         │              │
│  ┌──────────────────────────────────────┴───────────┐  │
│  │              SQLite (persistent volume)          │  │
│  │  families, lists, list_items, recipes, meals     │  │
│  └──────────────────────────────────────────────────┘  │
│                                                         │
│  ┌───────────────────────────────────────────────────┐ │
│  │           Vue.js frontend (Nextcloud app)         │ │
│  │  Listes | Calendrier | Meal Planner | Recettes    │ │
│  └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Stack technique

Backend

Choix Détail
Langage Node.js 22 LTS + TypeScript
Framework Fastify (léger, performant, typé)
Base de données SQLite (via better-sqlite3, synchrone, embarqué dans le conteneur)
Validation Zod (schémas de validation typés)
Logs pino (structured logging, compatible Fastify)
Tests Vitest (unit + integration), backend et frontend
Linter / Format ESLint 10 + Prettier 3, config unique à la racine sur @nextcloud/eslint-config

Frontend

Choix Détail
Framework Vue 3 (Composition API, <script setup>)
UI @nextcloud/vue v9+ (composants Nextcloud officiels)
State Pinia
Build Vite
Router vue-router (mode history, base via AppAPI proxy)
HTTP @nextcloud/axios (gère l'auth et le proxy)
i18n @nextcloud/l10n

Infrastructure

Choix Détail
Conteneur Docker multi-stage (node:22-slim builder → node:22-slim runtime)
Volume persistant nc_app_organisateur_familial_data (SQLite + uploads)
Entrypoint start.sh (celui de HaRP, qui configure FRP) → node dist/main.js
Écoute socket Unix /tmp/exapp.sock si HP_SHARED_KEY est défini (HaRP), sinon TCP ${APP_HOST}:${APP_PORT}
Healthcheck curl --unix-socket sous HaRP, curl http://${APP_HOST}:${APP_PORT}/heartbeat sinon

Décisions techniques

D1 — Node.js/TypeScript pour le backend

Choix : Node.js + TypeScript + Fastify.

Raison : La synergie avec le frontend Nextcloud (déjà en JS/TS/Vue). Un seul langage pour tout le projet. Écosystème npm très riche. Fastify est plus performant et plus typé qu'Express.

Trade-off : Pas de SDK officiel Nextcloud pour Node (contrairement à nc_py_api en Python). Il faut implémenter AppAPIAuth et les appels OCS à la main. C'est gérable : AppAPIAuth est un simple header AUTHORIZATION-APP-API: base64(userid:secret), et les appels OCS sont des requêtes HTTP REST standard.

D2 — SQLite pour le stockage

Choix : SQLite via better-sqlite3.

Raison : Zéro dépendance externe, embarqué dans le conteneur, stocké sur le volume persistant AppAPI. Suffisant pour un usage familial (dizaines d'utilisateurs, pas millions). Les opérations sont synchrones en Node (pas de callback hell avec better-sqlite3).

Trade-off : Pas idéal pour la concurrence élevée, mais un usage familial n'aura jamais ce problème. WAL mode activé pour permettre des lectures concurrentes. Backup = copier le fichier .db sur le volume.

D3 — Mises à jour temps réel : polling + fallback WebSocket

Choix : Polling par défaut (toutes les 3-5s sur les listes ouvertes), avec détection de HaRP pour activer les WebSockets si disponibles.

Raison : L'AppAPI proxy ne supporte pas les WebSockets avec le Docker Socket Proxy (DSP). Les WebSockets ne fonctionnent qu'avec HaRP (Nextcloud 32+). Le polling fonctionne partout, est simple à implémenter, et l'overhead est négligeable pour un usage familial.

Trade-off : Latence de 3-5s pour voir les changements d'un autre membre. Acceptable pour des listes de courses. Si HaRP est détecté, on upgrade vers WebSocket pour du vrai temps réel.

⚠️ À réévaluer (2026-08-16) : la contrainte qui motive ce choix est en train de disparaître. Le DSP est déprécié et marqué pour suppression dans Nextcloud 35 ; HaRP est le daemon de déploiement de NC 32+. Si la cible du projet est NC 32+ (ce que suggère max-version="35"), le "fallback WebSocket" devient le cas nominal et le polling le cas de compatibilité. Décision à trancher avant la phase 1B.5 : viser HaRP d'emblée (WebSocket par défaut) ou garder le polling comme socle. Détails : .claude/skills/nextcloud-exapp-dev/references/harp.md.

D4 — Multi-familles via Circles

Choix : Chaque "famille" est un Circle Nextcloud. L'ExApp stocke les métadonnées familiales (nom, couleur, icône, date de création) en SQLite, et le Circle définit l'appartenance.

Raison : Nextcloud Circles gère déjà les groupes d'utilisateurs, le partage de fichiers, de calendriers et de carnets d'adresses. L'ExApp orchestre la création et stocke les données qui n'ont pas leur place dans Nextcloud (listes, recettes, etc.).

Trade-off : Dépend de l'app Nextcloud Circles. Si elle n'est pas activée, l'ExApp doit gérer sa propre notion de groupe (moins intégré). On suppose Circles disponible (activé par défaut dans Nextcloud récent).

✅ Validé le 2026-08-16 sur Nextcloud 34. La doc AppAPI n'énumère pas Circles parmi les OCS APIs utilisables par une ExApp, mais la liste se termine par « Etc. » et l'API répond bien. Cycle complet exercé via AppAPIAuth sur l'instance locale :

Appel Résultat
GET /ocs/v2.php/apps/circles/circles 200
POST /circles {"name":"Famille Test"} 200, id retourné
GET /circles/{id}/members 200, créateur en niveau 9 (owner)
DELETE /circles/{id} 200
Les mêmes sans en-têtes AppAPIAuth 401

Le 401 du contrôle négatif confirme que c'est bien AppAPIAuth qui ouvre l'accès, et non un repli anonyme.

Attention au nom. L'app s'appelle Teams dans l'interface depuis Nextcloud 28, mais l'identifiant technique, les routes et la base restent circles. Chercher « teams » dans les APIs ne donne rien.

Piège à la création : POST /circles accepte name, personal et local, les deux derniers à false par défaut. Passer local: true exige Global Scale et échoue en 400 circle configuration not supported sur une instance normale. N'envoyer que name.

Le DAV répond aussi à AppAPIAuth (vérifié le 2026-08-16). PROPFIND sur /remote.php/dav/files/{user}/ renvoie 207 avec les en-têtes et 401 sans : AppAPIAuth couvre remote.php comme /ocs/. Deux différences côté client : pas de OCS-APIRequest, pas de format=json, et les réponses sont en XML — d'où un chemin distinct du client OCS dans ex_app/lib/src/ocs/. Le partage avec une Team se fait par l'API Shares (shareType: 7) pour les fichiers, et par un POST DAV avec principal:principals/circles/{id} pour les agendas et carnets d'adresses.

D8 — Mono-famille par défaut, multi-famille par exception

Choix : l'interface affiche une famille active et un sélecteur pour en changer. Elle n'expose jamais une liste de familles comme écran d'accueil. La navigation est par fonctionnalité — Listes, Calendrier, Repas — la famille étant un contexte ambiant. Une page d'accueil agrège ce qui arrive à la famille et devient la destination de / ; chaque fonctionnalité garde ensuite toute la largeur du contenu, sans synthèse latérale.

Raison : la quasi-totalité des utilisateurs n'aura qu'une famille. La navigation actuelle (écran « Familles » d'où l'on entre dans les listes) impose un détour à tout le monde pour servir une minorité. Un utilisateur mono-famille ne doit jamais avoir conscience que le concept existe.

Trade-off : la famille active devient un état à persister (Preferences OCS, avec localStorage en cache de démarrage) et à porter dans les URLs pour que les liens partagés ouvrent le bon contexte. C'est plus de machinerie qu'un identifiant dans le chemin, mais le coût est payé une fois côté frontend.

L'API reste inchangée : elle est déjà découpée par famille (/api/families/:id/...), ce qui est le bon modèle. C'est la présentation qui doit cesser d'exposer ce découpage. Le multi-famille continue d'être testé de bout en bout, notamment l'isolation.

Détail de la cible et de ce qui est repris de FamilyWall : UX.

✅ Appliqué (phases 1B-bis.1 à .4). Ce que la mise en œuvre a tranché :

  • La famille active vit dans le store, pas dans l'URL. Elle n'y apparaît qu'en paramètre optionnel ?family=, honoré au démarrage et tenu à jour tant qu'il est présent. Le laisser figé serait pire que l'omettre : un rechargement ramènerait au contexte du lien plutôt qu'à celui qu'on vient de choisir.
  • Démarrage en deux temps. L'URL puis le cache localStorage donnent un premier affichage immédiat ; la préférence serveur corrige ensuite. Sans ce premier temps, le cache ne servirait à rien — tout attendrait l'aller-retour. Un identifiant qui ne désigne aucune famille accessible est ignoré à chaque étape.
  • Un échec de préférence ne remonte jamais en erreur. L'utilisateur n'a rien demandé, et le cache local tient la session.
  • Les vues suivent la famille active, pas leurs paramètres de route. Lists.vue attend le drapeau ready avant de charger : au premier rendu, la famille vient du cache local et peut désigner une famille supprimée depuis.
  • Le compteur de membres ne coûte rien. La réponse Circles porte population ; le cache d'appartenance (D7) garde donc le Circle entier plutôt que son seul identifiant. Sans ça, afficher « 4 membres » demanderait un appel members par famille listée.
  • « Aucune famille » est un état de la coquille, pas une vue vide. L'absence de famille bascule sur l'assistant de première utilisation et retire la barre latérale ; la première famille créée l'inverse, et supprimer la dernière y ramène. L'arbitrage est dans la coquille et non dans un garde de navigation, qui s'exécuterait avant que le store ne sache si l'utilisateur a une famille.
  • L'accueil matérialise le dashboard de 1D. Un seul appel, GET /api/families/:id/summary, rassemble des sources hétérogènes — CalDAV pour les événements, SQLite pour les articles — lancées en parallèle pour tenir dans le temps du seul appel réseau. La page présente les rendez-vous à venir, les tâches dues et les listes récentes, avec un lien direct vers chaque fonctionnalité. Un agenda illisible n'y fait pas échouer la synthèse : il rend une liste vide et l'échec est journalisé.
  • Deux cadences de rafraîchissement. 60 s pour ce qu'un autre membre a changé, contre 3 s pour les listes : chaque tour de synthèse coûte une requête CalDAV. Les écritures locales, elles, rafraîchissent sur-le-champ, en suivant le compteur revision de la liste ouverte plutôt qu'en appelant la synthèse depuis chaque mutation.
  • Les réglages partagent une page, pas un propriétaire. /settings/:section déroule la famille, les préférences personnelles et l'administration dans une colonne continue. Le segment ne sélectionne pas un panneau : il sert seulement à positionner les anciens liens profonds. Chaque section conserve son store et son contrôle serveur ; l'administration de l'installation n'apparaît que lorsque GET /api/me confirme le droit. La même partie porte la zone dangereuse de suppression familiale pour son propriétaire, sans lui ouvrir les contrôles d'installation.
  • The routes are English, with no compatibility redirects. Renamed in #17, before the application shipped; the old French paths and the redirects they carried (/famille, /mes-notifications, /administration, /dashboard, /families/:familyId/lists/…) went with them. The full list is in UX.

D9 — Une seule notion de liste, déclinée en types

Choix : une table lists portant un kind (shopping, tasks, …) et une table list_items dont les champs spécifiques à un type sont nullables. Pas de module « to-do » distinct : les tables todo_lists et todo_tasks de la migration v1, jamais utilisées, disparaissent.

Raison : un article de courses et une tâche sont le même objet — une ligne qu'on coche, dans une liste ordonnée, appartenant à une famille. Ce qui les distingue tient à des champs optionnels : quantité d'un côté, assigné, échéance et priorité de l'autre.

Le coût de la duplication est ce qui tranche. La phase 1B a construit le CRUD des listes et des éléments, le cochage, le réordonnancement transactionnel, le polling avec garde de révision, les notifications et l'autorisation qui remonte élément → liste → famille → Team. Un module séparé signifierait réécrire tout cela et en maintenir deux exemplaires : deux chemins de polling, deux gabarits de notification, deux fois la même règle du 404. Pour un écart réel de trois colonnes.

Le type appartient à la liste, pas à l'élément. C'est lui qui décide des champs de modification et de l'icône affichée. L'ajout rapide, lui, ne demande que le nom pour tous les types : quantité, échéance, priorité et assignation se règlent ensuite dans la fiche de l'élément. Ajouter une ligne est le geste le plus fréquent de l'application, il doit rester le plus court.

Trade-off : si les deux formes divergent nettement plus tard — vue kanban, tâches récurrentes, sous-tâches — le kind devient une fourche à l'intérieur de chaque fonction, et il faudra alors séparer pour de bon. Aujourd'hui les 90 % communs l'emportent, et le type laisse la porte ouverte à d'autres déclinaisons (envies, cadeaux) sans nouveau module.

Conséquence sur le plan : les listes de tâches cessent d'être un module à construire. Il ne reste que les champs supplémentaires et ce que l'interface en fait.

D10 — Réordonnancement par glisser-déposer, avec repli clavier

Choix : SortableJS (via vuedraggable) pour le glisser-déposer, et les actions « Monter » / « Descendre » déplacées dans le menu de l'élément plutôt que supprimées.

Raison : le glisser est le geste naturel pour ordonner, et les flèches encombrent chaque ligne. Mais l'API HTML5 de drag-and-drop ne fonctionne pas au tactile, or cette application sert d'abord dans un magasin, à une main, sur téléphone. SortableJS gère le tactile, avec le délai qui évite de confondre glisser et faire défiler. Son clone de repli est attaché au body : le conteneur de contenu Nextcloud est décalé par rapport au viewport, et un clone fixe conservé dans la liste ne suivrait pas le même repère que le pointeur.

Trade-off : c'est la première dépendance d'interface hors @nextcloud/vue (~45 ko, à comparer aux 1,6 Mo que pèse déjà le bundle à cause du sélecteur d'emojis). Écrire soi-même un glisser tactile correct — seuil de déclenchement, défilement automatique, annulation — coûterait bien plus que la dépendance.

Le repli clavier n'est pas négociable : sans les flèches, réordonner deviendrait impossible sans souris ni doigt. Le menu de l'élément les conserve sans encombrer la ligne, et donne à la suite e2e un moyen déterministe de réordonner — le geste de glisser méritant son propre test, distinct de la persistance de l'ordre.

✅ Appliqué. force-fallback est obligatoire : SortableJS utilise par défaut le glisser natif HTML5 sur bureau, qui ne répond ni aux événements synthétiques ni à Playwright, et qui ne fonctionne pas au tactile. Le forcer sur son implémentation propre donne un seul comportement, pilotable et identique sur les deux supports. Une poignée dédiée s'y ajoute : sans elle, au doigt, tout geste vertical sur une ligne devient un déplacement et la liste ne défile plus.

D7 — Cache court de l'appartenance aux Teams

Choix : un cache mémoire de 15 s sur listCircles(userId), invalidé explicitement par toute mutation d'appartenance (création/suppression de famille, invitation, retrait).

Raison : chaque requête vérifie que l'utilisateur est membre de la famille visée, ce qui passe par un appel Circles. Mesuré sur l'instance locale : 180 ms à 1,7 s. Avec le polling des listes toutes les 3 s (D3), la vérification d'accès coûterait plus cher que la donnée demandée, et un pic de latence ferait s'empiler les requêtes.

Trade-off : un retrait de membre reste effectif jusqu'à 15 s si l'invalidation explicite est contournée — par exemple si l'appartenance change côté Nextcloud, hors de l'ExApp. Acceptable : la fenêtre est courte et le partage Nextcloud sous-jacent (l'agenda) est révoqué immédiatement par Circles, indépendamment de ce cache. Le cache est en mémoire du processus : il disparaît au redémarrage, ce qui est le comportement voulu.

D6 — Compensation plutôt que transaction pour le setup familial

Choix : createFamily empile une action de compensation après chaque étape réussie et les rejoue en ordre inverse si une étape échoue.

Raison : créer une famille, c'est plusieurs appels à Nextcloud puis une écriture SQLite. Rien ne les rend atomiques entre eux — si le partage de l'agenda échoue, la Team et l'agenda existent déjà. Sans compensation, chaque échec laisse des ressources orphelines visibles par l'utilisateur dans Calendar, qu'il devrait nettoyer à la main.

Depuis D23, il n'y a plus que trois étapes distantes au lieu de sept : la Team, l'agenda, son partage. Le mécanisme reste le même — c'est le nombre d'étapes qui a fondu, pas le besoin.

Trade-off : la compensation peut elle-même échouer. On la veut donc « best effort » — chaque échec de nettoyage est journalisé en error avec l'étape concernée, sans interrompre les suivantes, et l'erreur remontée à l'appelant reste la cause d'origine, pas le bruit du nettoyage. Un nettoyage partiel vaut mieux qu'aucun. Une reprise automatique des orphelines n'est pas implémentée : les logs suffisent à ce stade.

D5 — AppAPIAuth implémenté à la main

Choix : Middleware Fastify qui valide le header AUTHORIZATION-APP-API.

Raison : Pas de SDK Node officiel. L'algorithme entrant (Nextcloud → ExApp) :

  1. Exempter /heartbeat — AppAPI l'interroge avant tout échange de secret
  2. Lire EX-APP-ID, EX-APP-VERSION, AUTHORIZATION-APP-API des headers ; les trois doivent être présents et non vides. AA-VERSION n'est envoyé que sur les appels sortants — l'exiger en entrée fait échouer 100 % des requêtes
  3. Vérifier EX-APP-ID === APP_ID
  4. Décoder base64 de AUTHORIZATION-APP-API, découper sur le premier : → userid, secret
  5. Comparer secret à APP_SECRET avec crypto.timingSafeEqual (jamais === : c'est un bearer secret sur un endpoint exposé)
  6. Injecter userid dans la requête. Il est vide sur les appels lifecycle (/init, /enabled) — ce n'est pas une erreur, c'est le "user unavailable" d'AppAPI

Le sens sortant (ExApp → Nextcloud) envoie en plus AA-VERSION, et OCS-APIRequest: true avec ?format=json sur /ocs/v1.php/....

Trade-off : Code à maintenir soi-même, mais ~60 lignes. À extraire dans un package npm réutilisable si d'autres ExApps Node voient le jour.

Implémentation de référence (TypeScript, les deux sens) : .claude/skills/nextcloud-exapp-dev/references/appapi-auth.md.

Revised in #17 — a shopping list's checked items have a section of their own. Below the unchecked items, in a <details> open by default. The order on screen is then no longer the stored one, which this decision cares about, so: the checked section is not draggable; a drag in the section above writes that order followed by the checked items, so what is stored stays complete and matches the screen section by section; and « Monter » / « Descendre » move within the order on screen (store.move takes it) — by the stored order, « Monter » would do nothing whenever the neighbour above was a checked item shown below. Task lists keep checked items in place: there the order is the plan.

D11 — Ingrédients et étapes en JSON, pas en tables filles

Choix : les colonnes recipes.ingredients et recipes.steps stockent un tableau JSON, au lieu des tables recipe_ingredients / recipe_steps qu'un schéma relationnel appellerait.

Raison : une recette se lit toujours entière. La galerie renvoie des recettes complètes — ce qui permet de chercher par ingrédient sans aller-retour serveur — et la génération d'une liste de courses depuis un planning (2A.3) parcourra elle aussi des recettes complètes. Aucune requête ne s'intéresse à un ingrédient isolé. Une jointure n'achèterait donc rien, et coûterait une colonne d'ordre par ligne pour retrouver la séquence que le tableau JSON porte gratuitement.

C'est le raisonnement inverse de celui des listes, et la différence est réelle : un élément de liste se coche, se déplace, se supprime individuellement, et deux membres le modifient en même temps. Un ingrédient ne vit pas sans sa recette.

Trade-off : SQLite ne contraint rien dans ces colonnes. Elles sont NOT NULL mais contiennent du texte libre de son point de vue, donc une écriture ancienne ou une correction à la main peuvent y laisser n'importe quoi. La validation vit dans Zod à l'entrée, et la lecture est défensive : le parsing jette ce qu'il ne sait pas interpréter et renvoie le reste, plutôt que de lever. Une recette affichée sans ingrédients se rattrape ; un 500 sur la galerie bloque tout le module. Des tests dédiés corrompent les colonnes exprès pour tenir cette ligne.

✅ Appliqué en 2B. Les tables recipes et meal_plans existent depuis la migration v1 — créées en phase 0 par anticipation, jamais lues par une ligne de TypeScript jusque là. Elles convenaient encore : 2B n'a demandé aucune migration.

servings illustre le piège de ces colonnes dormantes. Elle porte DEFAULT 4 depuis v1, mais un INSERT qui écrit toujours la colonne — avec ?? null — annule ce défaut sans bruit et rend « pour quatre » indistinguable de « la recette ne le dit pas ». La colonne est donc omise de l'INSERT quand l'appelant ne dit rien.

D12 — Les ingrédients partent vers une liste sans fusion de quantités

Choix : envoyer les ingrédients d'une recette vers une liste de courses ajoute ceux que la liste ne nomme pas encore, et laisse les autres tranquilles. Les quantités ne sont jamais additionnées.

Raison : « 200 g » plus « 1/2 » n'a pas de somme. La quantité est du texte libre — et elle l'est délibérément, parce qu'une recette écrit « un peu », « 2-3 », « une pincée ». Inventer une addition demanderait de parser des unités, de les convertir, et de renoncer dès qu'un ingrédient sort du modèle ; le résultat serait faux plus souvent qu'utile. Ne pas ajouter deux fois « farine » couvre l'essentiel de la gêne, pour une comparaison de chaînes.

Le doublon se détecte sur le nom, insensible à la casse, sans regarder si l'élément est coché : personne n'écrit ses courses de façon cohérente, et un article déjà pris reste un article que la liste nomme.

Écrit au niveau de la recette, pas du planning. Générer la liste d'une semaine, c'est cette même opération appliquée à plusieurs recettes : 2A n'aura qu'à l'appeler en boucle. L'inverse — l'écrire dans le planning puis l'en extraire — aurait retardé une fonctionnalité utile seule.

Deux refus. Une liste de tâches est rejetée : le type décide du vocabulaire (D9), et des ingrédients dans une liste de corvées ne sont l'intention de personne. Et la famille de la recette est comparée à celle de la liste — les deux contrôles d'accès peuvent passer en désignant des familles différentes, puisque l'utilisateur appartient aux deux. Sans cette comparaison, une recette peut être poussée dans la liste d'une autre famille, et aucun des deux contrôles ne le voit seul.

✅ Appliqué. addItems insère en une transaction avec un seul incrément de revision : un geste, une écriture.

Étendu à la semaine en 2A.3. Envoyer une semaine planifiée, c'est cette opération appliquée à plusieurs recettes, et c'est écrit ainsi : les ingrédients sont rassemblés dans l'ordre du planning puis confiés au même écrivain. Le déjà présent grandit au fil du lot, donc « farine » n'arrive pas une fois par jour de la semaine qui en demande. Les créneaux en texte libre n'apportent rien — « chez mamie » n'a pas d'ingrédients — et un créneau dont la recette a été supprimée depuis est ignoré plutôt que de faire échouer la semaine entière.

D13 — Un créneau de repas est une coordonnée, pas une entité

Choix : le planning s'adresse par (famille, jour, midi|soir) et non par un id. PUT /api/families/:id/meals/:date/:mealType est un upsert, et chaque mutation répond avec la semaine entière.

Raison : c'est ce que la grille connaît. Une cellule n'a pas d'identité propre — elle a une place. L'upsert rend l'écriture idempotente, ce qui compte parce qu'un geste tactile déclenche son gestionnaire de dépôt plusieurs fois : sans lui, l'UNIQUE(family_id, date, meal_type) posé en v1 remonterait comme une erreur que l'utilisateur ne peut pas interpréter.

Répondre la semaine entière suit la règle des listes : le client remplace, il ne rapièce pas. La semaine est déductible de la date, donc le serveur peut toujours la calculer, et il n'y a rien à deviner côté client. C'est la leçon payée sur la galerie de recettes (D11).

L'arithmétique des semaines se fait en UTC sur le YYYY-MM-DD nu. Deux pièges, chacun avec son test : donner un jour nu à Date en fait un instant, qui glisse d'un jour à l'ouest de Greenwich — les repas du lundi passeraient sous dimanche pour la moitié du monde ; et getUTCDay() numérote dimanche à 0, donc la soustraction naïve laisse dimanche démarrer sa propre semaine au lieu de la clore.

Midi et soir seulement. Le petit-déjeuner est une routine, pas une décision familiale, et une troisième colonne vide chaque jour coûte plus d'attention qu'elle n'en rend.

Une recette ou du texte libre, jamais les deux, jamais aucun. Accepter les deux laisserait la grille arbitrer lequel afficher, et les deux divergeraient. Le refus des deux à vide est ce qui distingue « vider un créneau » (DELETE) de « le remplir ».

Une recette d'une autre famille est refusée, pour la raison de D12 : l'appelant est membre de la famille planifiée, mais rien ne l'empêche de nommer une recette d'une seconde famille à laquelle il appartient aussi, et la semaine pointerait alors vers quelque chose que ses membres ne peuvent pas ouvrir.

✅ Appliqué en 2A. meal_plans est la seconde table laissée par la phase 0 : elle convenait encore, aucune migration. La suppression d'une recette laisse le créneau en place et vide (ON DELETE SET NULL) — perdre le jour planifié parce qu'une fiche a été rangée serait pire.

D14 — L'attribution d'un événement passe par CATEGORIES, et le chemin d'un agenda dépend de qui le lit

Choix : un événement nomme les membres qu'il concerne dans CATEGORIES, sous forme de clés de membre opaques. Et le chemin CalDAV de l'agenda familial est résolu par lecteur, pas construit à partir du seul slug.

Pourquoi CATEGORIES et pas ATTENDEE. Deux raisons, et la seconde décide. Un ATTENDEE porteur d'une adresse déclenche l'envoi d'invitations par Nextcloud : chaque événement familial enverrait un courriel à toute la famille. Surtout, une famille comptera des membres sans compte Nextcloud — les jeunes enfants en premier. Leur semaine doit figurer sur l'emploi du temps bien avant qu'ils aient un endroit où se connecter, et ATTENDEE ne sait pas nommer quelqu'un qui n'a pas d'adresse.

Une clé est donc opaque à dessein. Aujourd'hui c'est un identifiant Nextcloud ; un membre sans compte recevra une clé d'une autre forme, et ni le parseur ni le filtre n'auront à changer.

Contrepartie : dans Nextcloud Calendar, ces clés s'affichent comme des étiquettes, et un membre peut les supprimer sans savoir ce qu'il défait. C'est le prix d'une attribution qui survit à l'édition depuis n'importe quel client.

Le chemin de l'agenda dépend du lecteur. L'agenda est créé sous la personne qui crée la famille, puis partagé avec l'équipe. Nextcloud renomme tout agenda reçu en partage en <uri>_shared_by_<propriétaire> — inconditionnellement, dans CalDavBackend::getCalendarsForUser. Demander le slug nu renvoie donc 404 pour tous les autres membres.

C'est le pire genre de bug : la lecture traitait ce 404 comme « agenda illisible » et renvoyait une liste vide. Depuis la phase 1B-bis.6, tout membre non créateur voyait une famille sans aucun événement, sans la moindre erreur nulle part. Rien ne l'avait signalé parce que toute la suite e2e tourne avec admin, toujours propriétaire de ce qu'il crée.

✅ Appliqué. calendarPathFor(userId, slug, ownerId) résout le chemin. Un test de bout en bout invite un compte jetable dans une famille et vérifie qu'il voit les événements — il échoue si l'on retire la résolution.

Les vues partagent une seule source. Jour, jour par membre, semaine, mois et planning ne construisent pas des agendas parallèles dans le navigateur. La route des événements reçoit une vue bornée, le serveur calcule la période et renvoie son instantané complet ; le jour par membre est la présentation journalière, et le planning la présentation chronologique du mois. Après une mutation hors vue semaine, le client relit la période complète au lieu de fusionner localement une réponse hebdomadaire.

Depuis D24, le propriétaire est le compte de service et non created_by : plus personne ne lit l'agenda familial « en propriétaire », tout le monde le lit en partage. Le chemin a donc une forme unique — <slug>_shared_by_<compte de service> — au lieu de deux selon qui regarde. La résolution reste, parce que la règle de renommage de Nextcloud reste ; mais la branche qui distinguait le créateur des autres n'a plus de cas à traiter.

D15 — Une règle de répétition plus riche que le modèle est conservée, jamais reconstruite

Choix : RRULE est modélisé au niveau de ce que l'interface propose — fréquence, intervalle, jours d'une règle hebdomadaire, échéance. Toute règle qui dit davantage est gardée telle quelle et réécrite à l'identique.

Raison : iCalendar exprime bien plus que ce qu'une famille saisit — « le troisième mardi du mois », des BYSETPOS, des secondes. Tout modéliser donnerait une interface que personne n'utiliserait et un parseur dont rien n'exerce les recoins.

Mais un événement peut avoir été écrit ailleurs, dans Nextcloud Calendar ou sur un téléphone. Le rouvrir ici et l'enregistrer reconstruirait sa règle à partir des morceaux compris, et « le troisième mardi du mois » deviendrait « tous les mois ». Un dégât silencieux, sur la donnée de quelqu'un d'autre. D'où raw : la ligne d'origine est conservée, le formulaire l'affiche sans l'offrir à l'édition, et l'écriture la rend intacte.

Ce que le client peut envoyer reste plus étroit que ce qui peut être lu. La route n'accepte que la forme modélisée. Accepter du texte RRULE libre laisserait chaque lecteur ultérieur deviner ce qui est réellement supporté.

Une série s'édite par son objet, pas par une occurrence. La lecture de semaine étend les récurrences et retire RRULE : une occurrence ne porte ni la règle ni la date de début de la série. L'ouvrir pour modification va donc chercher l'objet stocké (GET /events/:uid), sans quoi enregistrer déplacerait toute la série sur le jour cliqué. Et la réponse porte la semaine regardée, pas celle où la série commence, faute de quoi la grille sauterait après un enregistrement.

Le même raisonnement vaut pour l'objet entier, pas seulement pour la règle. Un objet d'agenda contient la série et chaque exception qu'on lui a faite — « cette semaine-là on se voit à 18 h » est un second VEVENT porteur d'un RECURRENCE-ID. Or buildIcs n'en écrit qu'un : réécrire un tel objet efface les exceptions. Mesuré : deux VEVENT en entrée, un en sortie.

Tant que 2C.5 ne sait pas réécrire l'objet sans y toucher, l'enregistrement est refusé sur une série qui en porte, comme il l'est sur une règle non modélisable. La suppression reste permise : retirer l'objet emporte ses exceptions, ce que « supprimer la série » dit sans ambiguïté.

✅ Appliqué en 2C.4, complété en 2C.5. Le formulaire demande d'abord ce que l'on vise : cette occurrence, ou toute la série. Retirer une occurrence l'exclut (EXDATE) plutôt que de la supprimer — elle n'a pas d'objet à elle — et en modifier une écrit une surcharge portant son RECURRENCE-ID.

Le défaut est « cette occurrence », et ce n'est pas arbitraire. Les deux défauts peuvent se tromper, mais les erreurs ne se valent pas : déplacer un cours et déplacer tous les lundis se ressemblent jusqu'à la semaine suivante, alors que renommer une seule semaine se voit immédiatement.

D16 — Les rappels sont tenus par le conteneur, pas par Nextcloud

Choix : une passe de rappels tourne sur une minuterie dans le processus de l'ExApp, toutes les heures. Ce qui a déjà été annoncé est mémorisé dans reminders_sent, et l'index UNIQUE de cette table est le mécanisme d'unicité, pas un garde-fou.

Raison : c'est le seul travail de cette application qui ne part pas d'une requête. Tout le reste commence par un appel HTTP, qui apporte un utilisateur, une famille et une autorisation. Un rappel part de l'horloge. Or le cron de Nextcloud ne sait pas entrer dans un conteneur ExApp, et AppAPI n'expose aucun crochet planifié : un conteneur déjà en train de tourner est la seule chose ici qui puisse tenir le temps.

Réclamer plutôt que vérifier. Envoyer un rappel commence par un INSERT OR IGNORE dans reminders_sent ; seul l'insert qui passe réellement déclenche l'envoi. C'est insensible aux courses par construction, là où un « lire puis écrire » demanderait un ordre soigneux et se tromperait un jour.

La date d'échéance fait partie de la clé, pas la date du jour : réclamer sur aujourd'hui annoncerait chaque matin la même tâche en retard, tandis qu'une tâche repoussée à plus tard mérite bien d'être réannoncée le moment venu.

« Échue au plus tard aujourd'hui », pas « échue aujourd'hui » : une tâche que personne n'a faite hier attend toujours, et se taire là-dessus serait une discrétion mal placée.

Une passe qui échoue est journalisée, jamais relancée en exception — une minuterie qui lève cesse d'être une minuterie.

Limite assumée : deux instances de l'ExApp annonceraient tout en double, et c'est reminders_sent qui l'empêcherait. Le déploiement visé n'en a qu'une.

« La veille » a fallu choisir une heure. La passe tourne toutes les heures : annoncer les événements de demain dès le premier passage après minuit réveillerait les gens pour quelque chose qui sera « aujourd'hui » avant qu'ils le lisent. Le seuil est 18 h UTC, soit le début de soirée en Europe de l'Ouest, à qui cette application s'adresse.

C'est une heure UTC fixe, et c'est la limite : une famille sous un autre fuseau la reçoit à sa propre soirée par coïncidence seulement. Faire mieux demanderait un fuseau par famille, que rien ne stocke ici.

Le seuil sert aussi de garde-fou au coût : la lecture des événements est un appel CalDAV par famille et par passe, et hors de la fenêtre du soir elle poserait la même question pour en jeter la réponse.

✅ Appliqué en 2D.1, pour les tâches échues comme pour les événements du lendemain.

D17 — La préférence consultée est celle du destinataire

Choix : deux interrupteurs par personne — « ce que fait la famille » et « les rappels » — et l'envoi consulte la préférence de chaque destinataire, pas celle de qui a déclenché la notification.

Raison : c'est la seule lecture qui ait un sens. Une notification part vers plusieurs personnes qui n'ont pas les mêmes attentes ; consulter la préférence de l'auteur reviendrait à laisser quelqu'un imposer le silence — ou le bruit — à toute la famille.

Coût mal évalué, et corrigé depuis. Cette décision annonçait « un appel OCS de plus par destinataire » comme un prix acceptable, hors du chemin critique. C'était faux à l'échelle : une famille qui s'en sert envoie des notifications en continu, et l'instance a fini par répondre 429 « Reached maximum delay » — après quoi l'application affichait une famille sans aucun membre. Retirer cette lecture réduisait fortement les échecs de la suite e2e sans les éliminer complètement.

La préférence est donc mise en cache, comme l'appartenance à l'équipe (D19), avec la même TTL courte. L'écriture vide l'entrée, pour que celui qui bascule l'interrupteur le voie prendre effet.

La leçon vaut au-delà de cette décision : « un appel de plus, hors du chemin critique » n'est pas une évaluation, c'est une intuition. Ici elle multipliait les appels par le nombre de destinataires et par la fréquence des notifications.

Deux interrupteurs, pas un par message. Tout ce qui est envoyé relève de deux expériences : ce que quelqu'un d'autre a fait, et ce que dit l'horloge. Qui coupe l'une veut rarement couper l'autre, et une liste plus fine serait un écran de réglages que personne ne lit.

Tout est activé tant que rien n'a été dit. Une préférence absente, c'est quelqu'un qui n'a jamais ouvert les réglages — pas quelqu'un qui demande le silence. C'est aussi à quoi ressemble une lecture en échec, et une préférence illisible fait envoyer quand même : se taire parce que Nextcloud a hoqueté perdrait la notification pour de bon, alors qu'une notification en trop n'est que du bruit.

L'écran est personnel, pas familial. Le réglage suit l'utilisateur dans toutes ses familles, d'où une entrée à lui plutôt qu'une section des paramètres de la famille — qui mentirait sur sa portée.

D18 — L'activité est rassemblée avant d'être annoncée

Choix : les ajouts d'articles sont retenus trente secondes et annoncés ensemble — « X a ajouté 6 articles à Courses » plutôt que six lignes qui disent presque la même chose.

Raison : on remplit une liste de courses par salves, pas article par article. Une notification par ajout transforme une application utile en gêne, et c'est la première chose qu'un membre coupe — auquel cas il perd aussi ce qui comptait.

Retenu, pas fusionné après coup. Nextcloud remplace une notification quand une nouvelle porte le même object_id, ce qui aurait été le mécanisme propre : envoyer « 2 articles » puis « 3 articles » aurait laissé une seule ligne à jour. Mais l'endpoint d'AppAPI fige cet identifiant sur l'application elle-même, donc rien de ce que nous envoyons ne peut remplacer quoi que ce soit. Le regroupement doit précéder l'appel, et précéder l'appel veut dire attendre.

La fenêtre ne redémarre pas à chaque ajout. Avec une minuterie qui se réarme, quelqu'un qui ajoute régulièrement ne serait jamais annoncé. Elle court depuis le premier ajout, point.

Regroupé par famille, liste et auteur. Deux personnes qui remplissent la même liste en même temps sont deux nouvelles, pas une.

L'état est en mémoire. Un redémarrage perd au plus une fenêtre d'annonces en attente — un prix juste face à une table et une boucle de scrutation pour quelque chose d'aussi cosmétique.

✅ Appliqué en 2D.2. Le cochage, lui, reste immédiat : il arrive à l'unité, et son verbe suit déjà le type de liste — « a terminé » une tâche, « a coché » un article.

D19 — Tout ce qui est demandé à Nextcloud plusieurs fois par minute est mis en cache

Choix : l'appartenance à une équipe, la liste des membres d'une famille et les préférences de notification passent toutes par un cache de quinze à trente secondes.

Raison : ce ne sont pas des optimisations, ce sont des conditions de fonctionnement. Nextcloud limite ces lectures, et pas seulement par quota explicite : Circles répond 429 « Reached maximum delay » bien avant que quoi que ce soit soit logiquement faux. Quand ça arrive, l'application ne ralentit pas — elle ment : la liste des membres revient vide, le filtre de l'emploi du temps ne propose personne, et le formulaire d'événement garde son bouton désactivé sans rien afficher qui l'explique.

La TTL est courte pour une raison précise : un changement doit prendre effet pendant que la personne qui l'a fait s'en souvient encore. Quinze secondes pour l'appartenance, trente pour les préférences, et l'écriture d'une préférence vide son entrée.

Le corollaire, côté appelant : un échec de lecture ne doit jamais être transformé en résultat vide. Le store de l'emploi du temps faisait catch(() => []) sur la liste des membres, ce qui changeait un 429 passager en « aucun membre » pour toute la vie de la page. Il conserve désormais ce qu'il savait déjà.

✅ Appliqué. Mesuré sur le spec timetable isolé : plusieurs minutes avec des échecs avant, moins d'une minute et une exécution entièrement verte après.

D20 — Un refus n'est jamais redemandé

Choix : membersOf retient pendant une minute un refus de Circles (403 ou 404) et échoue immédiatement sans rappeler Nextcloud. Une annonce d'activité différée vérifie que la famille existe encore avant de demander qui en fait partie.

Raison : Nextcloud ne se contente pas de refuser. Circles appelle $response->throttle() sur InsufficientPermissionException, ce qui inscrit le refus dans oc_bruteforce_attempts au nom de l'adresse appelante — et le délai appliqué à toutes les requêtes suivantes depuis cette adresse croît exponentiellement. Dix refus ont suffi à mettre l'instance derrière « Reached maximum delay », après quoi des appels parfaitement légitimes revenaient 429 et l'application affichait des familles sans personne dedans.

Autrement dit : ce n'est pas un quota de volume, c'est une punition pour appel refusé. Le coût d'un refus ne se paie pas sur l'appel refusé, il se paie sur les cent suivants.

D'où venaient les refus : de la fenêtre de regroupement de l'activité (D18). Elle attend trente secondes avant d'annoncer — largement de quoi laisser la famille être supprimée entre-temps. L'annonce demandait alors les membres d'un cercle qui n'existait plus, une fois par groupe en attente.

Ce qui est retenu et ce qui ne l'est pas : un 403 ou un 404 disent « vous n'avez pas le droit », ce qui ne redevient pas faux tout seul. Un 429 est l'instance qui nous freine déjà et un 500 est son propre problème : ni l'un ni l'autre ne dit qu'il ne faut plus demander, et les retenir transformerait un hoquet en une minute d'aveuglement. Toute opération qui change l'appartenance vide cette mémoire avec les autres caches.

Corollaire de diagnostic : une erreur OCS porte désormais l'utilisateur au nom de qui l'appel a été fait. Nextcloud répond différemment à la même URL selon qui demande ; une erreur qui ne dit pas qui demandait ne se diagnostique pas. Soixante refus ont été observés sans qu'aucune trace ne permette de dire quel compte avait été refusé.

✅ Appliqué. Mesuré sur la suite complète : des refus et réponses 429 sur chaque exécution avant, zéro après — et trois exécutions complètes vertes en 3,4 min, sans aucun nettoyage entre elles.

D21 — Une famille survit au compte qui l'a fondée

Choix : la suppression d'une famille demande le propriétaire de l'équipe, pas created_by. Et une lecture d'agenda qui échoue revient vide en le disant, au lieu de se faire passer pour un agenda sans rien dedans.

Ce qui a été mesuré, en supprimant pour de vrai le compte d'un fondateur :

Ce qu'on croyait Ce qui se passe
L'équipe est perdue Elle survit : Circles promeut de lui-même le membre restant en propriétaire
Les ressources suivent la famille L'agenda, le dossier et le carnet d'adresses partent avec le compte
created_by reste utilisable Il désigne un fantôme, définitivement

Le cul-de-sac : la route de suppression comparait created_by à l'appelant. Le créateur n'existant plus, chaque membre restant recevait « seul le créateur peut supprimer la famille » — à propos d'un créateur disparu. La famille devenait indestructible, pour tout le monde, sans recours. Demander le propriétaire de l'équipe donne la même réponse tant que le fondateur est là, et la bonne après.

Le mensonge : upcomingEvents et eventsBetween renvoyaient [] sur échec, avec un commentaire assumant le compromis — ne pas faire échouer toute la page pour un agenda momentanément injoignable. Le compromis est juste, la conséquence ne l'était pas : la colonne de synthèse annonçait sereinement « rien de prévu dans les trente prochains jours » à propos d'un agenda qu'elle n'avait jamais atteint. Les deux renvoient désormais un CalendarRead — ce qu'on sait, et si on a pu le lire — et l'interface distingue une semaine vide d'une semaine inconnue.

Corollaire sur le cache : les écrans depuis lesquels on agit demandent une liste de membres non mise en cache (?fresh=1). Deux minutes de retard ne coûtent rien sur un emploi du temps ; ici, le cache contenait encore le fantôme comme propriétaire et cachait la sortie de secours à la seule personne capable de l'emprunter. La grille et les notifications gardent la réponse en cache — c'est ce qui a arrêté l'auto-étranglement décrit en D19.

Ce que Circles ne sait pas faire : transférer la propriété d'une équipe. PUT /circles/<id>/members/<member>/level accepte 8 (admin) et échoue en 9 (propriétaire) avec SQLSTATE[0A000]: FOR UPDATE cannot be applied to the nullable side of an outer join — un défaut de Circles 34 sur PostgreSQL, mesuré, sans contournement côté application. Le transfert volontaire (2E.1) devra faire avec.

✅ Appliqué. Test de bout en bout : une famille fondée depuis un compte jetable, le compte supprimé, puis la famille supprimée par le membre restant. Vérifié par sabotage dans les deux sens.

D22 — Transmettre une famille, c'est déplacer ses ressources

Choix : POST /api/families/:id/owner recopie l'agenda de la famille sous le nouveau propriétaire, le repartage à l'équipe, met à jour families.created_by, puis seulement alors supprime l'ancien. Le nouveau propriétaire est promu administrateur de l'équipe.

Pourquoi recopier et non déplacer : il n'existe pas de « déplacer un agenda vers un autre compte » côté API. occ dav:move-calendar le ferait, mais un ExApp n'a pas de occ. On lit donc tous les objets, on crée la collection chez l'héritier, on écrit, on repartage. Le slug vient de l'identifiant de la famille et non de son nom : il est donc libre dans l'espace de l'héritier quoi qu'il possède par ailleurs.

La lecture n'est pas celle de la grille. reportCalendarObjects demande une plage de dates et l'expansion des récurrences — ce que veut une semaine d'emploi du temps, et l'exact contraire de ce que veut une copie : un événement hors de la fenêtre resterait sur place, et une leçon de natation hebdomadaire deviendrait cinquante-deux objets sans règle. listCalendarObjects ne filtre rien et n'expanse rien.

L'ordre est la garantie. Le nouvel agenda est rempli et partagé avant que l'ancien ne soit touché ; un échec en cours de route laisse la famille exactement là où elle était. La suppression de l'ancien vient après la mise à jour de created_by et ne peut plus rien coûter — Nextcloud ne fait de toute façon que le mettre à la corbeille.

Ce que ça ne déplace pas : le dossier partagé et le carnet d'adresses. Ils restent au propriétaire d'origine et meurent toujours avec son compte. Dit franchement plutôt que fait à moitié : une famille qui se croirait à l'abri serait pire que rien.

Ce que Nextcloud refuse : faire de l'héritier le propriétaire de l'équipe. Circles 34 échoue sur PostgreSQL (D21). La promotion s'arrête à administrateur, ce qui donne la gestion des membres. Conséquence acceptée, et moins grave qu'il n'y paraît : à la suppression du compte restant propriétaire, Circles promeut de lui-même quelqu'un.

✅ Appliqué. Test de bout en bout : une famille fondée depuis un compte jetable avec un événement récurrent, transmise, puis le compte supprimé — l'événement est toujours là. Vérifié par sabotage : sans la copie des objets, l'agenda de l'héritier est vide.

D23 — Une famille n'a plus qu'une ressource Nextcloud : son agenda

Choix : la création d'une famille ne provisionne plus de dossier partagé ni de carnet d'adresses, et la migration 6 retire les colonnes folder_path et addressbook_slug.

Pas de rétrocompatibilité : aucune instance ne fait tourner cette application hors celle de développement. Garder deux colonnes nullables, deux étapes de suppression et un jeu de mocks pour des familles qui n'existent nulle part, c'était payer pour un passé qu'on n'a pas. La règle « une migration publiée ne se modifie jamais » tient toujours — d'où une migration de plus plutôt qu'une retouche de la migration 2. Le jour de la mise en production, cette liberté disparaît.

Raison : en cherchant comment protéger les ressources d'une famille de la suppression d'un compte, l'inventaire de leur usage réel a donné ceci.

Ressource Ce que l'application en faisait
Agenda Lu et écrit en permanence : emploi du temps, synthèse, rappels
Dossier partagé Créé, renommé, supprimé. Affiché comme une chaîne de caractères.
Carnet d'adresses Créé, renommé, supprimé. Jamais lu, jamais écrit.

Deux tiers des ressources qu'une famille pouvait perdre avec un compte ne servaient à aucune fonctionnalité. Elles coûtaient quatre appels distants à la création, deux étapes de compensation, une propagation de renommage — dont un déplacement de dossier qui pouvait échouer bruyamment sur une collision — et deux étapes de suppression.

Ce n'est pas une régression de périmètre : Nextcloud gère déjà Files et Contacts très bien tout seul. Une famille qui veut un dossier partagé le crée dans Files et le partage à son équipe ; rien n'obligeait cette application à le faire à sa place, et le faire lui donnait la charge de le renommer, de le déplacer et de le supprimer.

Ce que ça simplifie ailleurs : sanitizeFolderName et sa résolution de collisions disparaissent, ocs/shares.ts entier aussi, et quatre fonctions du client DAV avec. Le renommage d'une famille ne touche plus que deux choses au lieu de quatre.

✅ Appliqué. La suppression de ces ressources inutilisées reste couverte côté backend et de bout en bout.

D24 — Le compte de service est créé par le navigateur de l'administrateur

Choix : un écran d'administration, dans l'application, crée le compte qui détiendra les agendas de toutes les familles. La création part du navigateur de l'administrateur ; le backend se contente d'enregistrer le compte après avoir vérifié qu'il existe.

Aucune famille sans lui. createFamily refuse en 409 tant qu'aucun compte de service n'est configuré, avant même de créer l'équipe. Se rabattre sur le fondateur recréerait en silence le problème que tout ceci existe pour supprimer, et personne ne le saurait avant la suppression d'un compte.

Pourquoi pas depuis le backend : créer un compte passe par l'API de provisionnement, que Nextcloud protège par une confirmation de mot de passe. Un ExApp qui appelle n'a pas de session pour la satisfaire — mesuré, 403 Password confirmation is required. En lisant PasswordConfirmationMiddleware, le seul repli sans session est allowed_no_password_confirmation_ranges, un réglage serveur qui relâche la confirmation pour toute une plage d'adresses. Prix trop élevé pour une action faite une fois dans la vie d'une installation.

Le navigateur de l'administrateur, lui, porte déjà sa session et ses droits. Mesuré, réglage retiré : 200. Et si sa dernière saisie de mot de passe est trop ancienne, OC.PasswordConfirmation affiche la boîte de Nextcloud — notre code ne voit jamais le mot de passe.

Le compte est actif, pas désactivé. Les partages d'un compte désactivé continuent de fonctionner aujourd'hui, mais c'est classé comme un bug côté Nextcloud (server#45479) : bâtir dessus, c'est bâtir sur ce qu'amont veut corriger. Le mot de passe est un double UUID généré puis oublié : le compte est actif et personne ne peut s'y connecter.

Configuré et présent sont deux questions. « Configuré » dit qu'un administrateur a fait la mise en place ; « présent » dit que le compte existe encore. Sans la première, l'application adopterait en silence n'importe quel compte portant ce nom — celui de quelqu'un, peut-être. Sans la seconde, elle écrirait dans un compte disparu et annoncerait des agendas vides pour toutes les familles à la fois.

Cloisonnement, mesuré : un compte de service, deux familles, chacune avec son agenda partagé à sa seule équipe. Un membre de l'une lit la sienne (200) et obtient 404 sur tous les chemins vers l'autre, y compris l'espace du compte de service. Le cloisonnement vient du partage, comme avant — et il est plus strict qu'avant, puisque plus aucun membre ne possède quoi que ce soit.

Ce que ça coûte : un administrateur peut supprimer ce compte, et ce serait catastrophique pour toutes les familles d'un coup. On échange une mine sous chaque membre contre un point unique, visible et nommable — que l'écran signale, et dont la disparition est détectée plutôt que tue. Et désormais réparable : recréer le compte remet les collections en place et refait le plein des deux qui sont des projections (D32). Ce qui ne revient pas, c'est ce que l'agenda familial contenait — lui n'a pas d'autre source.

Route : ^/api/admin/.* est déclarée en ADMIN avant ^/api/.* dans info.xml. La source d'AppAPI est explicite — « First match by path+verb wins. Apply its access level without falling through to broader routes » — donc l'ordre est porteur. Le groupe est revérifié dans le backend : la barrière du proxy protège le déploiement, celle de l'application protège l'application.

L'écran vit hors du contexte familial. Une installation neuve n'a aucune famille : la coquille montre l'assistant de première utilisation et aucune navigation. L'assistant porte donc le lien, pour un administrateur seulement — sans quoi la mise en place ne serait atteignable qu'après la chose qu'elle doit précéder.

✅ Appliqué. La mise en place, et l'usage : toute famille crée désormais son agenda sous ce compte, et personne d'autre n'en possède. Le test de bout en bout fonde une famille depuis un compte jetable, y sème un événement, supprime le compte — et l'événement est toujours là. Vérifié par sabotage : agenda créé sous le fondateur, le test ne peut même plus semer.

Ce qui devient sans objet : le transfert volontaire de propriété (l'ancien 2E.1) et les avertissements avant départ (2E.3). Ils traitaient le symptôme ; il n'y a plus de symptôme. Le code du transfert a été retiré plutôt que gardé « au cas où ».

D25 — Une photo de recette vit sur le volume de l'application, pas dans Files

Choix : la photo est écrite dans ${APP_PERSISTENT_STORAGE}/photos, à côté de la base, et recipes.photo_path retient son nom de fichier. L'application la sert elle-même sur GET /api/recipes/:id/photo.

Le plan de 2B.3 disait « upload via Files OCS », et ce plan précède deux décisions qui lui retirent sa raison d'être. D23 a supprimé le dossier partagé parce que personne ne le lisait ; remettre les photos dans Files, c'est réinstaller une arborescence à créer, renommer et supprimer. D24 a confié au compte de service ce qu'une famille ne peut pas perdre — les photos vivraient donc sous un compte qu'aucun membre n'atteint, et l'application devrait de toute façon relayer chaque lecture. Le seul bénéfice de Files, voir ses affaires dans Files, disparaît dès qu'elles appartiennent à quelqu'un d'autre.

Sur le volume, en revanche : même disque que la base, même sauvegarde, même durée de vie. Aucun compte ne possède la photo, donc aucune suppression de compte ne l'emporte, et sa disparition ne peut venir que de celle de sa recette.

Enregistrée et présente sont deux questions. Un volume restauré depuis une sauvegarde plus ancienne que la base répond oui à la première et non à la seconde. Ouvrir un flux sur un fichier absent ne rate pas là où un gestionnaire peut répondre : Fastify refuse la charge et le navigateur reçoit 500 FST_ERR_REP_INVALID_PAYLOAD_TYPE — un code interne, à propos du mauvais coupable, exactement ce que le gestionnaire d'erreurs existe pour éviter. Mesuré. La route vérifie donc l'existence avant de servir, et les deux cas sont le même 404 : il n'y a pas de photo à montrer.

L'ancien fichier part en dernier. Le nom porte l'extension, donc une recette rephotographiée dans un autre format atterrit dans un fichier différent et l'ancien doit disparaître — mais seulement une fois que la base nomme le nouveau. Le supprimer d'abord laissait une fenêtre où une écriture ratée faisait pointer la recette sur un fichier disparu, c'est-à-dire précisément l'état qui répondait 500.

Ce que l'API expose est hasPhoto, pas le chemin. L'interface construit l'URL à partir de l'identifiant de la recette ; elle n'a jamais à connaître un nom de fichier, et il n'y a donc rien à valider quand elle en renvoie un.

L'image arrive brute, pas en multipart. La requête ne transporte qu'une chose et la recette est nommée dans l'URL : un formulaire multipart coûterait une dépendance et un analyseur pour rien. Le plafond de 5 Mo est appliqué par l'analyseur de corps, donc un envoi trop gros est refusé avant d'atteindre le disque.

Une réserve assumée : Cache-Control: no-store s'applique ici comme à toute réponse /api/, donc la galerie retélécharge ses vignettes à chaque affichage. Une famille tient quelques dizaines de recettes ; la justesse sur une image partagée vaut mieux que les allers-retours économisés. Le jour où ça pèsera, ce sera une exception à écrire et à justifier, pas un oubli à corriger.

Le module lit APP_PERSISTENT_STORAGE dans l'environnement, pas via config. Importer config valide tout l'environnement sur-le-champ : un test unitaire sur les listes de courses s'est mis à échouer sur un APP_SECRET manquant, au seul motif qu'une recette peut porter une photo. config garde la validation au démarrage ; ce module refuse seulement d'être celui qui la déclenche.

✅ Appliqué. Le scénario de bout en bout envoie une vraie image et retélécharge les octets : un <img> qui pointe vers un 404 reste un <img>.

D26 — The server is authoritative on client state

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: every mutation answers with the complete snapshot of the resource it touched, and the client replaces its state with it. No store recomputes what the server already decided, and no store branches on an HTTP status code.

Reason. The alternative — a mutation returning only the entity it changed, the client patching its local copy — was tried and produced a whole class of bug that only shows up on the next reload: a deleted item reappearing, an order lost, a rename landing in one place on creation and somewhere else afterwards. Each one is individually fixable and collectively endless, because the client is maintaining a second opinion about state it does not own.

What "complete" means depends on the resource, and each choice is deliberate:

  • Lists answer with the list and its items. A list is a few dozen rows; the payload is nothing next to the class of bug it removes. Every read of a list also carries remaining, the count of its unchecked items, counted by the server with one subquery shared by the index, the snapshots and the home (#17). The index's counter therefore follows a check through the snapshot the client replaces its state with, instead of being recomputed from items the client may not hold.
  • The meal planner and the timetable answer with the whole week (or the bounded period being looked at). The week is derivable from a date, so the server can always compute it, and the client replaces its grid rather than patching a cell. The week answered is the one being looked at, not the one the event belongs to — a series starting months ago would otherwise make the grid jump after a save.
  • Recipes answer with the recipe alone, and the gallery is refetched rather than patched. This is the exception that proves the rule: the backend orders titles with COLLATE NOCASE, and no client-side comparison reproduces it. SQLite sorts "Éclair" after "Zeste" where localeCompare('fr') puts it between "Eau" and "Far", so a locally inserted recipe appeared in one place and jumped elsewhere on the next reload.

Corollary 1 — an outdated answer must never land. Making the server authoritative is not enough if a slower response can overwrite a newer one. Two mechanisms, both required:

  • A monotonic revision on the list resource, incremented by every write including on an item. Polling refuses any snapshot older than the one displayed. Without it, a poll issued before a tick but arriving after it unchecked the box a few seconds later — with no error, since nothing had failed. A counter and not updated_at: a counter is unambiguous where a timestamp is only so to its precision.
  • A generation counter per store, bumped by every load and every mutation, with late responses discarded. Loads overlap by construction here: startup shows the family from the local cache and then corrects it from the server preference, so two loads for two different families are routinely in flight at once. A mutation must bump it too — the grid stays clickable while a week loads, so writing a slot right after changing week leaves a load in flight whose snapshot predates the write.

Corollary 2 — a failed read is not a fact. A read that failed comes back as null, never as an empty array: the two are indistinguishable to a caller, and the empty one is a lie. One transient 429 on a member list turned into a filter with nobody in it and a submit button disabled for the life of the page, with nothing on screen saying why. Anything that prunes stored state against a list — a member filter, a member order — only prunes when the list genuinely loaded.

Cost. Slightly larger responses, and a refetch after every recipe mutation. Both were measured as negligible at family scale, and neither is close to the cost of the bugs they remove.

✅ Applied across services/list.ts, services/meal.ts, services/event.ts, services/recipe.ts and every Pinia store.

D27 — A preference lives on the server or in the browser, and which one is not arbitrary

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: a choice that belongs to the person goes to the AppAPI Preferences API; a choice that is how one screen looks on this device stays in localStorage.

On the server In localStorage
The active family The timetable's member filter, per family
Notification switches The member display order, per family
Lists whose checked items are hidden
The active family, as a startup cache only

Reason. The active family and the notification switches have to follow someone from their laptop to their phone — a preference that does not travel is a setting they will set twice. The filter and the hide-checked toggle are the opposite: filtering on the children while someone else reads the whole week is the normal case, and syncing that across devices would surprise more than it helps. It is also state that costs a round trip to store and is worthless if lost.

The active family appears in both columns on purpose. The server preference is the source of truth; localStorage holds the last known value so the first render has a family to show instead of an empty screen for the duration of a round trip. The stored value is then confronted with the families actually reachable and corrected if it names one that has been deleted or left.

Three rules the browser side follows:

  • Storage failure is never an error. Private browsing and blocked cookies both make writes throw. Nothing here is worth failing a context change or a display toggle over — the choice stays applied for the session.
  • Only non-default values are stored. "Everyone" and "show checked items" cost nothing to say, so an absent key is the default rather than a value to write.
  • A stale key is inert, never authoritative. A filter outlives the family it was made in, and a stored order predates whoever joined last. Keys the list does not know keep their server position rather than hiding a member; keys no member carries any more are pruned — but only against a member list that genuinely loaded (see D26).

✅ Applied in stores/families.js, stores/events.js and stores/lists.js; the server side is routes/preferences.ts over AppAPI's per-user Preferences API.

D28 — A request passes through four gates, and the order is the contract

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: an /api request is answered by four things in a fixed order, and no handler repeats what an earlier one has already done.

# Gate Where it lives What it guarantees
1 AppAPIAuth auth/appapi-auth.ts, onRequest, whole instance The caller is Nextcloud, and req.user is whoever it acts for — the empty string on a lifecycle call
2 requireUser auth/appapi-auth.ts, onRequest, per route module req.user is not empty
3 The route schema schema: { params, body, querystring }, Zod req.params, req.body and req.query hold what the schema describes, typed
4 resolveFamily auth/resolve-family.ts, preHandler, per scope req.family is a family the caller belongs to

Reason. Each of these was once the first line of every handler — requireUser(req) in 48 of them, familyParams.parse(req.params) in 66, getFamilyForUser followed by a hand-written 404 in 8. A check that is repeated is a check that can be forgotten, and a forgotten one fails silently: a route without requireUser answers a lifecycle call as though a person had made it.

Fastify runs schema validation before any preHandler, and that ordering is load-bearing. resolveFamily reads :id without validating it, because by the time it runs the schema has already refused anything that is not a uuid. Resolve first and a malformed id becomes a 404 where the API used to answer 400.

The validation error is not a ZodError. The schemas are Zod, but a schema failure reaches errors.ts as a Fastify validation error; left alone, Fastify answers in English — body/name must NOT have fewer than 1 characters — and this API's message field is displayed to the user as it arrives. The handler therefore reads the Zod issue back out of err.validation[].params.issue, which is where the French written in the schema is. families.spec asserts one such message end to end, through the proxy.

Two things sit deliberately outside the pipeline. /heartbeat is exempt from gate 1 — AppAPI probes it before any secret is exchanged. And /enabled keeps validating its query string inside the handler, because its 400 is a plain string AppAPI shows to the administrator, not this API's JSON shape.

D29 — The e2e suite imposes state, it does not read it

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: every run starts by deleting every family, purging the rate-limit and bruteforce tables and emptying the calendar trashbin — through the app and through occ, never by reading what is already there and deciding it is close enough. Each spec that needs a family creates its own, rather than reusing whatever an earlier spec or an earlier run happened to leave behind.

Reason. Some scenarios in families.spec passed for months only because a stray family from an older provisioning script happened to sit on the dev instance — the suite had never actually run from zero. The day it did, it failed: with no family at all, the shell shows the first-run assistant instead of a sidebar, and a spec written on the assumption that a sidebar exists finds nothing to click. A suite that borrows another spec's state is a suite that reads its own history instead of testing the application — and the failure only shows up the one time the history is missing.

The alternative — inspect what the instance already holds and adapt, or skip cleanup when a run looks like it succeeded — was rejected on the same evidence: cleanup is exactly what a failure needs, since that is when a family, a rate-limit counter or a trashed calendar is most likely to be left behind. reset.setup.ts runs before the suite and teardown.ts repeats the same steps after it, unconditionally.

✅ Applied. reset.setup.ts and teardown.ts share the reset logic in instance.ts; every spec that needs a family creates it in its own beforeAll/beforeEach rather than assuming one exists.

D30 — The week grid nests overlapping events rather than splitting the column

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: a day column arranges its events as a hierarchy, not as equal lanes.

  • An event whose end falls strictly before another's is drawn inside it, in the right half of its box. An end that reaches or passes its neighbour's makes the two siblings instead. One comparison decides, and a stack builds the whole forest in one pass.
  • A run of k siblings that overlap takes 2 / (k + 1) of the room each and steps across it, so every one covers half of the one before and the last finishes exactly on the right edge. Two siblings get two thirds each, three get a half.
  • Nesting stops after two levels; deeper, an event becomes a sibling of its container and pays an indent rather than half of what is left.
  • Height is the duration, with a 16px floor, and the text thins out with it: under 37px the hour and the title share a line, under 50px the members are dropped.
  • An event that crosses midnight is one event drawn in every column it touches, clipped to each.

Reason. The first implementation gave each of n overlapping events 1 / n of the column. That is right when two appointments genuinely compete for the same hour, and wrong the rest of the time: a whole morning of marking with a one-hour phone call inside it is not two events sharing a morning, it is one event with an interruption. Halving the morning to make room for the call said the opposite, and a day with a long band and three short ones inside it left four unreadable slivers. Containment is the relation the reader already sees, so it is the one the layout draws.

The two-thirds figure is not a taste: 2 / (k + 1) is the single rule "each event covers half of its neighbour", solved for the width that makes the run end on the column's edge. Equal lanes past three events were considered and rejected — the discontinuity between "staggered" and "split" was more surprising than the narrower chips.

A consequence worth stating: the timed layout attributes an event to a day on the reader's clock, not in UTC. It has to — the minutes come from getHours(), and clipping a span at midnight only agrees with that if the day boundaries are local too. The rest of the timetable still slices the UTC day off the ISO string (event.start.slice(0, 10)), so a late-evening event can land on different days in the week grid and in the day view. The week grid is the correct one; aligning the others is a separate change.

✅ Applied. services/week-timed-layout.ts owns the hierarchy and the geometry, with services/week-all-day-layout.ts for the banners and services/week-layout.ts for what both need. WeekDayColumn.vue reads the geometry and draws it.

D31 — Une famille tient trois agendas, et deux d'entre eux sont des projections

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: a family no longer has "its" calendar but three, each a real CalDAV collection created under the service account and shared with the Team:

Kind What it holds Who writes to it
events The timetable's own events A person, through routes/events.ts
tasks The deadline of every open task The projection, from list_items
meals Every planned meal The projection, from meal_plans

Why three collections and not three labels on one. A calendar is the unit Nextcloud already knows how to show, hide, colour and subscribe to. Making them three real ones means a phone shows tonight's dinner without this application being involved, Nextcloud Calendar can turn the deadlines off on its own, and the external subscriptions of D33 arrive as the same object rather than as a fourth mechanism. Tagging one collection with CATEGORIES would have bought none of that.

The database stays the truth for two of them. Events live nowhere but CalDAV (D26), because nothing here holds an event. A task and a meal are rows — with their own screens, their own history, their own notifications — so what goes into their calendars is a projection: written one way, never read back, rewritten whole on every change, and removed when the row stops deserving one. That is not the second truth D26 warns about; it is a derived view, and the direction is what makes the difference.

One predicate, not one branch per transition. calendar-projection.ts exposes a single project(family, kind, uid, draft | null, log). Every transition a task or a meal goes through — a deadline set, moved or cleared, a task ticked, renamed or reassigned, a slot emptied, a recipe renamed — reduces to "should this row be on a calendar, and as what". A branch per transition is how a projection ends up with a case nobody wrote.

All-day, always. A deadline is a day: list_items.due_date has no time. A meal has one, but only if the application invents it — and it would have to invent it in a timezone no family has told it about, the same gap reminder.ts already works around with a fixed UTC hour. An hour that is two hours off is worse on a grid than no hour at all, so meals sit in the all-day band with their meal named: Déjeuner : Gratin.

Best-effort, and it says so. The database write has already happened by the time the projection runs. A collection that refuses is not a reason to fail a gesture the user completed, so the failure is logged and the calendar stays one write behind until the row is touched again — or until the hourly pass of D32 catches up, which is what closes the gap this paragraph used to leave open.

A projected calendar is read-only to everyone but the projection, and that is enforced at three doors, because each one is reachable without the other two:

Door What stops it
The timetable grid A chip from a projected calendar opens a card naming where to edit it — Repas, or Listes — never the event form
PUT/DELETE /api/…/events/:uid The object is read from the events calendar first; absent → 404
Nextcloud Calendar, a phone, any CalDAV client The Team's share on those two collections carries no <o:read-write/>

Without the second, the first is a suggestion: the uid of a meal is its meal_plans row's, so a write through the events route did not update anything — it wrote a second object into the events calendar under the same uid. Measured: one meal edited from the grid came back as two entries with the same uid and different names, the meal plan untouched; and deleting it answered 200 while removing nothing, deleteCalendarObject treating the sharee's 404 as success.

Without the third, the first two are an app-level convention that Nextcloud Calendar walks straight past. Measured on a read-only share: a member's PUT, overwrite and DELETE all answer 404, PROPFIND still answers 207, and the owner keeps writing. Re-posting the share changes its level rather than adding a second one, so this is also how one is downgraded.

Read-only is what makes the whole arrangement honest. A calendar nobody but the projection can write to cannot diverge from the row it projects, which leaves the failed write as the only drift there is.

Families created before this keep a read-write share. No instance runs this application but the development one, and re-sharing needs a network call a migration must not make — the same trade D23 named. Deleting and recreating the family fixes it.

Reading spans every calendar; writing never does. A period request answers with the union, each event stamped with the id of the calendar it came from, and the calendars themselves travel with it so the legend can never describe a set the events did not come from. The hiding happens in the browser: filtering server-side would make toggling a calendar cost a round trip and a spinner, and would make a mutation answer with a period shaped by whatever the last read happened to ask for. Three REPORTs run in parallel, so the cost is one round trip, not three.

Which calendars are shown is a browser preference, per D27 — the same nature as the member filter, and stored the same way: per family, and only the deviation from the default. Two details it does not share:

  • What is hidden is stored, not what is shown. An absent entry means "no opinion yet", and the answer then comes from the defaults — so a family that gains a calendar does not gain an invisible one, and a change to the defaults reaches people who have already opened the timetable.
  • By kind, not by row id. A calendar recreated after a failed setup is a new row with the same purpose; a preference keyed on the id would silently come back.

Shown by default: events and tasks. A deadline is something to act on and belongs next to what else the day holds. A meal is already planned on its own screen, and fourteen of them a week bury everything else — anyone who wants them says so once.

Colour follows the same asymmetry. A week holds one deadline calendar and one meal calendar, so a shared colour is what says what a chip is. It holds any number of events, so there the useful thing is telling them apart — and the events calendar keeps the per-event hue week-layout.ts has always derived. The events calendar's colour is the family's, and is the only one a recolouring propagates to.

The home leaves out the projections, and nothing else. The reminders read the events calendar alone. The home already lists the tasks due today from SQLite and the meals have a screen of their own; folding their calendars in would say the same thing twice on the same page. The reminders skip them for a different reason — they would send a family three notifications about one day — and they skip a subscription too: nobody here planned what a feed holds, and a feed of a few hundred entries would notify a family about every one of them. Whether an external agenda should ever notify is an open question, not an oversight.

The rule is "nothing the page already shows under its own heading", not "the events calendar alone" — a distinction that cost a bug. The home was written as calendarByKind(EVENTS), and an external subscription (D33), which no other block of that page shows, was therefore invisible on it: visible on the grid, absent from « À venir », with nothing saying why. isProjection now carries the rule, so a fifth kind of calendar is read rather than silently dropped. What follows from reading several of them there:

  • The limit applies to their union. « À venir » keeps five events; they are ordered across every calendar before being cut, never five per calendar.
  • A calendar that fails no longer discards the others' answer. calendarUnreadable means "at least one did not answer", exactly as EventPeriod.unreadable does for a period, and the home warns above the list it still shows rather than replacing it. Which one failed is not named: the consequence is the same, and naming one would be wrong the moment a second fails too.
  • An event carries the colour of the agenda it came from, as it does on the grid — an external feed's event is not a family event, and nothing else on that page says so. The calendars therefore travel with the summary, as they do with a period (D26).
  • An event is judged on its end, not on its start. A fortnight of school holidays is one all-day event that began before today, and a home comparing start dates announced nothing at all for the whole fortnight. What carries no end at all is judged on its start, which is all there is to judge it on — and DTEND on an all-day event names the day after the last one, so the comparison is strict or holidays vanish on their final morning. The card says « En cours » rather than the date such an event began, which under « Les 30 prochains jours » would read as a bug.
  • Every subscription is read, not only those the grid shows. Hiding an agenda is a preference of the timetable, held in one browser (D27); the server does not know it, and the home is not that screen. The cost is bounded: the reads are parallel, so the home costs the slowest of them, and a family holds at most five subscriptions plus its events calendar (D33).

Found on the way: the home never showed any all-day event of the current day, its own calendar's included. Nextcloud 34 indexes an all-day VEVENT with no DTEND as a zero-length occurrence and keeps an object only while its last occurrence is strictly after the range starts, so asking from "now" answered 207 with the day's all-day events missing and nothing to say so. The home reads from the day before and filters the past out itself. The measurement is in the nextcloud-exapp-dev skill's references/icalendar.md.

Found on the way: deleting a family used to delete its calendar as the person asking. A sharee's DELETE on a shared collection unshares it rather than removing it, and the sharee's path answers 404 — which deleteCalendar accepts as success. The collection stayed in the service account for ever, silently. It is now deleted as the owner, which is what D24 made it.

Migration 8 creates family_calendars, moves the existing calendar_slug into it as the events row, and drops the column. The two other calendars are not backfilled: creating a CalDAV collection is a network call, and a migration must never make one. They are provisioned on demand, the first time something is written to them — which is also the recovery path for a family whose creation half-failed.

✅ Applied. Covered end to end: the three collections exist under the service account, a deadline reaches its calendar and leaves it when the task is ticked, and a planned meal appears on the grid only once its calendar is turned on.

Revised in #17 — a deadline names its list. A period read (getPeriodEvents, getWeekEvents) stamps listId on each event of the tasks calendar, found from the item its uid names — and only when that list belongs to the family whose calendar it is. The read-only card then opens /lists/:listId?item=… on that task instead of the lists screen in general. It is done on the period reads only: the hourly pass (D32) uses the same reader to compare calendars with rows, and an extra field there would be one more thing to compare.

D32 — An hourly pass makes the calendars agree with the rows again

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: an hourly pass, in the container that already sends the reminders, walks every family and corrects two drifts that share one shape — a family_calendars row that no longer matches what is in Nextcloud. The same pass runs on demand from the administration screen.

Drift Where it comes from What the pass does
The collection is gone The service account was deleted; Nextcloud takes every collection with it (D24) MKCALENDAR, re-share to the Team, keep the row
The objects are stale A projection write failed; it was best-effort and nothing caught up (D31) Rewrite or remove the objects that disagree

They are one problem, not two. They were planned apart — one as "reconcile the projections", the other as "react to an account deletion" — and both reduce to "the row says one thing and CalDAV says another". They also share their traversal — every family, every calendar — and their trigger. Writing them apart would have meant two passes over the same families an hour apart.

Recreating the account is only half the recovery, and the half that was already there was the useless one. Before this, an administrator could put the account back and the screen would say it was healthy while every family still reached nothing: the rows kept slugs naming collections the new account did not own, and ensureCalendar provisions on a missing row, never a stale one. Adoption therefore runs the pass, and answers with what it repaired — the only sign an administrator gets that anything had been lost.

What comes back and what does not. The tasks and meals calendars are projections, so refilling them is the pass doing its ordinary job. The events calendar is the only store there is (D26): its collection is recreated, empty, and what it held is gone with the account. The screen says so rather than implying a full recovery.

A 404 is an answer; anything else is not. collectionExists returns false on 404 and throws on every other status, so nothing is ever recreated on a question that went unanswered — a Nextcloud that is briefly down would otherwise have the pass MKCALENDAR over collections that are very much there.

The service account is probed once per pass, through DAV. Reading an account through the Provisioning API needs an administrator to ask, and a background pass acts as nobody. A DAV principal is readable by the account itself, so that is the probe. Without it, a deleted account means three failed MKCALENDARs per family per hour and a log that never names the one thing to fix.

Measured, and it contradicted the obvious guess: acting as a deleted account answers 401, not 404. A person asking over HTTP Basic about a name nobody answers to gets 404, but AppAPIAuth cannot resolve the account it is being told to act as, so the request fails before routing. Read as a transport failure, that made the pass log a stack trace where it should have been naming a deleted service account. A wrong APP_SECRET answers identically and calls for the same refusal, so both are read as "cannot act as this account". Recorded in references/appapi-auth.md.

A stray uid is resolved against its row, not deleted on sight. A deadline pushed past the window and an item that no longer exists look identical from inside the period, and deleting on absence would take a task off the calendar for good because someone moved it a month out. The window is seven days back and twenty-eight forward: backwards because an overdue task is what the home is most likely to be showing, and bounded because every extra day is paid for on every pass.

A calendar that could not be read is left alone. An empty answer from a collection nobody reached is not an empty calendar, and acting on it would delete everything the family has. eventsBetween already separates the two — that is what unreadable is for.

The comparison is of what a projection writes, read back the way the parser gives it. The parser trims a summary and drops an empty description, so comparing raw values would report a difference on every pass and rewrite the same object for ever.

What the pass does not do. It does not stop an administrator deleting the service account: AppAPI offers no hook to refuse, and there is no call of ours to guard. The single point of failure D24 accepted is still there. What changed is that its disappearance is now detected, named in the log, and undone by one button rather than being silent and final.

✅ Applied. Verified on the instance, not reasoned about: the account deleted and the pass answering {ran: false} with the cause in the log; the account recreated and adopted, giving three collections back, the task deadline refilled, and the projected calendars still refusing a member's write; a row changed behind the calendar's back and rewritten; a row deleted and its object removed; two consecutive passes on a healthy instance reporting nothing. The end-to-end scenario deletes the account for real and fails when the repair is taken out of adoption — checked by sabotage.

D33 — An external subscription is a family calendar, owned by the service account and read as it

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: the external calendar is a fourth family_calendars kind, subscription. A family may hold several; nothing built into the app provisions one — it exists only when a member adds it. It is created as a Nextcloud calendar subscription (MKCOL with a cs:subscribed resourcetype and a cs:source, not MKCALENDAR), owned by the service account like the other three (D24), but not shared with the Team.

Why not shared: it cannot be. A POST with an <o:share> body — the mechanism D31's three built-in calendars use — answers 501 Sabre\DAV\Exception\NotImplemented on a subscription. Measured on Nextcloud 34: there is no plugin willing to handle a share POST on this node type at all. This is the fact the whole design turns on.

So it is read as the service account, regardless of who asks. CalendarRef carries an optional readerId that overrides the requesting member for exactly this kind — there is no share to read a subscription through, so a member's own DAV principal 404s on it. The three built-in calendars are unaffected: they keep being read as the requesting member, through the Team share, which is what exercises that share and is load-bearing (D31).

Every DAV request now sends X-NC-CalDAV-Webcal-Caching: On. Without it, REPORT on a subscription answers 207 with an empty <d:multistatus/> — not an error, and indistinguishable from "nothing in this range" from inside eventsBetween, which would read it as an empty calendar rather than unreadable. The header only affects nodes under a subscription's path (measured: a regular calendar's PROPFIND/REPORT is unchanged), so it is sent unconditionally rather than decided per call.

No refreshrate is ever sent. RefreshWebcalService::refreshSubscription skips the fetch while lastmodified + refreshrate > now, and lastmodified is stamped at creation — so a subscription created with one waits out the whole interval before its first real fetch, its background job running on schedule and doing nothing every time, nothing logged. Omitting it entirely skips the guard outright; Nextcloud paces the refresh itself through dav.calendarSubscriptionRefreshRate (default P1D). A subscription only fills on the next run of its own RefreshWebcalJob, registered at creation and never before — never at MKCOL time.

It is read-only, and for once nothing new has to enforce it. D31 needed three doors because the projected calendars are writable by their owner. A subscription is writable by nobody: Nextcloud itself refuses (there is no share to grant), and writableCalendarOf already returns the events calendar and nothing else — every other kind, subscription included, is refused by the same check. The grid's refusal card gained a wording variant (TimetableProjectedDialog) naming it an external calendar rather than implying, as the projected-calendar wording does, that the family planned it elsewhere in this app.

It is not a projection, and reconcile.ts's SOURCES must never include one. A projection compares what a calendar holds against rows this app authored and deletes what disagrees; a subscription's objects were authored by a feed nobody here wrote, and adding it to SOURCES would delete every object in it on the next hourly pass. The D32 repair pass instead walks findSubscriptions directly and recreates a missing collection as a subscription, from the row's own stored source — BUILT_IN_CALENDAR_KINDS (not CALENDAR_KINDS) is what creation, renaming and the built-in half of repair walk, so a subscription is never mistaken for a fourth built-in and never gets MKCALENDAR'd over.

What is lost, and what the interface says about it. A subscription is visible in this application alone — not in Nextcloud Calendar, not on a phone, because there is no share to carry it there. The Agendas panel says so, and that subscribing to the same URL directly in Nextcloud Calendar recovers it. This is a real cost of the arrangement chosen over the only alternative that would have avoided it — one subscription per member, in each member's own calendar home — which was rejected: it would multiply the number of CalDAV collections by family × members, turn every join and departure into a provisioning act that can half fail, and make the D32 repair pass walk members as well as families. That is the class of per-member ownership D21–D24 spent four steps escaping; reintroducing it for one feature is the wrong trade.

Migration 9 recreates family_calendars: the table-level UNIQUE(family_id, kind) becomes a partial unique index over the three built-in kinds only (idx_family_calendars_builtin … WHERE kind IN ('events', 'tasks', 'meals')), so a family may hold several subscriptions while a second events calendar is still refused. SQLite only matches an upsert's conflict target against a partial index when the WHERE clause is repeated verbatim in the ON CONFLICT — insertCalendar's upsert had to be updated to say so, or it throws "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint" on every call. Two columns are added: source (the feed URL, null for a built-in) and created_by (who added it, null for a built-in). Migration 8 anticipated exactly this — its own comment named the constraint that would have to give way.

Instance-wide caps are shared with calendars, on the one principal that owns them all. rateLimitCalendarCreation/rateLimitPeriodCalendarCreation (default 10 per hour) and maximumCalendarsSubscriptions (default 30, counting owned calendars and subscriptions together) both key on MKCOL's owning principal — the service account since D24. Three collections are already spent per family before any subscription, so the default cap tops out around ten families before this feature even runs. Not new in 3.1, but 3.1 is the first thing that lets a user spend what is left of it, so an app-level cap (MAX_SUBSCRIPTIONS_PER_FAMILY = 5 in services/subscription.ts) bounds what one family may take from every other family sharing that account.

✅ Applied. Verified on the instance: a subscription created via the panel appears in oc_calendarsubscriptions owned by the service account, with no refreshrate; forcing its RefreshWebcalJob fills it; the event renders on the grid in the subscription's colour; clicking it opens the external-calendar refusal card, not the event form; removing it deletes both the app's row and the Nextcloud subscription (checked in the database, not only the screen); deleting the family takes any subscription with it. See calendar-subscription.spec.ts and "Calendar subscriptions" in the nextcloud-exapp-dev skill's references/ocs-apis.md for the full set of measurements this decision rests on.

D34 — Any member may add or remove a subscription

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: adding or removing a family calendar subscription needs membership and nothing more — the same right that lets any member create a shopping list or a recipe. The server enforces exactly that: familyForUser (which resolveFamily's preHandler already runs for every family route) is the only check addSubscription/removeSubscriptionFor make. There is no canManage gate, and none is added.

Why not canManage (the Circles level that already gates renaming the family, invites and deletion). A subscription is configuration, but it is configuration of the same weight as everything else a member already does unprompted in this app — nothing here has ever asked one member to approve another's ordinary action, and gating this one thing behind a level would be the first place an ordinary member hits a wall configuring their own family. The instance-wide resource a subscription spends (D33's per-family cap) is what protects the service account, not a reason to protect members from each other.

Removing one takes the same right as adding one. A subscription someone else added is still family configuration, not that member's property — a member who adds an unwanted feed is undone by any other member removing it, the same as a bad shopping-list item.

canManage is untouched. This decision does not extend its reach or narrow it; it stays exactly the gate it already was on renaming, inviting, revoking and deleting the family.

✅ Applied. services/subscription.ts resolves the family through familyForUser and runs no further check; routes/calendars.ts's two routes require nothing but membership. Covered by services/subscription.test.ts (any member may add or remove; a non-member is refused with 404, never 403, per the existing rule that a refusal must never confirm a resource exists) and exercised end to end in calendar-subscription.spec.ts.

D35 — Offline is a separate read-only page a service worker synthesises, not a mode the app runs in

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: with no network, opening the application shows a self-contained HTML page — listing the active family's lists and tasks, checked state included — served by a service worker at /apps/app_api/. It has no relation to the Vue bundle beyond reading the same Cache Storage entries; there is no "offline mode" inside the running application, only a banner and a slower poll when a request the app already made fails to reach the server.

Why a separate page, and not the app running against a cache. Three measured facts, none negotiable:

  • The AppAPI proxy stamps Content-Security-Policy: default-src 'none' on every response it relays, the worker script included. A service worker inherits the CSP of its own script response, so fetch() issued from inside the worker fails outright — online or not. The one exception is navigation preload: the browser makes that request on the navigation's behalf, not the worker, so it is the only network access the worker ever has. This rules out a worker that intercepts API calls and answers from a cache the way a conventional PWA does — there is nothing here for it to fetch() with.
  • The embedded page pulls about twenty assets from outside /apps/app_api/ — Nextcloud's own JS bundles, the active theme's stylesheet — none of them reachable from this worker's scope, and the theme stylesheet is private, max-age=86400, so nothing here can promise it stays cached. The Nextcloud shell is therefore not something this ExApp can offer offline; only its own content can.
  • Files already registers a service worker at scope / (/apps/files/preview-service-worker.js). Registering at /apps/app_api/ instead of / was required for coexistence, not a preference — the longest matching scope wins per URL, and both registrations stand as long as neither claims the other's ground.

Why writing stays out of scope. D26 makes the server authoritative on client state: every mutation returns the full snapshot and the client replaces its state with it rather than computing an optimistic result of its own. A write queued while offline and replayed later is the client deciding what happened in the meantime, which is exactly what D26 refuses. Conflict detection is the piece that would have to exist first; D40 now provides it for list items, and the write queue itself is still an open issue (#15).

What fills the cache, and the race that shaped it. services/offline-snapshot.ts holds three kinds of key — family meta, the list index, one entry per list snapshot — written from the running application, under its ordinary CSP, never from the worker. Three independent call sites write the list index: stores/lists.ts's loadLists (a navigation to "Listes"), the same store's createList (so a list created without ever leaving the dialog is not missing from the next offline read), and a background prefetch that fires once per family per session so a list nobody happened to open is still there. The first version of that prefetch called listsApi.ofFamily on its own, independently of the live store — and lost a real race: fired at the moment a family becomes active, before any list exists, its own fetch could resolve after a normal navigation's fetch had already written the correct index, overwriting good data with the empty snapshot it had captured earlier. The fix was not a lock but removing the second writer: the prefetch now calls useListsStore().loadLists(familyId) itself, so there is one fetch of the index, not two competing ones. The equivalent race for a single list's snapshot — a slow prefetch read landing after a fresher poll or write — is closed differently, because a per-list snapshot does carry an unambiguous freshness token already: saveListSnapshot compares revision against what is cached and drops anything not newer, the same rule stores/lists.ts's own applySnapshot already enforces against a stale poll response.

PUBLIC in info.xml, not USER. passesExAppProxyRouteAccessLevelCheck answers a USER route with NotFoundResponse once the session has lapsed — indistinguishable from a route that does not exist. The worker's periodic update check and the manifest fetch carry no session by design (a service worker registration outlives the tab that made it), so both /sw.js and /manifest.webmanifest are PUBLIC; neither holds anything a logged-out request should not see.

Service-Worker-Allowed: /, not /apps/app_api/. The header raises the ceiling a worker registered from this script is allowed to claim; it does not itself set the scope. main.ts still registers at /apps/app_api/, computed from the current URL's path rather than assumed, because the mount path carries /index.php on some instances and not others — the same reasoning routerBase() already applies to the router's own base. A ceiling of / is what lets that computed scope land correctly regardless of which form the instance uses, without hard-coding either.

✅ Applied. routes/static.ts serves /sw.js and /manifest.webmanifest; ex_app/src/sw.ts (built separately from the app bundle by vite.config.sw.ts, since it is an IIFE with no @nextcloud/vue, no CSS, and its own tsconfig.sw.json — a ServiceWorkerGlobalScope and a Window cannot share one lib) intercepts navigations under /apps/app_api/embedded/organisateur_familial/ and returns navigation preload's response, or the offline shell (offline/shell.html, inlined at build time with offline/constants.ts's cache-key names substituted in) when preload rejects or resolves empty. services/offline-worker.ts registers it on load, after calling forgetIfOtherUser — Cache Storage outlives a logout, and a family's lists are not for the next person to sign in on a shared browser to read. services/connectivity.ts-backed banner, slowed poll and refused writes are the live application's only acknowledgement that the network is down; stores/connectivity.ts is fed from api/client.ts, which reports a failure only on ApiError.status === 0 — the one status meaning a request never reached the server, since navigator.onLine was measured true under a simulated offline network and is not used anywhere. Covered by services/offline-snapshot.test.ts, stores/connectivity.test.ts, api/client.test.ts, the offline assertions added to stores/lists.test.ts, and exercised end to end in e2e/tests/offline.spec.ts.

D36 — An export carries what this application owns, and identities only where they do work

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: GET /api/families/:id/export answers with one JSON document — the family's lists (items in position order), recipes and meal plans. POST /api/families/:id/import reads the same shape back in. Nothing else travels: no calendar events, no external subscriptions, no members, no notification preferences, no recipe photo bytes.

What is out, and why. Calendar events already have an export path that is not this one — Nextcloud Calendar exports its own ICS — superseded by D37, which brings the events calendar and the external subscriptions into the file — and the two projected calendars (D31) need no export of their own: tasks and meals rebuild themselves from the rows that are exported, the same way a repair rebuilds them after the calendar itself goes missing (D32). A recipe photo is capped at 5 MB (services/photo.ts); base64-encoding forty of them turns a file meant to be mailed into a 250 MB document nobody opens by hand. The export instead records hadPhoto: true — whether one was left behind, not where — and the import route counts how many recipes came back without theirs.

Import appends. It never replaces, merges, or overwrites. Every imported row gets a fresh randomUUID(), and running the same file twice gives two copies — the honest outcome of "add what is in this file". An import that updated existing rows would need to tell "this changed since the export" apart from "this is what the export said", which is conflict detection — D40 now provides it for list items, and an import built on it is still to come. A family created only to receive an import already holds the two empty lists every new family starts with (D41); the import adds beside them like everywhere else.

No identity travels except assignee, and only when the target family can use it. created_by is NOT NULL on every table the export touches, and no author ever crossed the wire — the importer authors every row, whoever created it in the family it came from. checked survives; checked_by and checked_at do not, for the same reason. assignee is the one exception: it drives notifications and the timetable's member filter, so it is worth keeping — but only when it names a current member of the target family, checked once against membersOf before the transaction starts, otherwise dropped and counted in the report as droppedAssignees.

A meal whose slot is already taken is skipped, never overwritten. UNIQUE(family_id, date, meal_type) (migration 1) means an import racing a plan someone made since the export has exactly two rows contending for one slot; the existing one wins, and the count lands in skippedMeals. A meal naming a recipe the import cannot place — because that source recipe was itself skipped, or the id is simply wrong in a hand-edited file — imports with recipeId: null rather than throwing: a malformed reference is not grounds to fail rows around it that were fine.

The whole write is one SQLite transaction, then one best-effort reconcileFamily() — never a projection call per imported row. A hundred imported tasks firing a hundred CalDAV PUTs would hit the rate limits D19 exists to avoid, and D32 already wrote the pass for exactly this shape of problem: "make the calendars agree with the rows again", run over a bounded window. Reconciliation runs after the transaction commits and swallows its own failure — an administrator who has let the service account lapse still gets their import, and the hourly pass catches the calendars up on its own schedule. The same reasoning applies to notifications: one notifyFamily call per import, not one per row (D18).

Any member may export or import. Same flat model as D34: everything the file carries is already readable by every member through the running application, and an import is an ordinary write like any other a member makes. No administrator gate, no owner check.

The download is built in the browser, not served with Content-Disposition. The export route answers ordinary JSON; stores/transfer.ts turns it into a Blob, an object URL, and a synthetic <a download> click — nothing here depends on the AppAPI proxy relaying a header that has never been measured. The anchor is appended to the document before the click and removed after: a detached one's download attribute did not trigger a download at all in Chromium, measured against the e2e round trip before this line existed.

No info.xml change. ^/api/.* already declares GET and POST; routes.test.ts confirms it rather than assuming it.

✅ Applied. routes/transfer.ts (GET .../export, POST .../import, the latter with its own bodyLimit — Fastify's 1 MiB default 413s a real family export otherwise), services/transfer-export.ts, services/transfer-import.ts, contracts/transfer.ts. db/meals.ts gained findAllMealsByFamily — unlike findMealsBetween, unbounded on purpose, since an export that silently dropped meals outside the timetable's current week would be wrong. db/list-items.ts gained markItemsChecked, deliberately not a new addItems capability: ticking something off is a gesture with an author, bulk-loading during an import is not. routes/recipe-schema.ts and routes/list-schema.ts hold the field bounds both the ordinary routes and the import route validate against, so a bound declared once cannot drift into two answers for the same field. The settings hub gained a fourth register, Données, next to Family/Notifications/Administration. Covered by services/transfer-export.test.ts, services/transfer-import.test.ts, stores/transfer.test.ts, and exercised end to end in e2e/tests/transfer.spec.ts.

D37 — The calendar travels as stored objects, and its import is replayable

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: the export document gained a calendar section — the events calendar's objects, the definition of every external subscription, the source family's member list, whether the calendar could be read, and how many objects a cap left behind. EXPORT_VERSION is 2, and a v1 file is refused rather than upgraded: the application is not in production, and z.literal already answers « version de fichier non reconnue » without a line of migration code.

Only the events calendar has content of its own. tasks and meals are projections (D31) of rows the same file already carries; exporting them would duplicate the lists and the meal plan, and importing them would write into two calendars that are read-only at three doors. An external subscription has no content anyone here authored — its URL travels and the feed re-subscribes (D33).

Raw VCALENDAR objects, never a serialised CalendarEvent. This application models a subset of iCalendar (D15), and a round trip through that model drops everything outside it — VALARM, ATTENDEE, a named timezone, an X- property. parseObject already keeps a series' exceptions as raw text for exactly that reason; D37 extends the treatment to the whole object. services/ical/rewrite.ts edits two properties line by line and carries every other line through untouched.

Reading needs a REPORT without <c:expand>. reportCalendarObjects asks the server to expand, which is right for a grid and ruinous for an export — measured: seven occurrences in, one out. fetchCalendarObjects sends the same calendar-query with no <c:expand>, no <c:limit-recurrence-set> and no <c:time-range>: a time range keeps an object only while one of its occurrences falls inside it, so any window at all silently drops something, and what a family carries away should not depend on a bound nobody chose.

An imported uid is derived, not minted — the one place the document departs from D36's "import twice, get two copies". derivedUid(targetFamily, sourceUid) is a truncated sha256 shaped as a UUID. These writes sit outside the SQLite transaction, one CalDAV PUT each, with no projection behind them to reconcile — so unlike every row D36 imports, nothing would ever catch up what failed halfway. Deriving buys three things: replaying the file finishes an interrupted import instead of doubling it; an import can never overwrite an event the target family authored, since a uid written here is a randomUUID() and cannot equal the hash; and two target families reading one file do not collide. Four writes in flight, and a refused object is counted, never thrown — one event the server dislikes must not take the other four hundred with it.

Measured on the dev instance, through the AppAPI proxy — the numbers the bounds are set from, none of them estimated:

A minimal event, JSON-escaped 287 B
A series with three exceptions and two EXDATE 1 153 B
One authored in Nextcloud Calendar, with VTIMEZONE ~2 500 B
A 3 500-object export 839 KB, about 1 s
Importing 500 objects 8.8 s, none refused
Importing 3 000 objects 48 s, none refused
Body the proxy relays unharmed at least 24 MiB
Where the 413 comes from at 30 MiB our own bodyLimit, not the proxy

So the transport is not the constraint and MAX_IMPORT_BYTES moved from 5 to 25 MiB. The constraint is duration, and it is what sets the cap: writing is ~60 objects a second where reading a whole collection is one REPORT, so MAX_EXPORT_OBJECTS is 3 000 — the 48 seconds measured above, on a synchronous request. The 20 000 the size arithmetic first gave would have been five minutes, long enough for a gateway to cut the request off before the report came back. No real family approaches either figure, and the replay property covers the case where one does.

A category is dropped only when it was a member there and is not one here. CATEGORIES carries the members of an event (D14) and whatever label someone typed in Nextcloud Calendar, with nothing in the value to tell them apart. So the file carries the source family's members — one more identity than D36 allowed, and it exists solely to make this judgement possible; it is never written as an author. The naive rule, "keep what names a member here", deletes « Vacances » in silence.

A subscription's URL is exported, and the screen says so before the click. A feed URL may carry a credential, which is why the Agendas panel shows only its host (D33). Putting it in a file people mail each other is a real risk, taken deliberately: a subscription without its URL is one the receiving family recreates by hand, which is the same as not exporting it. Re-subscribing skips a source already present, so it is idempotent with no derivation, and the per-family cap of five refuses the rest into skippedSubscriptions.

An unreadable calendar says so; it does not export as empty. unreadable: true with no objects, and the export is not refused — a family whose service account has lapsed must still be able to carry its lists away. A file showing an empty calendar without saying so is the mistake D26 names: empty means unknown, not free.

The export applies the import's own bounds — the object count and the per-object length — and reports what it left in omitted. A file produced here must always be readable here, and one oversized object would otherwise fail the whole document, lists and recipes included. What the cap drops is never a series: an object carrying an RRULE is a habit the family keeps, so the budget goes to those first and fills the rest with the most recent one-off events. Old, dated history falls off the end; nothing still running does.

One subscription schema for both writers. routes/calendar-schema.ts now holds the name, colour and URL rules that routes/calendars.ts and routes/transfer.ts both validate against, the way list-schema.ts and recipe-schema.ts already did. The scheme allow-list is the part that must not be copied: z.string().url() — the obvious thing to reach for on an import — accepts javascript: and file:, and Nextcloud fetches feeds server-side.

No info.xml change, no migration. ^/api/.* already declares GET and POST, and nothing here adds a table or a column — the objects live in CalDAV, the subscriptions go through provisionSubscription.

✅ Applied. dav/client.ts (fetchCalendarObjects), services/ical/text.ts (folding, unfolding and escaping, shared rather than duplicated between build.ts and parse.ts), services/ical/rewrite.ts, services/transfer-export.ts, services/transfer-import.ts, contracts/transfer.ts, routes/transfer.ts, routes/calendar-schema.ts, stores/transfer.ts and views/DataSettings.vue. Covered by ical/rewrite.test.ts, routes/calendar-schema.test.ts, the two transfer service tests, stores/transfer.test.ts, and end to end in e2e/tests/transfer.spec.ts — where a recurring event makes the round trip, because a one-off would pass against an implementation that flattened every series.

D38 — The interface follows the Nextcloud account's language, from English sources and a French catalogue

Every sentence a member reads is written in English in the code, inside a t(domain, '…') or n(domain, '…', '…', count) call, and translated through a catalogue. The application has no language setting of its own: it reads the one the account already carries, and follows it after a reload. An unsupported language falls back to English, which is the source and therefore always complete.

Sources are extracted into translationfiles/templates/organisateur_familial.pot and translated in translationfiles/fr/organisateur_familial.po with the pinned Nextcloud translationtool. make l10n-build generates ex_app/l10n/{fr.js,fr.json}, which are committed; make l10n-check regenerates into a temporary directory, compares, and refuses a source with no French, an empty or fuzzy entry, a counted message missing a form, or a translation that lost a {placeholder}. It needs Docker, so it is a CI job of its own on the host runner.

One catalogue serves three consumers, which is why it is generated once rather than per target: the browser bundle, through the script Nextcloud injects into the page; the Node backend, which reads fr.json from the container; and Nextcloud itself, which renders a notification in the recipient's language.

The formatting locale is a separate setting from the language. An English interface with French dates is a valid pairing, and neither follows the other: dates go through Intl with getCanonicalLocale(), and a locale Intl refuses falls back to the browser's rather than taking a screen down. A bare day is still formatted in UTC — it has no time, so no timezone to shift it (D31's rule, unchanged). The week still starts on Monday.

What a family typed is never translated. Names of families, lists, items, recipes, events and calendars travel as parameters — a catalogue holding one would translate their own words back at them. Identifiers, French route paths, enum values, cache keys and CalDAV UIDs are not translated either: they are not read, they are matched.

A count is a sentence, not a number and a noun. French agrees at 2 where English agrees at 1, and a phrase built by concatenation cannot be reordered by a translation nor made to agree; every counted message uses n(…) with a complete sentence per form.

Four mechanisms make this work, and each was a defect before it was a rule:

  1. The bundle is emitted as a real IIFE. As top-level code, every binding the minifier produced became global — Vue's uid counter, minified to let OC = 0, shadowed Nextcloud's OC for every script that ran afterwards, so OC.L10N.register threw in this application's catalogue and in four core apps'.
  2. The application waits for the catalogue before mounting. Nextcloud injects that script after this bundle, so a first render would read an empty registry. English skips the wait: no catalogue is installed for the source language.
  3. A label held in a constant is a getter. Module evaluation happens before the catalogue registers, so a value computed there would stay English for the session.
  4. The frontend states the account's language on its own requests. AppAPI fills Accept-Language in from the account only when a request carries none, and a browser always sends one — otherwise the browser's language would decide what the API answers in, whatever the account says.

The API answers in the caller's language while keeping its contract: the same status, the same path on an issue, and warnings still a string[] on the wire. Refusals carry an English source and its parameters from where they are raised to the HTTP boundary, which is the first place that knows who is asking — two members can be mid-request in two languages at once. Zod's own messages are not translated: they are built at parse time and cannot be extracted, so an issue is described from its code and its bound, and a schema that wrote its own reason keeps it.

Notifications are not translated before sending. AppAPI's notifier loads this application's catalogue in the recipient's language, so the stored subject is an English source with rich parameters; translating early would freeze the author's language into every recipient's copy, and looking each member's language up would cost an OCS call per member.

The offline page (D35) carries its catalogue: it has no network and no subresource, so the French it needs is compiled into it at build time, cut down to the sentences it uses. Which language it reads comes from the snapshot — a cache written before this existed has no language and is read in French, the only one the application then had; no cache at all reads English. The installable manifest takes its language from a URL parameter, being PUBLIC: there is no session to read an account language from, and a shared URL would serve one account the other's short name from a cache.

D39 — A member's colour lives in SQLite as a palette index, is materialised on the member read, and is derived at render — never written into the rows it colours

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: every member of a family carries a colour, seen by the whole family on every device, and read at a glance where an assignment exists:

Where What takes the colour
The timetable — day, day by member, week, month A chip of an event whose CATEGORIES names exactly one member (D14); the day-by-member view also wears a dot in the column header
A task list The assignee chip of a task's row
The home, « À venir » An event of exactly one member, as on the grid
The home, « À faire aujourd'hui » The assignee of a task due today

An event naming several members takes the family's colour — that is what a family event is, and it must read as « the family » at a glance. An event naming nobody keeps the hue week-layout.ts has always derived. Meals and external subscriptions are never coloured: nobody here assigned anything about them.

Why SQLite and not the account or the browser. The colour is seen by the whole family, so it follows the D27 test to its family side: not the AppAPI Preferences API (a person's choice does not travel to their relatives' screens) and not localStorage (one device's choice must not decide another's). It is family data, like families.color.

CREATE TABLE member_colors (
    family_id   TEXT NOT NULL REFERENCES families(id) ON DELETE CASCADE,
    member_key  TEXT NOT NULL,
    color_index INTEGER NOT NULL,
    PRIMARY KEY (family_id, member_key)
);

The key is the opaque member key of D14 — a user id today, not the Circles membership id, which changes when someone leaves and comes back while every event attributed to them still names the old one. A row does not assert that a member exists: the Circle is authoritative on membership (D4), and the row is only ever read next to a member list that genuinely loaded — a key no member carries any more is inert, never authoritative (D27).

An index, not a colour. The palette is a display convention of the frontend; storing an index keeps a re-tuning of the palette for contrast from touching a single row. The palette is bounded to 8 — the official @nextcloud/vue colours (Purple, Feldspar, Gold, Olivine, Acapulco, Boston Blue, Mariner, Blue Violet) — each darkened until white text clears a 4.7 contrast, which is what the timetable chips already assume, so one pair serves the light and the dark theme alike. The indices are never renumbered; the values may be re-tuned (styles/tokens.css).

Attribution is materialised on the member read, in getMembers — the one funnel every member list goes through, already behind the cache of D19. A member without a row receives the first free colour of the palette, so every arrival is covered — an invitation, a family created, someone added from Nextcloud itself — without a hook in any of those flows and without the backfill a migration would otherwise owe. The write happens once per member and per family; it is a write in a read route, bounded by that cache. Past the eighth member the fallback is a stable hash of the key modulo 8: a collision is then inevitable, a flickering one is not.

The correction belongs to any member — the same openness as a subscription (D34): the family is a unit of trust, and a child without an account cannot choose their own colour. It lives in the family settings' Members panel: a dot per row opens the eight swatches, the colours another member already wears are disabled, and the choice lands on PUT /api/families/:id/members/:memberId/color — 404 for a memberId nobody listed (never 403, which would confirm a membership), 409 when the colour is taken, and the answer is the whole member list, which the caller replaces its state with (D26).

Deriving rather than writing is the whole safety. The colour never enters the iCalendar object: a phone or Nextcloud Calendar would ignore or misrender it, and changing a member's colour would have to rewrite every event. It never enters an export (D36) — the import re-assigns as an arrival does. It is not on the offline page (D35) — that page reads Cache Storage, which holds no member list. And the projections are untouched: calendar-projection.ts is exactly what it was, because the deadline of an assigned task is coloured by the same single lookup every other chip uses.

The home is one bounded snapshot (D26): the summary route reads the members through getMembers — behind the D19 cache — and the snapshot leaves with the colours: each task with the index of its assignee, and the whole key → index map for the event list, so the coming-up card resolves an event exactly as the grid does — the two screens were once caught disagreeing by the functional review, purple here and the family's blue there. A refused member read colours nothing and fails nothing: the task stays colourless and the events keep the colour of their agenda, because a colour is additive and never worth a blank home over. The Lists view loads the members once per family through the same store; the timetable already held them for its filter.

✅ Applied. Migration 10 creates the table; the correction route, the settings panel palette, the chip colouring on the grid, the list rows, the home and the day-by-member column dots are covered by e2e/tests/member-colors.spec.ts.

D40 — A list item change is compared field by field against what its editor started from

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: PUT /api/items/:id takes an optional base — for each field the request changes, the value the editor had when it opened. A field named in base is written only if its current value still equals it, or already equals the value being written. One field in conflict refuses the whole change with a 409 and a generic translated message; nothing is written. A field absent from base is written unconditionally, as before.

Why per field, and not a counter. The list already carries a revision, but it moves on every tick: an item dialog open during a shopping trip would be refused almost every time. A counter per item (a migration) still refuses a rename because someone else changed the due date, and cannot say what moved. Comparing values needs no column, lets two members touching different fields never meet, treats two members making the same correction as agreeing, and knows exactly which field is contested. It is also the building block an import that updates rather than appends (D36) and an offline write queue (D35, #15) need: both are "apply this change if the row is still what I think it is".

What is never conditional.

  • checked. A tick sends the state wanted, not a change to it; it is idempotent and never refused. base does not accept the key.
  • A request with no base — every request from a bundle loaded before this decision. It writes as it always did, so deploying needs no coordinated reload.
  • A reorder. It sends the whole order, which can predate an insertion by construction. It is never refused: the last order wins, and reorderItems rewrites every position inside its transaction — the ids received that still exist, deduplicated, then the items the sender did not know about in their current relative order — so a stale order can no longer produce two rows on one position.
  • Deleting an item someone is editing, and clearing checked items. The deletion goes through; it is the editor's dialog that reports it (below).

The comparison lives in the write's transaction, not in the service. patchItem reads the item before awaiting the Team check (familyForUser), and a write landing during that await is exactly the conflict being looked for. updateItem in db/list-items.ts reads the row again, compares and writes inside one better-sqlite3 transaction; being synchronous, nothing interleaves. It answers written, conflict (with the fields) or gone, and the service turns the last two into a 409 and a 404 — projecting and notifying nothing.

The 409 carries no field list. On any failed write the store already reads the list again (write() → resync()); the dialog compares its base with that fresh item to decide what to show. The snapshot is the authoritative account of what happened meanwhile, and nothing branches on the status code (D26). An empty text and null compare equal on both sides, and the editor compares a stored value as its form would hand it back — a priority of 0 or an empty quantity, which an import can store and the form can only show as "none", is not an edit the member made. The dialog keeps the list kind it opened with until it closes: the parent's falls back to shopping once the open list is deleted, and switching would discard what was typed.

What the editor does (ItemDialog.vue, services/item-changes.ts). It keeps the item it opened on as its base and sends only the fields that differ from it, each with its base value — nothing changed, nothing sent. After a refusal, per field of the fresh item: changed only by the other side → taken silently; changed by both, differently → the member's entry stays and the field is named with the other side's value, the base moves to the fresh item, so saving again overwrites knowingly while a third change meanwhile is still caught; « Use their version » puts theirs back. An item gone from the fresh list is named as deleted, and « Recreate » adds a new item from what was typed — the other member's deletion is not undone under the old identity. The notice says what changed, not who: list_items records no last author per field, and a last author per row would name the wrong person as often as not.

Scope. List items only. Recipes (#18) take the same mechanism; calendar events (#19) need a different one, CalDAV's own ETag and If-Match, since the other writer may be Nextcloud Calendar or a phone.

✅ Applied. routes/list-schema.ts (itemUpdateBody), db/list-items.ts (updateItem, reorderItems), services/list.ts (Conflict), components/ItemDialog.vue and ItemRefusal.vue, services/item-changes.ts. Covered by db/list-items.test.ts, services/list.test.ts, routes/list-schema.test.ts, services/item-changes.test.ts, and end to end by e2e/tests/item-conflicts.spec.ts, which also pins the 409 and its message as relayed by the AppAPI proxy.

D41 — A new family starts with two empty lists, named once in its founder's language

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: creating a family also creates an empty shopping list and an empty task list, named « Liste de courses » and « Tâches » — or "Shopping list" and "Tasks" — in the language of the request that created the family. They are ordinary rows of lists, authored by the founder, and nothing about them is special afterwards.

Why. A new family used to land on a home with nothing in it, and the first thing to do — create a list — had to be guessed. Two lists give it something to open straight away.

Named once, not translated at display. Since D38 a source could be rendered in each reader's language, if the name were stored as a marker (NULL, or a key) until the first rename. That costs a migration, and every reader of a list name — the export, the offline page, notifications, the home, the Lists view — would have to resolve it. Instead the name is translated when it is written and stored like any name a member typed: it becomes the family's. A member reading in another language sees it as it was written, which is already true of every list they did not create, and renaming it is the ordinary fix.

Content to start from, not a state to maintain.

  • Deleting them works and recreates nothing. No flag remembers them, and no pass looks for them.
  • Existing families are not given them. A family with no list cannot be told apart from one that deleted its lists on purpose.
  • An import adds its lists beside them (D36: import appends). A family created only to receive an import therefore has two empty lists more than the file — accepted, and one deletion each.

Where. The last step of createFamily, after the calendars, and without a compensation of its own: it cannot fail on the network, and lists.family_id cascades, so the existing « family row » undo removes them if anything after it failed. Both rows are written in one better-sqlite3 transaction (createLists). No notification (the founder is the only member) and no projection (an empty list puts nothing on the tasks calendar, D31). findListsByFamily orders by created_at, rowid: created_at is precise to the second and both rows share it, so the shopping list comes first by creation order rather than by SQLite's choice. The home's findRecentLists (updated_at DESC) gives the same tie to the older row (rowid ASC), so the two screens agree.

✅ Applied. services/family.ts (STARTER_LISTS), db/lists.ts (createLists), routes/families.ts (passes req.language). Covered by services/family.test.ts, db/lists.test.ts, and end to end by families.spec.ts (a new family shows them; deleted, they stay deleted after a reload) and i18n.spec.ts (an English account gets the English names).

D42 — Undoing an item's deletion puts the same row back, from what the client held, revalidated

Written in English per the language rule in AGENTS.md; this wiki is still being translated.

Decision: deleting an item stays immediate, and for eight seconds a notice offers « Annuler ». Undoing calls POST /api/lists/:listId/items/restore with the item the client held; the server re-inserts it under its own id and at its own position. No soft delete, no migration.

Why not a confirmation. Deleting is the most frequent correction in a shop, done one-handed. A dialog on every deletion costs more than the rare mistake it catches (#17).

Why the client sends the item (decided on #17 against a server-side copy kept in memory for a few minutes). It survives a restart of the container, and the row it needs is already in the store. The cost is that the server must not believe it:

  • The creation schema, plus three fields — id, position, checked — and nothing else. createdBy, checkedBy and checkedAt are not accepted: they are the restorer's, as if they had just created and checked it. A client cannot write « coché par Alice » for someone else.
  • The family walk and its 404, like any write.
  • An id still in use is refused with 409. A restore that overwrote a live row would be an edit nobody made.
  • The rows at or after the position move down one, in the same transaction, in case a reorder took the place meanwhile. The revision is bumped like any other write (D26).

Not the same as D40's « Recreate ». That one answers a conflict — the item was deleted by someone else while being edited — and creates a new item from what was typed. This one undoes the member's own gesture and must leave the list exactly as it was, id and place included.

Around it. The task goes back on the tasks calendar through syncTask, as a creation does (D31). No notification: the family was not told about a deletion that lasted a few seconds either. The notice is dismissed when another list opens, since an undo offered there would put the item back somewhere unseen.

✅ Applied. db/list-items.ts (restoreItem), services/list.ts (restoreRemovedItem), routes/lists.ts, composables/useItemRemoval.ts. Covered by db/list-items.test.ts (id, place, room made after a reorder, attribution, revision), services/list.test.ts (409, 404, no notification, projection) and lists.spec.ts end to end.

Structure du projet

OrganisateurFamilial/
├── appinfo/
│   └── info.xml                  # Métadonnées ExApp (external-app, routes, env vars)
├── ex_app/
│   ├── lib/                      # Backend Node.js / TypeScript
│   │   ├── src/
│   │   │   ├── main.ts           # Écoute (TCP ou socket HaRP), arrêt, rappels
│   │   │   ├── app.ts            # buildApp() : l'instance câblée, sans écoute
│   │   │   ├── config.ts         # Variables d'environnement (validées par Zod)
│   │   │   ├── http.ts           # Codes de statut et bornes 4xx/5xx nommés
│   │   │   ├── time.ts           # Conversions de durées (ms, jour, semaine)
│   │   │   ├── errors.ts         # Traduction des erreurs en réponses HTTP
│   │   │   ├── auth/
│   │   │   │   ├── appapi-auth.ts      # AppAPIAuth entrant + requireUser (D28)
│   │   │   │   └── resolve-family.ts   # preHandler : req.family, ou 404 (D28)
│   │   │   ├── contracts/        # Types partagés avec le frontend (gelés phase 1)
│   │   │   ├── presenters/       # Domaine → charge utile, un fichier par ressource
│   │   │   ├── ocs/              # Point d'entrée /ocs/vN.php
│   │   │   │   ├── client.ts     # Requête OCS générique + logging, UI, notifications
│   │   │   │   ├── circles.ts    # Teams (app `circles`)
│   │   │   │   ├── error.ts      # OcsError, séparé du client (chargement léger)
│   │   │   │   ├── preferences.ts # Préférences par utilisateur
│   │   │   │   └── users.ts      # Groupes, recherche de comptes, existence
│   │   │   ├── dav/              # Point d'entrée /remote.php/dav
│   │   │   │   └── client.ts     # Agendas + partage par principal
│   │   │   ├── routes/           # Plugins Fastify, un par ressource
│   │   │   │   ├── lifecycle.ts  # /heartbeat, /init, /enabled
│   │   │   │   ├── static.ts     # Service du bundle frontend via le proxy
│   │   │   │   ├── api.ts        # /api/me et l'endpoint de fumée /api/hello
│   │   │   │   ├── admin.ts      # Compte de service (D24), remise en état (D32)
│   │   │   │   ├── families.ts   # CRUD familles + membres
│   │   │   │   ├── preferences.ts # Famille active, notifications
│   │   │   │   ├── lists.ts      # Listes et éléments, tous types (D9)
│   │   │   │   ├── summary.ts    # Accueil familial
│   │   │   │   ├── events.ts     # Agenda familial (CalDAV)
│   │   │   │   ├── meals.ts      # Meal planner
│   │   │   │   └── recipes.ts    # Recipe box, photos comprises
│   │   │   ├── db/
│   │   │   │   ├── migrations.ts # Migrations versionnées (user_version)
│   │   │   │   ├── index.ts      # Instance better-sqlite3, WAL
│   │   │   │   ├── families.ts   # Table families
│   │   │   │   ├── lists.ts      # Table lists
│   │   │   │   ├── list-items.ts # Table list_items
│   │   │   │   ├── meals.ts      # Table meal_plans
│   │   │   │   ├── recipes.ts    # Table recipes
│   │   │   │   ├── reminders.ts  # Table reminders_sent
│   │   │   │   └── settings.ts   # Réglages de l'installation
│   │   │   └── services/
│   │   │       ├── access.ts     # familyForUser : ressource → famille → Team
│   │   │       ├── ui.ts         # (Dés)enregistrement TopMenu + scripts
│   │   │       ├── family.ts     # Setup familial + compensation
│   │   │       ├── membership.ts # Cache d'appartenance et de refus (D19, D20)
│   │   │       ├── notification.ts # Envoi de notifications NC
│   │   │       ├── activity.ts   # Regroupement des ajouts avant annonce
│   │   │       ├── reminder.ts   # Passe horaire : échéances et lendemain
│   │   │       ├── reconcile.ts  # Passe horaire : agendas contre lignes (D32)
│   │   │       ├── calendar-repair.ts # Recrée une collection disparue (D32)
│   │   │       ├── installation.ts    # Ce qu'un administrateur fait une fois (D24, D32)
│   │   │       ├── calendar.ts   # Lecture de l'agenda familial (REPORT CalDAV)
│   │   │       └── ical/         # Le format lui-même
│   │   │           ├── parse.ts      # iCalendar → événements
│   │   │           ├── build.ts      # Événements → iCalendar
│   │   │           └── recurrence.ts # RRULE, dans les deux sens (D15)
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── src/                      # Frontend Vue 3 (TypeScript strict)
│   │   ├── main.ts               # Bootstrap Vue 3 + router base AppAPI
│   │   ├── App.vue               # Coquille : navigation + identité familiale
│   │   ├── routes.ts             # Routes par fonctionnalité (`/lists`, `/settings/:section`)
│   │   ├── api.ts                # Appels HTTP, tous via le proxy AppAPI
│   │   ├── vite.config.ts        # createAppConfig + public path proxifié
│   │   ├── build/aliases.ts      # Alias de chemins, partagés Vite / Vitest / tsconfig
│   │   ├── views/                # Welcome, Lists, SettingsHub, Dashboard, …
│   │   ├── components/           # FamilySwitcher, ItemDialog, EventFormDialog, …
│   │   ├── composables/          # État nommé et réutilisable : useActiveFamily, useMediaQuery
│   │   ├── services/             # Fonctions pures : day, format, relative-day, week-*
│   │   ├── stores/               # Pinia stores (families, lists, summary)
│   │   ├── constants/            # AppAPI.ts, family.ts, lists.ts (types de liste)
│   │   └── package.json
│   ├── img/                      # Icônes
│   └── l10n/                     # Traductions Nextcloud
│                                 # (pas de css/ : les styles sont inlinés dans le bundle)
├── Dockerfile                    # Image finale multi-stage
├── Makefile                      # Commandes dev (register, build-push)
├── start.sh                      # Entrypoint conteneur
├── healthcheck.sh                # Healthcheck Docker
└── README.md

Schéma de données (SQLite) — vue préliminaire

Les tables shopping_lists / shopping_items de la phase 1B deviennent lists / list_items (migration v4), et les tables todo_lists / todo_tasks créées en v1 mais jamais utilisées sont supprimées. Voir D9.

-- Familles (miroir des Circles Nextcloud)
CREATE TABLE families (
    id          TEXT PRIMARY KEY,        -- UUID
    circle_id   TEXT NOT NULL UNIQUE,    -- ID du Circle Nextcloud
    name        TEXT NOT NULL,
    color       TEXT DEFAULT '#0082c9',
    icon        TEXT,
    created_by  TEXT NOT NULL,           -- NC user ID
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Les agendas CalDAV d'une famille : le sien, ses tâches, ses repas, et zéro ou plusieurs
-- abonnements externes (voir D31, D33). `slug` est NULL quand la collection n'a pas pu être
-- créée : la ligne survit pour que l'interface puisse nommer l'agenda et le dire injoignable.
-- `source` (l'adresse du flux) et `created_by` ne sont renseignés que pour un abonnement.
CREATE TABLE family_calendars (
    id         TEXT PRIMARY KEY,
    family_id  TEXT NOT NULL REFERENCES families(id) ON DELETE CASCADE,
    kind       TEXT NOT NULL,           -- events | tasks | meals | subscription
    slug       TEXT,
    name       TEXT NOT NULL,
    color      TEXT NOT NULL,
    position   INTEGER NOT NULL DEFAULT 0,
    source     TEXT,
    created_by TEXT,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Un seul de chaque agenda intégré par famille ; un abonnement n'a pas cette limite.
CREATE UNIQUE INDEX idx_family_calendars_builtin
    ON family_calendars(family_id, kind)
    WHERE kind IN ('events', 'tasks', 'meals');

-- A member's colour inside one family, materialised at the member read and never
-- backfilled (D39). `member_key` is the opaque key of D14, not the Circles membership id;
-- `color_index` is an index into the 8-entry palette, never a colour. A row only ever
-- sits next to a member list that genuinely loaded — the Circle is authoritative on
-- membership (D4).
CREATE TABLE member_colors (
    family_id   TEXT NOT NULL REFERENCES families(id) ON DELETE CASCADE,
    member_key  TEXT NOT NULL,
    color_index INTEGER NOT NULL,
    PRIMARY KEY (family_id, member_key)
);

-- Listes partagées : courses et tâches sont le même objet (voir D9)
CREATE TABLE lists (
    id          TEXT PRIMARY KEY,
    family_id   TEXT NOT NULL REFERENCES families(id),
    kind        TEXT NOT NULL DEFAULT 'shopping',  -- shopping | tasks
    name        TEXT NOT NULL,
    created_by  TEXT NOT NULL,
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
    revision    INTEGER NOT NULL DEFAULT 0         -- détecte un changement, cf. D3
);

CREATE TABLE list_items (
    id          TEXT PRIMARY KEY,
    list_id     TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
    name        TEXT NOT NULL,
    checked     INTEGER NOT NULL DEFAULT 0,
    checked_by  TEXT,                    -- NC user ID
    checked_at  TEXT,
    created_by  TEXT NOT NULL,
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    position    INTEGER NOT NULL DEFAULT 0,

    -- Propres au type `shopping`
    quantity    TEXT,

    -- Propres au type `tasks`
    description TEXT,
    assignee    TEXT,                    -- NC user ID
    due_date    TEXT,
    priority    INTEGER
);

-- Recettes
CREATE TABLE recipes (
    id          TEXT PRIMARY KEY,
    family_id   TEXT NOT NULL REFERENCES families(id),
    title       TEXT NOT NULL,
    description TEXT,
    ingredients TEXT NOT NULL,           -- JSON array
    steps       TEXT NOT NULL,           -- JSON array
    photo_path  TEXT,                    -- nom du fichier sur le volume de l'app (D25)
    servings    INTEGER DEFAULT 4,
    prep_time   INTEGER,                 -- minutes
    cook_time   INTEGER,                 -- minutes
    created_by  TEXT NOT NULL,
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Planning des repas
CREATE TABLE meal_plans (
    id          TEXT PRIMARY KEY,
    family_id   TEXT NOT NULL REFERENCES families(id),
    date        TEXT NOT NULL,           -- YYYY-MM-DD
    meal_type   TEXT NOT NULL,           -- 'lunch' | 'dinner' (D13)           -- breakfast, lunch, dinner, snack
    recipe_id   TEXT REFERENCES recipes(id),
    custom_text TEXT,                    -- si pas de recette (ex: "pizza commandée")
    created_by  TEXT NOT NULL,
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    UNIQUE(family_id, date, meal_type)
);

Variables d'environnement (ExApp)

Fournies par AppAPI :

Variable Usage dans l'ExApp
APP_ID Identifiant de l'app
APP_SECRET Secret partagé pour AppAPIAuth
APP_VERSION Version de l'app
APP_HOST Host d'écoute (0.0.0.0)
APP_PORT Port d'écoute
NEXTCLOUD_URL URL de base pour les appels OCS
APP_PERSISTENT_STORAGE Chemin du volume persistant (SQLite + uploads)
AA_VERSION Version AppAPI (header envoyé sur les appels sortants)
APP_DISPLAY_NAME Nom d'affichage de l'app

Fournies par AppAPI uniquement sous HaRP (NC 32+) — leur présence est le signal qui détermine le mode d'écoute :

Variable Usage dans l'ExApp
HP_SHARED_KEY Si défini → mode HaRP : écouter sur le socket Unix, pas sur APP_PORT
HP_FRP_ADDRESS Adresse du serveur FRP (consommée par start.sh)
HP_FRP_PORT Port du serveur FRP (consommée par start.sh)
HP_EXAPP_SOCK Chemin du socket, défaut /tmp/exapp.sock

Propres à l'ExApp (déclarées dans info.xml) :

Variable Description Défaut
POLL_INTERVAL_MS Intervalle de polling pour les listes (ms) 3000
DB_PATH Chemin du fichier SQLite ${APP_PERSISTENT_STORAGE}/data.db
LOG_LEVEL Niveau de log (trace/debug/info/warn/error) info