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[] = [