Table of contents
- Architecture technique
- Vue d'ensemble
- Stack technique
- Décisions techniques
- D1 — Node.js/TypeScript pour le backend
- D2 — SQLite pour le stockage
- D3 — Mises à jour temps réel : polling + fallback WebSocket
- D4 — Multi-familles via Circles
- D8 — Mono-famille par défaut, multi-famille par exception
- D9 — Une seule notion de liste, déclinée en types
- D10 — Réordonnancement par glisser-déposer, avec repli clavier
- D7 — Cache court de l'appartenance aux Teams
- D6 — Compensation plutôt que transaction pour le setup familial
- D5 — AppAPIAuth implémenté à la main
- D11 — Ingrédients et étapes en JSON, pas en tables filles
- D12 — Les ingrédients partent vers une liste sans fusion de quantités
- D13 — Un créneau de repas est une coordonnée, pas une entité
- D14 — L'attribution d'un événement passe par CATEGORIES, et le chemin d'un agenda dépend de qui le lit
- D15 — Une règle de répétition plus riche que le modèle est conservée, jamais reconstruite
- D16 — Les rappels sont tenus par le conteneur, pas par Nextcloud
- D17 — La préférence consultée est celle du destinataire
- D18 — L'activité est rassemblée avant d'être annoncée
- D19 — Tout ce qui est demandé à Nextcloud plusieurs fois par minute est mis en cache
- D20 — Un refus n'est jamais redemandé
- D21 — Une famille survit au compte qui l'a fondée
- D22 — Transmettre une famille, c'est déplacer ses ressources
- D23 — Une famille n'a plus qu'une ressource Nextcloud : son agenda
- D24 — Le compte de service est créé par le navigateur de l'administrateur
- D25 — Une photo de recette vit sur le volume de l'application, pas dans Files
- D26 — The server is authoritative on client state
- D27 — A preference lives on the server or in the browser, and which one is not arbitrary
- D28 — A request passes through four gates, and the order is the contract
- D29 — The e2e suite imposes state, it does not read it
- D30 — The week grid nests overlapping events rather than splitting the column
- D31 — Une famille tient trois agendas, et deux d'entre eux sont des projections
- D32 — An hourly pass makes the calendars agree with the rows again
- D33 — An external subscription is a family calendar, owned by the service account and read as it
- D34 — Any member may add or remove a subscription
- D35 — Offline is a separate read-only page a service worker synthesises, not a mode the app runs in
- D36 — An export carries what this application owns, and identities only where they do work
- D37 — The calendar travels as stored objects, and its import is replayable
- D38 — The interface follows the Nextcloud account's language, from English sources and a French catalogue
- 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
- D40 — A list item change is compared field by field against what its editor started from
- D41 — A new family starts with two empty lists, named once in its founder's language
- D42 — Undoing an item's deletion puts the same row back, from what the client held, revalidated
- Structure du projet
- Schéma de données (SQLite) — vue préliminaire
- Variables d'environnement (ExApp)
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
localStoragedonnent 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.vueattend le drapeaureadyavant 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 appelmemberspar 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
revisionde 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/:sectiondé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 lorsqueGET /api/meconfirme 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) :
- Exempter
/heartbeat— AppAPI l'interroge avant tout échange de secret - Lire
EX-APP-ID,EX-APP-VERSION,AUTHORIZATION-APP-APIdes headers ; les trois doivent être présents et non vides.AA-VERSIONn'est envoyé que sur les appels sortants — l'exiger en entrée fait échouer 100 % des requêtes - Vérifier
EX-APP-ID === APP_ID - Décoder base64 de
AUTHORIZATION-APP-API, découper sur le premier:→userid,secret - Comparer
secretàAPP_SECRETaveccrypto.timingSafeEqual(jamais===: c'est un bearer secret sur un endpoint exposé) - Injecter
useriddans 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" wherelocaleCompare('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
revisionon 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 notupdated_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
loadin 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
ksiblings that overlap takes2 / (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.
calendarUnreadablemeans "at least one did not answer", exactly asEventPeriod.unreadabledoes 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
DTENDon 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, sofetch()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 tofetch()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 isprivate, 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:
- The bundle is emitted as a real IIFE. As top-level code, every binding the minifier
produced became global — Vue's
uidcounter, minified tolet OC = 0, shadowed Nextcloud'sOCfor every script that ran afterwards, soOC.L10N.registerthrew in this application's catalogue and in four core apps'. - 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.
- 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.
- The frontend states the account's language on its own requests. AppAPI fills
Accept-Languagein 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.basedoes 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
reorderItemsrewrites 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,checkedByandcheckedAtare 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_itemsde la phase 1B deviennentlists/list_items(migration v4), et les tablestodo_lists/todo_taskscréé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 |