- TypeScript 37.2%
- Svelte 27.1%
- JavaScript 25.9%
- CSS 4.9%
- Just 1.7%
- Other 3.2%
A zero is already the shape of an eye, and zero days is the canonical state of the joke — so at 00 the two plates become a pair of eyes that follow the pointer. Only at 00: from 01 they are digits again. Each eye keeps EXACTLY a digit's box. An invisible "0" holds the metrics and the SVG sits on top, so the sign does not change width between 00 and 01 and no size has to be hand-matched at each breakpoint. Three things the idea needed before it could ship. A desk screen has no pointer at all, and /panneau/<id> is where this will mostly be seen. Without an idle behaviour the eyes would sit dead centre forever, which reads as broken rather than funny — so after four seconds without a pointer they start wandering, a new target every couple of seconds, eased far more slowly than when they are tracking. The eyes do NOT take currentColor, unlike every other colour in the app. A digit inverts with the theme and stays legible either way; an eye whose white turns black stops being an eye. Those two literals are the only hardcoded colours here, and they hold on both plates because the plate is a dark recess in both themes. And prefers-reduced-motion stops everything — no tracking, no blinking, eyes open and still. Someone who asked for less motion did not ask for a pair of eyes to stare at them. The blink is pure CSS, with slightly different periods per eye (7.3s and 7.9s) and an offset, so they never blink like a metronome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| k8s | ||
| server | ||
| src | ||
| static | ||
| .dockerignore | ||
| .envrc | ||
| .gitignore | ||
| .npmrc | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
yadérive
Le compteur de jours sans changement de périmètre, servi sur derive.yagni.fr. « Dérive du périmètre » est le terme français pour le scope creep — et le principe reste fidèle à la marque : aucun backend applicatif.
C'est la version sérieuse d'une private joke : le panneau d'usine « X jours sans accident », appliqué aux specs qui bougent.
Le principe
Une seule image, un seul port, un seul conteneur — comme le blog et yapoker :
docker run -p 8080:8080 -v yaderive-data:/data forgejo.yagni.fr/yagni/yaderive
Dedans, un processus Node qui fait deux choses, et rien de plus :
| Ce qu'il sert | Rôle | Ce qu'il sait des compteurs |
|---|---|---|
GET * |
le front SvelteKit compilé — c'est lui qui porte toute la logique : jours, séries, record, journal | tout, mais dans le navigateur |
WS /sync/<compteur> |
relaie les updates CRDT entre les clients d'un même compteur et les persiste sur disque | rien |
Le serveur est celui de yapoker, repris tel quel : il ne prononce jamais le mot « poker », et il ne prononce jamais le mot « compteur » non plus. Il relaie des updates Yjs opaques et les écrit dans une base SQLite.
Deux repos, deux images, zéro paquet partagé. Factoriser un hub de 300 lignes pour son deuxième usage serait une blague involontaire sur un projet qui s'appelle yagni ; le jour où il y aura un troisième usage, on en reparlera.
Ce qui est stocké (et ce qui ne l'est pas)
Un compteur, c'est un Y.Doc, dont le nom de document est le slug de l'URL :
meta Y.Map createdAt, label
changes Y.Array les changements déclarés (JSON figé, jamais réécrit)
Il n'y a pas de lastResetAt, pas de record, pas de nombre de jours. Tout
ça se dérive du journal à la lecture (src/lib/counter/streaks.ts).
Ce n'est pas de l'élégance gratuite. Un lastResetAt dans le Y.Map serait le
même piège que les votes imbriqués de yapoker : deux personnes qui déclarent un
changement en même temps — ou l'une des deux dans le train, qui se resynchronise
une heure plus tard — écrivent la même clé, et la fusion CRDT n'en garde qu'une.
Un changement disparaîtrait sans laisser de trace, dans l'outil dont c'est
précisément le seul travail de ne rien laisser passer.
Un push dans un Y.Array est commutatif : les deux entrées survivent, dans un
ordre identique chez tout le monde. Le test qui garde cette propriété est
src/lib/yjs/doc.test.ts → « deux personnes qui déclarent en même temps ne
s'écrasent pas ». S'il casse, c'est la conception qui est à revoir, pas le test.
Conséquence agréable : aucun état dérivé à maintenir cohérent, rien à réparer, et changer la façon de compter ne demande aucune migration.
Trois pièges, et ce qu'on en a fait
La purge des documents vierges. La persistance supprime les documents où
personne n'a jamais rien écrit — voulu, sinon un scan d'URL sèmerait un compteur
par adresse essayée. Mais un compteur neuf, à zéro jour et sans changement, est
un document vierge. D'où la mise en service explicite, qui écrit createdAt :
c'est à la fois l'origine de la première série et ce qui sauve le compteur de la
compaction. Ouvrir une URL inconnue ne crée toujours rien.
Les horloges. Le hub ne tamponne pas les dates : il ne connaît pas le contenu
des documents, et c'est ce qui fait tout l'intérêt du montage. Les dates viennent
donc du navigateur qui déclare. Sur une séance de planning poker, aucune
importance ; sur un compteur en jours, un poste mal réglé afficherait -3 ou
4021. Les dates invraisemblables sont écartées du calcul (tolérance de cinq
minutes) et signalées dans le journal plutôt que cachées : une déclaration
qui disparaît sans explication passe pour un bug.
Le volume. Une vingtaine de changements par an, à une centaine d'octets l'entrée, contre un plafond de 4 Mio par document. Il n'y a aucun pruning à écrire, et il ne faut pas en écrire.
Les natures de changement
Huit cases, définies dans src/lib/counter/kinds.ts :
| Nature | Ce qu'elle couvre |
|---|---|
| Ajout en cours de route | une demande de plus entre dans le périmètre |
| Changement de priorité | l'ordre des choses à faire est réorganisé |
| Règle métier modifiée | ce qui est vrai dans le domaine change : un calcul, un seuil, un cas à traiter |
| Fonctionnalité revue | le besoin tient, ce qu'on livre pour y répondre change : l'écran, le format, le parcours |
| Retrait ou report | quelque chose sort du périmètre — la seule case qui est souvent une bonne nouvelle |
| Découverte en cours de route | l'équipe a trouvé quelque chose : spec ambiguë, angle mort, infaisable comme prévu |
| Contrainte externe | RGPD, sécurité, client, dépendance — ce que personne autour de la table ne contrôle |
| Autre | filet de sécurité, volontairement en dernier |
Le critère de découpage : une case existe si elle mène à une conversation différente en rétro. C'est ce qui sépare règle métier de fonctionnalité revue — une série de la première dit que le domaine n'était pas stabilisé (remède en amont : ateliers, aller voir les vrais experts), une série de la seconde dit que la solution a été validée trop tard (remède : maquettes et démos plus tôt). Deux remèdes, deux cases. Sinon, on fusionne.
L'arbitrage quand deux cases collent (une infaisabilité trouvée par l'équipe change bien la solution) : si le changement vient de l'équipe, c'est découverte, quoi qu'il bouge.
À surveiller : si autre dépasse une déclaration sur cinq, ce n'est pas que les changements sont inclassables, c'est qu'il manque une case — et le journal perd sa valeur tant qu'on ne l'a pas ajoutée.
La taxonomie a déjà changé deux fois (interlocuteur, une origine déguisée en
nature ; exigence, du vocabulaire de cahier des charges que personne
n'employait). Les entrées anciennes restent lisibles : le journal affiche la
valeur brute d'une nature retirée plutôt qu'un badge vide, et kinds.test.ts
le verrouille.
Ce que ce n'est pas
Un outil de surveillance, ni un dossier à charge. Les natures de changement nomment ce qui a bougé, jamais qui l'a demandé — c'est aussi pour ça que l'app ne s'appelle pas « yagirouette ». Un panneau affiché dans un couloir se lit très vite comme un doigt d'honneur permanent adressé à quelqu'un.
Et toutes les cases ne décrivent pas des choses qu'on subit : découverte en cours de route couvre le périmètre qui bouge parce que l'équipe a trouvé quelque chose (spec ambiguë, angle mort, infaisable comme prévu). Sans elle, le panneau ne tient pas dix minutes en rétro. Le compteur mesure la stabilité du périmètre, pas les torts de qui que ce soit.
Il n'y a ni compte, ni mot de passe, ni permissions : qui a l'adresse peut lire, déclarer et corriger. Outil interne entre collègues de confiance, même arbitrage que les cartes visibles dans les devtools de yapoker.
Mode affichage
/panneau/<compteur> rend le panneau seul, en grand, sans bouton ni journal :
pour un écran de bureau, ou pour montrer le compteur à quelqu'un d'extérieur à
l'équipe. Le bouton « copier le lien d'affichage » est sur la page du compteur.
Ce n'est pas une permission, et l'UI le dit : qui a cette adresse peut aussi
ouvrir /compteur/<compteur> et déclarer. Ça évite les clics malheureux, pas
les gens décidés. Une vraie lecture seule demanderait un jeton d'écriture
vérifié par le hub — faisable sans lui apprendre ce qu'il relaie, mais ça lui
donne un état à gérer et des jetons à distribuer, pour un compteur de vannes.
Ce qui rend l'arbitrage confortable : une remise à zéro accidentelle est entièrement réversible. Comme rien n'est stocké à part le journal, retirer l'entrée fautive recalcule les jours, la série et le record à l'identique. Il n'y a aucun dégât permanent à prévenir.
Pourquoi un groupe de routes
src/routes/+layout.svelte feuille de style + favicon, rien d'autre
src/routes/(app)/+layout.svelte thème, largeur de lecture, pied de page
src/routes/(app)/+page.svelte accueil -> /
src/routes/(app)/compteur/[id]/ le compteur -> /compteur/<id>
src/routes/panneau/[id]/ l'affichage -> /panneau/<id>
Le layout racine s'applique à TOUTES les routes, sans exception : +page@.svelte
remonte jusqu'à lui et ne s'en détache pas (vérifié dans le manifest de build —
c'était une erreur de conception au premier jet). Pour qu'une page occupe tout
l'écran, il faut donc que l'habillage vive plus bas, dans un groupe. Les
parenthèses ne changent aucune URL.
Démarrage local
Prérequis : Node 24 et just. Le
flake les fournit (nix develop, ou automatiquement avec direnv
après direnv allow).
just install # dépendances de l'app et du serveur
just server # terminal 1 : serveur sur :8080
just dev # terminal 2 : Vite (live-reload) sur :5173
Puis http://localhost:5173. Vite proxifie /sync vers le serveur, donc le
client parle toujours à sa propre origine — exactement la situation de prod.
Aucune URL à configurer.
| Commande | Ce que ça fait |
|---|---|
just serve |
build + serveur, sans Docker -> http://localhost:8080 |
just image |
construit l'image unique (yaderive:dev) |
just run [port] |
construit puis lance l'image comme en prod (just run 8088) |
just clean-data |
supprime le volume Docker de test |
just test |
dérivations du compteur (vitest) + persistance (node --test) |
just check |
typecheck Svelte + TypeScript |
just counters |
les compteurs présents dans la base locale |
just drop-counter <nom> |
supprime définitivement un compteur et son journal |
Déploiement
k8s/ contient des manifests kustomize prêts pour un dépôt GitOps (Argo CD), et
.forgejo/workflows/deploy.yml construit l'image avec kaniko puis bumpe le tag.
Les deux sont repris de yapoker ; les seules différences tiennent en une ligne :
Recreate, un seul replica, jamais de scale-out. Un seul processus doit écrire la base SQLite, et deux pods ne partageraient pas leurs documents en mémoire.- Le PVC porte
Prune=false. Contrairement à une room de planning poker, ces données sont faites pour durer des années : c'est le volume à sauvegarder, et le perdre remet toutes les équipes à zéro jour.
Choix assumés
- Pas d'i18n. yapoker parle français et anglais ; ici tout est en français.
Une vanne sur la dérive du périmètre ne se traduit pas, et le jour où il
faudra,
src/lib/i18n/de yapoker se recopie en une heure. - Pas de générateur de nom rigolo. Une room est jetable et doit se dicter au téléphone ; un compteur est un marque-page qu'on retape pendant des mois.
- Pas de graphique. Le journal dit déjà « a cassé une série de 12 jours » à chaque ligne. Le jour où six mois d'historique tiendront dans une sparkline, ce sera peut-être utile — pas avant.