WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe

Le planning quitte le module de démonstration : la grille lit `WeeklySchedule`
et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des
salariés fictifs disparaissent.

Modèle
- `TeamMember` : rattachement d'un salarié à une équipe, distinct de
  `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un
  salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état
  de départ de toute semaine en construction.
- RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`.

Temps
- `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO,
  jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1
  de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1.
- `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant
  visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf
  heures à minuit donnerait 10 h locales.
- La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure
  d'été 167 — vérifié par test.

Écritures
- Création, modification, suppression de créneau ; publication et dépublication
  **par équipe**, avec verrou optimiste sur `version` : deux managers sur la
  même grille est le cas normal, pas l'exception.
- Chevauchement refusé en transaction, pas seulement dans le formulaire.
- Modifier une semaine publiée exige `planning.edit_published`, capacité que le
  rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu.
- Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace
  **avant** l'effacement, sinon l'état supprimé serait perdu.

Lecture
- `planning.view_unpublished` filtre en base : sans cette capacité, les
  brouillons ne sont pas chargés du tout. Un test vérifie que les horaires
  n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait.
- Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée
  réelle plutôt que figée à 06 h–21 h.

Vérification
- 26 tests unitaires sur le repérage des semaines et la mise en grille.
- Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ;
  et ce que voient un salarié et un manager sur la même semaine.
- `scripts/dev-db.sh` : la base de développement est éphémère dans cet
  environnement, la remonter ne doit pas être une redécouverte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 23:43:48 +00:00
1 parent e9d459366d
commit 023f236881
27 files changed
+3373 -674

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
/**
* Mise en forme de la grille hebdomadaire.
*
* Volontairement séparé de l'accès aux données : ranger sept jours de créneaux
* en colonnes est la partie où l'on se trompe (fuseau, semaine à cheval,
* créneau de nuit), et elle se teste sans base.
*/
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import { shiftMinutes } from '@/domain/counters/week';
import { zonedClock, zonedDate } from '@/domain/planning/week';
import type { PosteCode } from '@/lib/design/postes';
export interface BoardShiftInput {
id: string;
membershipId: string | null;
startAt: Date;
endAt: Date;
breakMinutes: number;
poste: PosteCode;
isValidated: boolean;
note: string | null;
}
export interface BoardShift {
id: string;
poste: PosteCode;
/** « 09:00–17:00 », en heure locale de l'établissement. */
time: string;
minutes: number;
breakMinutes: number;
state: ShiftState;
note: string | null;
}
export interface BoardRow {
/** `null` sur la ligne des besoins non couverts. */
membershipId: string | null;
firstName: string;
lastName: string;
job: string;
forfaitJours: boolean;
counters: WeekCounters;
/** Sept cases, lundi → dimanche. */
days: BoardShift[][];
unassigned: boolean;
}
export interface RowPerson {
membershipId: string;
firstName: string;
lastName: string;
job: string;
forfaitJours: boolean;
contractMinutes: number;
}
/**
* Range les créneaux d'une semaine en lignes × sept colonnes.
*
* Le jour de rattachement se déduit de `startAt` **vu depuis le fuseau de
* l'établissement**, pas de la colonne `localDate` : celle-ci est un cache de
* regroupement, et un créneau déplacé sans la mettre à jour se rangerait au
* mauvais endroit. Ici, les deux ne peuvent pas diverger.
*/
export function buildRows(
people: RowPerson[],
shifts: BoardShiftInput[],
weekDates: string[],
timeZone: string,
isPublished: boolean,
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]);
const byMember = new Map<string, BoardShift[][]>();
const minutesByMember = new Map<string, number>();
const sundaysByMember = new Map<string, Set<number>>();
const unassignedDays = emptyDays();
let hasUnassigned = false;
for (const shift of shifts) {
const column = columnOf.get(zonedDate(shift.startAt, timeZone));
// Un créneau hors des sept jours ne peut pas s'afficher : le taire vaut
// mieux que de l'écraser sur lundi, où il fausserait le compteur.
if (column === undefined) continue;
const minutes = shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
const view: BoardShift = {
id: shift.id,
poste: shift.poste,
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
minutes,
breakMinutes: shift.breakMinutes,
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
note: shift.note,
};
if (!shift.membershipId) {
unassignedDays[column]?.push(view);
hasUnassigned = true;
continue;
}
let days = byMember.get(shift.membershipId);
if (!days) {
days = emptyDays();
byMember.set(shift.membershipId, days);
}
days[column]?.push(view);
minutesByMember.set(
shift.membershipId,
(minutesByMember.get(shift.membershipId) ?? 0) + minutes,
);
if (column === 6) {
const sundays =
sundaysByMember.get(shift.membershipId) ?? new Set<number>();
sundays.add(column);
sundaysByMember.set(shift.membershipId, sundays);
}
}
for (const days of byMember.values()) {
for (const column of days) column.sort(byStartTime);
}
for (const column of unassignedDays) column.sort(byStartTime);
const rows = people.map((person) => {
const days = byMember.get(person.membershipId) ?? emptyDays();
const worked = days.filter((column) => column.length > 0).length;
return {
membershipId: person.membershipId,
firstName: person.firstName,
lastName: person.lastName,
job: person.job,
forfaitJours: person.forfaitJours,
unassigned: false,
days,
counters: {
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
// que la formule d'écart ne change pas quand elles se brancheront.
absenceMinutes: 0,
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked,
},
} satisfies BoardRow;
});
const unassignedRow: BoardRow | null = hasUnassigned
? {
membershipId: null,
firstName: 'Non',
lastName: 'assigné',
job: 'Besoins à couvrir',
forfaitJours: false,
unassigned: true,
days: unassignedDays,
counters: {
contractMinutes: 0,
plannedMinutes: unassignedDays
.flat()
.reduce((total, shift) => total + shift.minutes, 0),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
}
: null;
return { rows, unassignedRow };
}
function byStartTime(a: BoardShift, b: BoardShift): number {
return a.time.localeCompare(b.time);
}
/**
* État visuel d'un créneau.
*
* L'ordre des tests compte : un besoin non couvert reste affiché en fantôme
* même sur une semaine publiée, sinon il se confondrait avec un créneau réel.
*/
export function shiftState(
isValidated: boolean,
isPublished: boolean,
membershipId: string | null,
): ShiftState {
if (!membershipId) return 'unassigned';
if (isValidated) return 'validated';
return isPublished ? 'published' : 'draft';
}
export function initialsOf(row: BoardRow): string {
return `${row.firstName.charAt(0)}${row.lastName.charAt(0)}`.toUpperCase();
}
export function displayName(row: BoardRow): string {
return `${row.firstName} ${row.lastName}`;
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Repérage des semaines de planning.
*
* Une semaine est désignée par son couple **année ISO + numéro ISO**, jamais
* par une date de début. La raison est pratique : fin décembre, le lundi 29
* décembre 2025 appartient à la semaine 1 de 2026. Stocker « 2025-12-29 »
* comme clé ferait apparaître deux semaines 1 selon le point d'entrée, et la
* contrainte d'unicité `(teamId, isoYear, isoWeek)` ne protégerait plus rien.
*
* Toutes les fonctions de ce module sont pures. Les instants sont calculés en
* UTC pour la clé, et les *jours* de la grille sont produits dans le fuseau de
* l'établissement — les deux ne coïncident pas (PLAN.md §3.3).
*/
export interface IsoWeek {
isoYear: number;
isoWeek: number;
}
const DAY_MS = 86_400_000;
/** Minuit UTC du jour donné, sans dépendre du fuseau de la machine. */
function utcDay(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Numéro de semaine ISO 8601 d'une date **civile**.
*
* L'algorithme est celui de la norme : on se déplace au jeudi de la semaine,
* dont l'année donne l'année ISO, puis on compte les semaines depuis le
* 4 janvier. Passer par le jeudi est ce qui rend le résultat correct au
* changement d'année, où la semaine chevauche deux millésimes.
*/
export function isoWeekOf(date: Date): IsoWeek {
const target = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
// getUTCDay : 0 = dimanche. En ISO, lundi = 1 et dimanche = 7.
const dayNumber = target.getUTCDay() === 0 ? 7 : target.getUTCDay();
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const isoYear = target.getUTCFullYear();
const firstThursday = utcDay(isoYear, 1, 4);
const firstDayNumber =
firstThursday.getUTCDay() === 0 ? 7 : firstThursday.getUTCDay();
firstThursday.setUTCDate(firstThursday.getUTCDate() + 4 - firstDayNumber);
const isoWeek =
1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * DAY_MS));
return { isoYear, isoWeek };
}
/** Lundi (date civile, minuit UTC) de la semaine ISO donnée. */
export function mondayOfIsoWeek({ isoYear, isoWeek }: IsoWeek): Date {
const jan4 = utcDay(isoYear, 1, 4);
const dayNumber = jan4.getUTCDay() === 0 ? 7 : jan4.getUTCDay();
const week1Monday = new Date(jan4.getTime() - (dayNumber - 1) * DAY_MS);
return new Date(week1Monday.getTime() + (isoWeek - 1) * 7 * DAY_MS);
}
/** Nombre de semaines ISO de l'année : 52, ou 53 les années longues. */
export function weeksInIsoYear(isoYear: number): number {
return isoWeekOf(utcDay(isoYear, 12, 28)).isoWeek;
}
/** Semaine précédente, en franchissant correctement les années à 53 semaines. */
export function previousIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek > 1) return { isoYear, isoWeek: isoWeek - 1 };
const year = isoYear - 1;
return { isoYear: year, isoWeek: weeksInIsoYear(year) };
}
export function nextIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek < weeksInIsoYear(isoYear)) {
return { isoYear, isoWeek: isoWeek + 1 };
}
return { isoYear: isoYear + 1, isoWeek: 1 };
}
/** Les sept dates civiles de la semaine, lundi → dimanche, en `YYYY-MM-DD`. */
export function weekDates(week: IsoWeek): string[] {
const monday = mondayOfIsoWeek(week);
return Array.from({ length: 7 }, (_, index) =>
new Date(monday.getTime() + index * DAY_MS).toISOString().slice(0, 10),
);
}
/**
* Bornes absolues de la semaine dans un fuseau donné.
*
* Sert à charger les créneaux : `startAt >= from && startAt < to`. Les bornes
* sont des **instants**, pas des dates civiles — la semaine du passage à
* l'heure d'hiver dure 169 heures, celle du passage à l'heure d'été 167.
*/
export function weekBounds(
week: IsoWeek,
timeZone: string,
): { from: Date; to: Date } {
const dates = weekDates(week);
const first = dates[0];
const monday = zonedMidnight(first ?? '1970-01-01', timeZone);
const nextMonday = zonedMidnight(
new Date(mondayOfIsoWeek(week).getTime() + 7 * DAY_MS)
.toISOString()
.slice(0, 10),
timeZone,
);
return { from: monday, to: nextMonday };
}
/**
* Instant correspondant à minuit local d'une date civile dans un fuseau.
*
* Il n'existe pas d'API native pour cela : on tâtonne à partir de l'UTC, en
* mesurant le décalage réel de ce fuseau **à cet instant-là**, puis on corrige.
* Deux passes suffisent, y compris le jour d'un changement d'heure.
*/
export function zonedMidnight(isoDate: string, timeZone: string): Date {
const [year, month, day] = isoDate.split('-').map(Number);
const naive = Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1);
let instant = naive;
for (let pass = 0; pass < 2; pass += 1) {
instant = naive - offsetAt(new Date(instant), timeZone);
}
return new Date(instant);
}
/** Décalage du fuseau à un instant donné, en millisecondes. */
export function offsetAt(instant: Date, timeZone: string): number {
// `en-US` avec `hourCycle: 'h23'` évite le « 24:00 » que produit `h24` à
// minuit, qui décalerait le calcul d'une journée entière.
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(instant);
const get = (type: string) =>
Number(parts.find((part) => part.type === type)?.value ?? '0');
const asUtc = Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour'),
get('minute'),
get('second'),
);
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* Instant correspondant à une heure locale « 09:00 » d'une date civile.
*
* Passe par minuit local puis corrige : le jour d'un changement d'heure, le
* décalage à 09 h n'est pas celui de minuit, et ajouter neuf heures à minuit
* donnerait 08 h ou 10 h locales.
*/
export function zonedInstant(
isoDate: string,
clock: string,
timeZone: string,
): Date {
const [hours, minutes] = clock.split(':').map(Number);
const midnight = zonedMidnight(isoDate, timeZone);
const naive = new Date(
midnight.getTime() + ((hours ?? 0) * 60 + (minutes ?? 0)) * 60_000,
);
const reached = offsetAt(naive, timeZone);
const atMidnight = offsetAt(midnight, timeZone);
return reached === atMidnight
? naive
: new Date(naive.getTime() - (reached - atMidnight));
}
/** Date civile (`YYYY-MM-DD`) d'un instant, vue depuis un fuseau. */
export function zonedDate(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(instant);
}
/** Heure locale « 09:00 » d'un instant, vue depuis un fuseau. */
export function zonedClock(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('fr-FR', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(instant);
}
const DAY_LABELS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
/** « Lun. 10 » — en-tête de colonne de la grille. */
export function dayHeadings(week: IsoWeek): string[] {
return weekDates(week).map((isoDate, index) => {
const label = DAY_LABELS[index] ?? '';
const day = Number(isoDate.slice(8, 10));
return `${label.charAt(0).toUpperCase()}${label.slice(1)} ${day}`;
});
}
const MONTHS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
/** « Semaine 33 · 10 – 16 août 2026 ». */
export function weekLabel(week: IsoWeek): string {
const dates = weekDates(week);
const first = dates[0] ?? '';
const last = dates[6] ?? '';
const startDay = Number(first.slice(8, 10));
const endDay = Number(last.slice(8, 10));
const startMonth = MONTHS[Number(first.slice(5, 7)) - 1] ?? '';
const endMonth = MONTHS[Number(last.slice(5, 7)) - 1] ?? '';
const endYear = last.slice(0, 4);
const span =
startMonth === endMonth
? `${startDay} – ${endDay} ${endMonth} ${endYear}`
: `${startDay} ${startMonth} – ${endDay} ${endMonth} ${endYear}`;
return `Semaine ${week.isoWeek} · ${span}`;
}
/** Analyse « 2026-W33 » ; renvoie `null` si la forme ou les bornes sont fausses. */
export function parseWeekParam(value: string | undefined): IsoWeek | null {
if (!value) return null;
const match = /^(\d{4})-W(\d{1,2})$/.exec(value);
if (!match) return null;
const isoYear = Number(match[1]);
const isoWeek = Number(match[2]);
if (isoWeek < 1 || isoWeek > weeksInIsoYear(isoYear)) return null;
return { isoYear, isoWeek };
}
export function formatWeekParam({ isoYear, isoWeek }: IsoWeek): string {
return `${isoYear}-W${String(isoWeek).padStart(2, '0')}`;
}