Pointage enregistré et statistiques de présence
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.

Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 08:06:32 +01:00
co-authored by Claude Opus 5.5
parent a37f6c2c41
commit c57c70569c
19 changed files with 600 additions and 22 deletions
+35 -1
View File
@@ -37,6 +37,8 @@ import type {
ShiftUpdate,
VolunteerCreate,
VolunteerUpdate,
AssignmentRead,
AttendanceStatus,
} from './schema';
const edition = EDITION_ID;
@@ -157,8 +159,14 @@ export function useMatrix() {
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
* recalculées côté serveur à chaque appel. */
/** Rafraîchie toutes les 30 s tant que l'écran est ouvert : la section
* « Présence » suit le pointage des référents en temps réel. */
export function useStats() {
return useQuery({ queryKey: ['stats', edition] as const, queryFn: () => api.stats.get(edition) });
return useQuery({
queryKey: ['stats', edition] as const,
queryFn: () => api.stats.get(edition),
refetchInterval: 30_000,
});
}
/** Configuration du recto/verso des badges (images, champs). */
@@ -523,6 +531,32 @@ export function useUpdateAssignment() {
});
}
/** Pointage présent / absent (`null` annule), enregistré en base. Mis à
* jour tout de suite dans les listes d'affectations en cache (le référent
* pointe vite, sur un réseau de festival souvent lent), puis rechargé. */
export function useSetAttendance() {
const client = useQueryClient();
const patch = (id: string, change: Partial<AssignmentRead>) =>
client.setQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] }, (list) =>
list?.map((a) => (a.id === id ? { ...a, ...change } : a)),
);
return useMutation({
mutationFn: ({ id, attendance }: { id: string; attendance: AttendanceStatus | null }) =>
api.assignments.setAttendance(edition, id, { attendance }),
onMutate: async ({ id, attendance }) => {
await client.cancelQueries({ queryKey: ['assignments', edition] });
const previous = client.getQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] });
patch(id, { attendance });
return { previous };
},
onError: (_error, _vars, context) => {
for (const [key, data] of context?.previous ?? []) client.setQueryData(key, data);
},
onSuccess: (saved) => patch(saved.id, saved),
onSettled: () => client.invalidateQueries({ queryKey: ['assignments', edition] }),
});
}
export function useDeleteAssignment() {
const invalidate = useInvalidate();
return useMutation({
+6
View File
@@ -30,6 +30,7 @@ import type {
VerifyRequest,
VolunteerCreate,
VolunteerUpdate,
AttendanceUpdate,
} from './schema';
import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types';
@@ -232,6 +233,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
setAttendance: (editionId, id, body: AttendanceUpdate) =>
request(`${base(editionId)}/assignments/${id}/attendance`, {
method: 'PUT',
body: JSON.stringify(body),
}),
validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, {
method: 'POST',
+4
View File
@@ -382,6 +382,8 @@ export function buildMockState(): MockState {
shift_id: shift.id,
source: 'SOLVER',
locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW,
updated_at: NOW,
});
@@ -411,6 +413,8 @@ export function buildMockState(): MockState {
shift_id: shift.id,
source: 'MANUAL',
locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW,
updated_at: NOW,
});
+57 -1
View File
@@ -1,5 +1,5 @@
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time';
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
import type {
AccountRead,
@@ -594,6 +594,53 @@ function buildStats(): EditionStats {
}),
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
},
attendance: buildAttendance(),
};
}
/** Même calcul que `app/api/stats.py:_attendance`. */
function buildAttendance(): EditionStats['attendance'] {
const now = nowNaiveMinutes();
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
c.assigned += 1;
if (a.attendance === 'PRESENT') c.present += 1;
else if (a.attendance === 'ABSENT') c.absent += 1;
else c.unmarked += 1;
};
const past = empty();
const live = empty();
const rows = new Map(
state.positions.map((p) => [
p.id,
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
]),
);
const phase = new Map<string, 'past' | 'live'>();
for (const s of state.shifts) {
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
else if (naiveMinutes(s.starts_at) <= now) {
phase.set(s.id, 'live');
const row = rows.get(s.position_id);
if (row) row.live_shifts += 1;
}
}
for (const a of state.assignments) {
const when = phase.get(a.shift_id);
const shift = state.shifts.find((s) => s.id === a.shift_id);
const row = shift ? rows.get(shift.position_id) : undefined;
if (!when || !row) continue;
count(when === 'past' ? past : live, a);
count(when === 'past' ? row.past : row.live, a);
}
return {
as_of: touch(),
past,
live,
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
by_position: [...rows.values()]
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
};
}
@@ -1536,6 +1583,8 @@ export function createMockApi(): PlanningApi {
shift_id: body.shift_id,
source: body.source ?? 'MANUAL',
locked: body.locked ?? false,
attendance: null,
attendance_marked_at: null,
created_at: now,
updated_at: now,
};
@@ -1551,6 +1600,13 @@ export function createMockApi(): PlanningApi {
});
return delay({ assignment: a, warnings: [] as Violation[] });
},
setAttendance: (_edition, id, body) => {
const a = state.assignments.find((x) => x.id === id) ?? notFound();
a.attendance = body.attendance;
a.attendance_marked_at = body.attendance ? touch() : null;
a.updated_at = touch();
return delay({ ...a });
},
remove: (_edition, id) => {
state.assignments = state.assignments.filter((x) => x.id !== id);
return delay(undefined);
+36
View File
@@ -370,10 +370,20 @@ export interface AssignmentRead {
shift_id: string;
source: AssignmentSource;
locked: boolean;
/** Pointage du référent (`null` = pas encore pointé) et son heure. */
attendance: AttendanceStatus | null;
attendance_marked_at: string | null;
created_at: string;
updated_at: string;
}
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
export interface AttendanceUpdate {
/** `null` = annuler le pointage. */
attendance: AttendanceStatus | null;
}
export interface AssignmentCreate {
volunteer_id: string;
shift_id: string;
@@ -831,4 +841,30 @@ export interface EditionStats {
by_position: StatsNeedPosition[];
volunteers_with_assignment: number;
};
attendance: StatsAttendance;
}
/** Pointage d'un ensemble d'affectations : `assigned` = `present` +
* `absent` + `unmarked`. */
export interface StatsAttendanceCounts {
assigned: number;
present: number;
absent: number;
unmarked: number;
}
/** Présents / absents pointés, sur les créneaux terminés (`past`) et en
* cours (`live`) à `as_of` ; les créneaux à venir ne comptent pas. */
export interface StatsAttendance {
as_of: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
by_position: Array<{
position_id: string;
name: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
}>;
}
+2
View File
@@ -57,6 +57,7 @@ import type {
VolunteerRead,
VolunteerUpdate,
EditionStats,
AttendanceUpdate,
} from './schema';
/**
@@ -140,6 +141,7 @@ export interface PlanningApi {
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
remove(editionId: string, id: string): Promise<void>;
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
validate(
editionId: string,
body: AssignmentValidateRequest,
+18
View File
@@ -177,6 +177,8 @@
"mobileLead.view.team": "Mon équipe",
"mobileLead.view.planning": "Mon planning · {count}",
"mobileLead.you": "TOI",
"mobileLead.markedAt": "pointé à {time}",
"mobileLead.attendanceError": "Le pointage n'a pas pu être enregistré (réseau ?). Réessaie.",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.badgePresent": "✓ PRÉSENT",
@@ -575,6 +577,22 @@
"stats.kpi.collected": "Cotisations encaissées en ligne",
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
"stats.attendance.title": "Présence",
"stats.attendance.help": "Présents et absents pointés par les référents sur leur écran, par poste. « En ce moment » : créneaux commencés et pas encore finis ; « Créneaux terminés » : tout ce qui est fini. Les créneaux à venir ne comptent pas. Mis à jour toutes les 30 secondes.",
"stats.attendance.live": "En ce moment",
"stats.attendance.past": "Créneaux terminés",
"stats.attendance.asOf": "mis à jour à {time}",
"stats.attendance.noLive": "Aucun créneau en cours.",
"stats.attendance.noPast": "Aucun créneau terminé pour l'instant.",
"stats.attendance.liveSummary": "{shifts} créneau(x) en cours · {expected} bénévole(s) attendu(s)",
"stats.attendance.shiftsCount": "{count} en cours",
"stats.attendance.expected": "Attendus",
"stats.attendance.present": "Présents",
"stats.attendance.absent": "Absents",
"stats.attendance.unmarked": "Non pointés",
"stats.attendance.rate": "Présence",
"stats.attendance.rateTip": "{present} présent(s) sur {marked} pointé(s)",
"stats.attendance.unmarkedHint": "{count} affectation(s) sur des créneaux terminés n'ont jamais été pointées : le taux de présence ne porte que sur les bénévoles pointés.",
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
"stats.showTable": "Voir le tableau",
"stats.unit.hours": "h",
+23 -13
View File
@@ -5,6 +5,7 @@ import {
useAssignments,
useDayParts,
usePositions,
useSetAttendance,
useShifts,
useUpdatePosition,
useVolunteer,
@@ -43,10 +44,10 @@ import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
//
// Le pointage présent/absent reste local à l'écran (état React, jamais
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
// Le pointage présent/absent est enregistré sur l'affectation
// (`Assignment.attendance`, `PUT .../assignments/{id}/attendance`) : il
// survit à un rechargement et se retrouve sur un autre téléphone.
type Presence = Record<string, 'present' | 'absent'>;
type View = 'team' | 'planning';
const STATUS_KEY: Record<SlotStatus, string> = {
@@ -67,7 +68,7 @@ export function MobileLeadPage() {
const [view, setView] = useState<View | null>(null);
const updatePosition = useUpdatePosition();
const now = useNowMinutes();
const [presence, setPresence] = useState<Presence>({});
const setAttendance = useSetAttendance();
const [positionId, setPositionId] = useState<string | null>(null);
const [shiftId, setShiftId] = useState<string | null>(null);
@@ -142,7 +143,8 @@ export function MobileLeadPage() {
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '',
state: presence[a.id] ?? null,
state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
markedAt: a.attendance_marked_at,
};
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
@@ -150,12 +152,10 @@ export function MobileLeadPage() {
const presentCount = team.filter((m) => m.state === 'present').length;
const absentCount = team.filter((m) => m.state === 'absent').length;
// Un second appui sur le même bouton annule le pointage.
const mark = (assignmentId: string, value: 'present' | 'absent') =>
setPresence((p) => {
const next = { ...p };
if (next[assignmentId] === value) delete next[assignmentId];
else next[assignmentId] = value;
return next;
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
setAttendance.mutate({
id: member.id,
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
});
// Bénévoles distincts affectés à au moins un créneau du poste.
@@ -303,6 +303,11 @@ export function MobileLeadPage() {
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
{setAttendance.isError ? (
<div className="border-[3px] border-ink bg-orange px-3 py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileLead.attendanceError')}
</div>
) : null}
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.roll', { count: team.length })}
@@ -344,6 +349,11 @@ export function MobileLeadPage() {
{member.phone}
</span>
) : null}
{member.state && member.markedAt ? (
<span className="block font-mono text-[10px] font-medium leading-snug opacity-60">
{t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })}
</span>
) : null}
</span>
{member.phone ? (
<a
@@ -358,7 +368,7 @@ export function MobileLeadPage() {
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() => mark(member.id, 'present')}
onClick={() => mark(member, 'present')}
aria-pressed={member.state === 'present'}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
@@ -369,7 +379,7 @@ export function MobileLeadPage() {
</button>
<button
type="button"
onClick={() => mark(member.id, 'absent')}
onClick={() => mark(member, 'absent')}
aria-pressed={member.state === 'absent'}
className={cx(
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
+101 -2
View File
@@ -1,5 +1,5 @@
import { useStats } from '@/api/hooks';
import type { EditionStats } from '@/api/schema';
import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
import {
BarList,
CumulativeLineChart,
@@ -15,7 +15,8 @@ import { ErrorNote, Loading } from '@/components/state';
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
import { t } from '@/i18n';
import { foldAccents } from '@/lib/planning';
import { longDayLabel, shortDayLabel } from '@/lib/time';
import { longDayLabel, naiveMinutes, shortDayLabel } from '@/lib/time';
import { clockLabel } from '@/lib/slotTimeline';
const fr = (n: number, digits = 0) =>
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
@@ -62,6 +63,9 @@ export function StatsPage() {
/>
</div>
{/* Pendant le festival, la présence en direct passe en tête. */}
{s.attendance.live_shifts > 0 ? <AttendanceSection stats={s} /> : null}
{s.registrations.total === 0 ? (
<Panel>
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
@@ -76,6 +80,7 @@ export function StatsPage() {
</>
)}
<NeedsSection stats={s} />
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
</div>
);
}
@@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
</Section>
);
}
function attendanceSegments(c: StatsAttendanceCounts) {
return [
{ label: t('stats.attendance.present'), value: c.present, color: SERIES.aqua },
{ label: t('stats.attendance.absent'), value: c.absent, color: SERIES.orange },
{ label: t('stats.attendance.unmarked'), value: c.unmarked, color: SERIES.muted },
];
}
/** Ligne de tableau : attendus, présents, absents, non pointés, et part de
* présents parmi les bénévoles déjà pointés. */
function attendanceCells(c: StatsAttendanceCounts) {
const marked = c.present + c.absent;
return [
fr(c.assigned),
fr(c.present),
c.absent ? <b key="a">{fr(c.absent)}</b> : '0',
fr(c.unmarked),
<span key="m" className="flex justify-end">
<Meter
value={c.present}
max={marked}
label={marked ? `${pct(c.present, marked)} %` : '—'}
title={t('stats.attendance.rateTip', { present: fr(c.present), marked: fr(marked) })}
/>
</span>,
];
}
function AttendanceSection({ stats }: { stats: EditionStats }) {
const a = stats.attendance;
const head = [
t('stats.col.position'),
t('stats.attendance.expected'),
t('stats.attendance.present'),
t('stats.attendance.absent'),
t('stats.attendance.unmarked'),
t('stats.attendance.rate'),
];
const liveRows = a.by_position.filter((p) => p.live_shifts > 0);
const pastRows = a.by_position.filter((p) => p.past.assigned > 0);
return (
<Section title={t('stats.attendance.title')} help={t('stats.attendance.help')}>
<div className="flex flex-wrap items-baseline gap-2">
<SubTitle>{t('stats.attendance.live')}</SubTitle>
<span className="ml-auto font-mono text-[10px] opacity-60">
{t('stats.attendance.asOf', { time: clockLabel(naiveMinutes(a.as_of)) })}
</span>
</div>
{a.live_shifts === 0 ? (
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noLive')}</p>
) : (
<>
<p className="font-mono text-[11px]">
{t('stats.attendance.liveSummary', { shifts: fr(a.live_shifts), expected: fr(a.live.assigned) })}
</p>
<ShareBar segments={attendanceSegments(a.live)} />
<div className="overflow-x-auto">
<DataTable
head={head}
rows={liveRows.map((p) => [
`${p.name} · ${t('stats.attendance.shiftsCount', { count: p.live_shifts })}`,
...attendanceCells(p.live),
])}
/>
</div>
</>
)}
<SubTitle>{t('stats.attendance.past')}</SubTitle>
{a.past.assigned === 0 ? (
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noPast')}</p>
) : (
<>
<ShareBar segments={attendanceSegments(a.past)} />
<div className="overflow-x-auto">
<DataTable
head={head}
rows={[
...pastRows.map((p) => [p.name, ...attendanceCells(p.past)]),
[<b key="t">{t('stats.payments.total')}</b>, ...attendanceCells(a.past)],
]}
/>
</div>
{a.past.unmarked > 0 ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('stats.attendance.unmarkedHint', { count: fr(a.past.unmarked) })}
</p>
) : null}
</>
)}
</Section>
);
}