From 0c4f6b6650cde016c4cc59e8f08efb4758e0a61d Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 1 Oct 2026 18:35:24 +0200 Subject: [PATCH] Derive the participant count instead of incrementing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Participation is readable by everyone — only the link to a person is protected — so an event's count is obtained by counting its participations. No privileged view, nothing to compensate for. The stored counter is gone from the write path: joining writes a participation and nothing else, leaving writes nothing else. Writing the participation IS moving the count, so there is no second write to keep in step and no counter to race. That race was measured, not supposed: the count sat on whatever the seed had written, with no error anywhere, because each increment derived from a read of the previous one. Derived in BOTH providers, so demo and connected modes cannot disagree about what a count means. fp:participantCount stays in the shape and the fixtures, kept in step, but nothing reads it. The fixtures become the source of truth and had to say something true: they advertised 24, 12, 45, 16, 30 against five participation rows. Seventeen rows now, counts aligned to them. Marie's three are untouched on purpose — the home screen lists "my events" from exactly those, and the @ui tests render against it. Small on purpose too: a plausible demo, not a hundred rows reproducing figures nobody reads. us-7-inscription-evenement.feature still sets a starting count and expects it ±1. Those steps encode the superseded counter and will need rewriting around creating participations; they are @data, which is parked while no backend is registered. --- src/shared/context/FestipodDataContext.tsx | 53 ++++++++++++++++------ src/shared/data/seedData.ts | 43 ++++++++++++++---- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index ce53afd..e5a287d 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -131,6 +131,35 @@ const mapParticipation = (p: FpParticipation): FpParticipationData => ({ // Shared queries builder — same logic for both local and NG modes // ============================================================================ +/** + * The participant count, DERIVED from the participations rather than read off the + * event. + * + * Every Participation is readable by everyone — only the link to a person is + * protected, so counting them needs no privileged view and no compensation + * (concept `data-layer`, brief_2026-07-20_attendance-set-model). + * + * WHY NOT THE STORED FIELD. It used to be incremented by whoever joined: read the + * counter out of the list, add one, write it back. Every write then derived from a + * read of the previous one, which races itself the moment the read is not + * instantaneous — measured here as a count stuck on whatever the seed had written, + * with no error anywhere. The project's own brief declares that mutated counter + * superseded. Derived, it cannot drift: it is a function of the rows. + * + * `fp:participantCount` stays in the shape and in the fixtures, kept in step, but + * nothing reads it any more. + */ +function withDerivedCounts( + events: FpEventData[], + participations: FpParticipationData[], +): FpEventData[] { + const counts = new Map(); + for (const p of participations) { + counts.set(p.eventId, (counts.get(p.eventId) ?? 0) + 1); + } + return events.map(e => ({ ...e, participantCount: counts.get(e.id) ?? 0 })); +} + function buildQueries( events: FpEventData[], users: FpUserData[], @@ -181,9 +210,11 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { const [selectedEventId, setSelectedEventId] = useState(empty ? '' : 'event-1'); const [selectedUserId, setSelectedUserId] = useState(''); - const events = empty ? [] : seedEvents; const users = empty ? [] : seedUsers; const participations = empty ? [] : seedParticipations; + // Derived here too, so demo mode and connected mode cannot disagree about what + // a count means. + const events = empty ? [] : withDerivedCounts(seedEvents, participations); const meetingPoints = empty ? [] : seedMeetingPoints; const friendships = empty ? [] : seedFriendships; @@ -331,9 +362,9 @@ function useNgData(): FestipodDataContextValue { const profileRows = profilesResult?.data ?? []; const participationRows = participationsResult?.data ?? []; - const events = eventRows.map(eventBinding.toDomain); const users = profileRows.map(profileBinding.toDomain); const participations = participationRows.map(participationBinding.toDomain); + const events = withDerivedCounts(eventRows.map(eventBinding.toDomain), participations); // --- Writes: every entity through REFINE ---------------------------------- const { mutateAsync: createEventRow } = useCreate(); @@ -528,14 +559,11 @@ function useNgData(): FestipodDataContextValue { console.log('[FestipodData] Already participating, skipping'); return; } + // NOTHING ELSE. The count is derived from the participations, so writing the + // participation IS moving the count — there is no second write to keep in + // step, and no counter to race. await createParticipation(eventId, uid); - // The count lives on the event, which is now a Refine record: bumping it is a - // full record write, not the in-place ORM mutation it used to be. - const current = eventRows.find(r => eventBinding.domainId(r) === eventId); - if (current) { - await writeEvent(eventId, { participantCount: current.participantCount + 1 }); - } - }, [participations, createParticipation, eventRows, eventBinding, writeEvent, currentUserId]); + }, [participations, createParticipation, currentUserId]); const leaveEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; @@ -547,16 +575,13 @@ function useNgData(): FestipodDataContextValue { return p.eventId === eventId && p.userId === uid; }); if (row) { + // Removing the participation IS moving the count — see joinEvent. await deleteParticipationRow({ resource: participationBinding.resource, id: participationBinding.recordKey(row), }); - const current = eventRows.find(r => eventBinding.domainId(r) === eventId); - if (current) { - await writeEvent(eventId, { participantCount: Math.max(0, current.participantCount - 1) }); - } } - }, [participationRows, participationBinding, deleteParticipationRow, eventRows, eventBinding, writeEvent, currentUserId]); + }, [participationRows, participationBinding, deleteParticipationRow, currentUserId]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); diff --git a/src/shared/data/seedData.ts b/src/shared/data/seedData.ts index 03ad81b..09452a4 100644 --- a/src/shared/data/seedData.ts +++ b/src/shared/data/seedData.ts @@ -128,7 +128,7 @@ export const seedEvents: FpEventData[] = [ endTime: '18:00', location: 'Le Revel, Rogues (30)', distance: 142, - participantCount: 24, + participantCount: 5, description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.', hostName: 'Reconnexion', hostInitials: 'RC', @@ -143,7 +143,7 @@ export const seedEvents: FpEventData[] = [ endTime: '17:00', location: 'La Maison du Vélo, Lyon', distance: 3, - participantCount: 12, + participantCount: 3, description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.', hostName: 'La Maison du Vélo', hostInitials: 'MV', @@ -158,7 +158,7 @@ export const seedEvents: FpEventData[] = [ endTime: '18:00', location: "Tiers-lieu L'Hermitage", distance: 89, - participantCount: 45, + participantCount: 4, description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.", hostName: "L'Hermitage", hostInitials: 'LH', @@ -173,7 +173,7 @@ export const seedEvents: FpEventData[] = [ endTime: '17:00', location: 'MJC Montplaisir, Lyon', distance: 5, - participantCount: 16, + participantCount: 3, description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.', hostName: 'MJC Montplaisir', hostInitials: 'MJ', @@ -188,7 +188,7 @@ export const seedEvents: FpEventData[] = [ endTime: '21:00', location: "La Maison de l'Environnement", distance: 7, - participantCount: 30, + participantCount: 2, description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.", hostName: 'Les Colibris', hostInitials: 'LC', @@ -198,14 +198,41 @@ export const seedEvents: FpEventData[] = [ // Participations: which users are in which events export const seedParticipations: FpParticipationData[] = [ - // Marie (current user) participates in events 1, 2, 3 + // THESE ARE THE SOURCE OF TRUTH for a participant count. The count is DERIVED + // by counting an event's participations, not stored on the event: every + // Participation is readable by everyone, only the link to a person is + // protected, so there is nothing to compensate for. The `participantCount` on + // `seedEvents` is kept in step with these rows and is otherwise ignored on read. + // + // Marie (the current user) participates in events 1, 2 and 3 — UNCHANGED, and + // deliberately: the home screen lists "my events" from exactly these, and the + // @ui tests render against it. { id: 'part-1', eventId: 'event-1', userId: 'user-1', isConfirmed: true }, { id: 'part-2', eventId: 'event-2', userId: 'user-1', isConfirmed: true }, { id: 'part-3', eventId: 'event-3', userId: 'user-1', isConfirmed: true }, - // Jean participates in events 1 + // Everything below exists so a derived count shows a plausible number instead + // of the near-zero the three rows above would give. Small on purpose: the point + // is a demo that looks real, not a reproduction of the figures the fixtures + // used to advertise (24, 12, 45…), which would take a hundred rows nobody reads. + // event-1 — 5 participants { id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true }, - // Thomas participates in event 1 { id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true }, + { id: 'part-6', eventId: 'event-1', userId: 'user-3', isConfirmed: true }, + { id: 'part-7', eventId: 'event-1', userId: 'user-9', isConfirmed: true }, + // event-2 — 3 participants + { id: 'part-8', eventId: 'event-2', userId: 'user-4', isConfirmed: true }, + { id: 'part-9', eventId: 'event-2', userId: 'user-8', isConfirmed: true }, + // event-3 — 4 participants + { id: 'part-10', eventId: 'event-3', userId: 'user-5', isConfirmed: true }, + { id: 'part-11', eventId: 'event-3', userId: 'user-6', isConfirmed: true }, + { id: 'part-12', eventId: 'event-3', userId: 'user-10', isConfirmed: true }, + // event-4 — 3 participants, none of them the current user + { id: 'part-13', eventId: 'event-4', userId: 'user-2', isConfirmed: true }, + { id: 'part-14', eventId: 'event-4', userId: 'user-5', isConfirmed: true }, + { id: 'part-15', eventId: 'event-4', userId: 'user-9', isConfirmed: true }, + // event-5 — 2 participants + { id: 'part-16', eventId: 'event-5', userId: 'user-3', isConfirmed: true }, + { id: 'part-17', eventId: 'event-5', userId: 'user-8', isConfirmed: true }, ]; export const seedMeetingPoints: FpMeetingPointData[] = [