Files
benevoles/frontend/src/mock/generate.ts
T
AdamThomasandClaude Sonnet 5 ac469f0cd1 Frontend : intégration du design Claude Design (6 écrans)
Portage du projet « Bénévoles Festival.dc.html » en implémentation statique
pilotée par un jeu de démonstration déterministe (portage du générateur du
design : 15 postes, 64 bénévoles, ~209 affectations).

- Système visuel néo-brutaliste : palette + primitives Tailwind, polices
  Archivo / DM Mono, primitives dans components/ui.tsx.
- Domaine (aligné CLAUDE.md) : types, jours/plages, moteur de validation
  (mêmes codes BLOCKING/WARNING que le back-end), sélecteurs, différentiel
  de publication.
- État applicatif : PlanningProvider (reducer) — affecter / désaffecter /
  publier / confirmer / éditer besoins et bénévoles / import simulé / pointage.
- Écrans : 01 Grille des besoins + panneau d'affectation (candidats triés,
  alertes), 02 Vue par bénévole, 03 Publication, 04 Mobile bénévole,
  05 Mobile référent, 06 Configuration (4 sous-onglets).
- i18n : aucun texte d'interface en dur, locales/fr.json.
- Vérifs : build tsc + vite, eslint (0 erreur), rendu headless des 6 écrans
  (npm run smoke).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
2026-09-09 23:16:38 +02:00

206 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
DAY_PARTS,
DEMO_CATEGORIES,
FESTIVAL_DAYS,
availabilityKey,
dayPartHours,
} from '@/domain/festival';
import type { Assignment, AssignmentStatus, Position, Volunteer } from '@/domain/types';
import { buildPositions } from './positions';
export interface DemoDataset {
positions: Position[];
volunteers: Volunteer[];
assignments: Assignment[];
removed: { volunteerName: string; positionName: string; zone: string; dayShortLabel: string; dayPartLabel: string }[];
nextAssignmentSeq: number;
}
// Générateur pseudo-aléatoire déterministe (mulberry32), repris du design pour
// obtenir exactement le même jeu de démonstration à chaque chargement.
function mulberry32(seed: number): () => number {
let a = seed;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const FIRST_NAMES = [
'Camille', 'Yanis', 'Léa', 'Mathis', 'Soraya', 'Thomas', 'Awa', 'Julien', 'Chloé', 'Nadia',
'Hugo', 'Inès', 'Baptiste', 'Manon', 'Karim', 'Élodie', 'Rémi', 'Sarah', 'Antoine', 'Fanny',
'Malik', 'Clara', 'Victor', 'Jeanne',
];
const LAST_NAMES = [
'Fournier', 'Berger', 'Moreau', 'Lambert', 'Benali', 'Rey', 'Diallo', 'Chevalier', 'Marchand',
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
'Renard', 'Dupuy',
];
const VOLUNTEER_COUNT = 64;
const NEVER_ASSIGNED_INDEXES = [7, 21, 34, 48, 55];
const OVERLOADED_INDEXES = [3, 9];
function statusFromRoll(roll: number): AssignmentStatus {
if (roll < 0.72) return 'CONFIRMED';
if (roll < 0.95) return 'PROPOSED';
return 'DECLINED';
}
export function buildDemoDataset(): DemoDataset {
const positions = buildPositions();
const rnd = mulberry32(20260715);
const volunteers: Volunteer[] = [];
for (let i = 0; i < VOLUNTEER_COUNT; i += 1) {
const firstName = FIRST_NAMES[i % FIRST_NAMES.length];
const lastName = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const availability = new Set<string>();
FESTIVAL_DAYS.forEach((day) => {
DAY_PARTS.forEach((part) => {
const isEdgeDay = day.index === 0 || day.index === 3;
if (rnd() < (isEdgeDay ? 0.4 : 0.6)) {
availability.add(availabilityKey(day.id, part.code));
}
});
});
const preferredCategories = [DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]];
if (rnd() < 0.45) {
preferredCategories.push(DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]);
}
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const phone = `06 ${pair()} ${pair()} ${pair()} ${pair()}`;
volunteers.push({
id: `v${i}`,
firstName,
lastName,
fullName: `${firstName} ${lastName}`,
initials: firstName[0] + lastName[0],
phone,
availability,
preferredCategories,
});
}
// Bénévole de démonstration (« moi ») : disponible partout, demande Bar + Accueil.
const me = volunteers[0];
me.preferredCategories = ['BAR', 'WELCOME'];
FESTIVAL_DAYS.forEach((day) =>
DAY_PARTS.forEach((part) => me.availability.add(availabilityKey(day.id, part.code))),
);
// Référent de démonstration.
volunteers[1].preferredCategories = ['BAR'];
// Bénévoles inscrits mais jamais affectés (« oubliés »).
NEVER_ASSIGNED_INDEXES.forEach((i) => {
volunteers[i].neverAssigned = true;
});
const assignments: Assignment[] = [];
const busy: Record<string, boolean> = {};
const dailyHours: Record<string, number> = {};
const totalHours: Record<string, number> = {};
let seq = 1;
let slotIndex = 0;
positions.forEach((position) => {
FESTIVAL_DAYS.forEach((day) => {
position.needs
.filter((need) => need.dayId === day.id)
.forEach((need) => {
slotIndex += 1;
let target = need.idealStaff;
if (slotIndex % 7 === 0) target = Math.max(1, need.minStaff - 1);
else if (slotIndex % 5 === 0) target = need.minStaff;
else if (slotIndex % 3 === 0) target = Math.max(need.minStaff, need.idealStaff - 1);
const hours = dayPartHours(need.dayPartCode);
const pool = volunteers
.filter(
(v) =>
!v.neverAssigned &&
!busy[`${v.id}|${day.id}|${need.dayPartCode}`] &&
(dailyHours[`${v.id}|${day.id}`] ?? 0) + hours <= 8,
)
.sort((a, b) => score(b) - score(a));
function score(v: Volunteer): number {
const available = v.availability.has(availabilityKey(day.id, need.dayPartCode)) ? 100 : 0;
const wanted = v.preferredCategories.includes(position.category) ? 40 : 0;
return available + wanted - (totalHours[v.id] ?? 0) * 3;
}
pool.slice(0, target).forEach((v) => {
const status = statusFromRoll(rnd());
assignments.push({
id: `a${seq}`,
volunteerId: v.id,
positionId: position.id,
dayId: day.id,
dayPartCode: need.dayPartCode,
status,
source: 'SOLVER',
locked: false,
pending: null,
});
seq += 1;
if (status !== 'DECLINED') {
busy[`${v.id}|${day.id}|${need.dayPartCode}`] = true;
dailyHours[`${v.id}|${day.id}`] = (dailyHours[`${v.id}|${day.id}`] ?? 0) + hours;
totalHours[v.id] = (totalHours[v.id] ?? 0) + hours;
}
});
});
});
});
// « Moi » : 1 créneau confirmé puis des propositions à confirmer.
assignments
.filter((a) => a.volunteerId === me.id)
.forEach((a, i) => {
a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED';
});
// Deux surcharges volontaires : plus de 8 h sur le vendredi.
OVERLOADED_INDEXES.forEach((index) => {
const volunteer = volunteers[index];
const dayId = 'ven';
const taken = assignments
.filter((a) => a.volunteerId === volunteer.id && a.dayId === dayId)
.map((a) => a.dayPartCode);
const freeCode = ['A', 'S', 'N', 'M'].find((code) => !taken.includes(code));
if (!freeCode) return;
const position = positions.find((p) =>
p.needs.some((n) => n.dayId === dayId && n.dayPartCode === freeCode),
);
if (!position) return;
assignments.push({
id: `a${seq}`,
volunteerId: volunteer.id,
positionId: position.id,
dayId,
dayPartCode: freeCode,
status: 'CONFIRMED',
source: 'MANUAL',
locked: false,
pending: null,
});
seq += 1;
});
// Modifications non encore publiées.
assignments.forEach((a, i) => {
if (i % 29 === 7) a.pending = 'ADD';
});
const removed = [
{ volunteerName: 'Sarah Prévost', positionName: 'Parking', zone: 'Nord', dayShortLabel: 'VEN 17', dayPartLabel: '17–22' },
{ volunteerName: 'Karim Benali', positionName: 'Propreté', zone: 'Site', dayShortLabel: 'SAM 18', dayPartLabel: '12–17' },
];
return { positions, volunteers, assignments, removed, nextAssignmentSeq: seq };
}