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
+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);