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:
27 files changed
+3373
-674
No files matched your search
@@ -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}`;
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
Reference in new issue
Block a user