Files
planflow/src/domain/planning/week.ts
T
Claude 023f236881 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
2026-08-07 23:43:48 +00:00

264 lines
8.7 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.
/**
* 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')}`;
}