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
216 lines
6.7 KiB
TypeScript
216 lines
6.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
dayHeadings,
|
||
formatWeekParam,
|
||
isoWeekOf,
|
||
mondayOfIsoWeek,
|
||
nextIsoWeek,
|
||
offsetAt,
|
||
parseWeekParam,
|
||
previousIsoWeek,
|
||
weekBounds,
|
||
weekDates,
|
||
weekLabel,
|
||
weeksInIsoYear,
|
||
zonedClock,
|
||
zonedDate,
|
||
zonedMidnight,
|
||
} from '@/domain/planning/week';
|
||
|
||
const utc = (iso: string) => new Date(iso);
|
||
|
||
describe('isoWeekOf', () => {
|
||
it('numérote une semaine ordinaire', () => {
|
||
expect(isoWeekOf(utc('2026-08-12T00:00:00Z'))).toEqual({
|
||
isoYear: 2026,
|
||
isoWeek: 33,
|
||
});
|
||
});
|
||
|
||
it('rattache le 1ᵉʳ janvier à l’année ISO précédente quand la norme l’exige', () => {
|
||
// 1ᵉʳ janvier 2027 est un vendredi : il appartient à la semaine 53 de 2026.
|
||
expect(isoWeekOf(utc('2027-01-01T00:00:00Z'))).toEqual({
|
||
isoYear: 2026,
|
||
isoWeek: 53,
|
||
});
|
||
});
|
||
|
||
it('rattache fin décembre à l’année ISO suivante quand la norme l’exige', () => {
|
||
// 29 décembre 2025 est un lundi : semaine 1 de 2026.
|
||
expect(isoWeekOf(utc('2025-12-29T00:00:00Z'))).toEqual({
|
||
isoYear: 2026,
|
||
isoWeek: 1,
|
||
});
|
||
});
|
||
|
||
it('reconnaît les années à 53 semaines', () => {
|
||
expect(weeksInIsoYear(2026)).toBe(53);
|
||
expect(weeksInIsoYear(2025)).toBe(52);
|
||
});
|
||
});
|
||
|
||
describe('mondayOfIsoWeek', () => {
|
||
it('est l’inverse de isoWeekOf', () => {
|
||
for (let week = 1; week <= 52; week += 1) {
|
||
const monday = mondayOfIsoWeek({ isoYear: 2026, isoWeek: week });
|
||
expect(monday.getUTCDay()).toBe(1);
|
||
expect(isoWeekOf(monday)).toEqual({ isoYear: 2026, isoWeek: week });
|
||
}
|
||
});
|
||
|
||
it('place la semaine 1 de 2026 au 29 décembre 2025', () => {
|
||
expect(
|
||
mondayOfIsoWeek({ isoYear: 2026, isoWeek: 1 }).toISOString().slice(0, 10),
|
||
).toBe('2025-12-29');
|
||
});
|
||
});
|
||
|
||
describe('navigation de semaine', () => {
|
||
it('recule sur l’année précédente en respectant sa longueur', () => {
|
||
// 2026 compte 53 semaines : reculer depuis la 1 de 2027 doit donner 53,
|
||
// pas 52. Une erreur ici fait disparaître une semaine de planning par an.
|
||
expect(previousIsoWeek({ isoYear: 2027, isoWeek: 1 })).toEqual({
|
||
isoYear: 2026,
|
||
isoWeek: 53,
|
||
});
|
||
expect(previousIsoWeek({ isoYear: 2026, isoWeek: 1 })).toEqual({
|
||
isoYear: 2025,
|
||
isoWeek: 52,
|
||
});
|
||
});
|
||
|
||
it('avance au-delà de la dernière semaine', () => {
|
||
expect(nextIsoWeek({ isoYear: 2026, isoWeek: 53 })).toEqual({
|
||
isoYear: 2027,
|
||
isoWeek: 1,
|
||
});
|
||
expect(nextIsoWeek({ isoYear: 2025, isoWeek: 52 })).toEqual({
|
||
isoYear: 2026,
|
||
isoWeek: 1,
|
||
});
|
||
});
|
||
|
||
it('fait un aller-retour sans dérive sur toute une année', () => {
|
||
let week = { isoYear: 2026, isoWeek: 1 };
|
||
for (let step = 0; step < 60; step += 1) week = nextIsoWeek(week);
|
||
for (let step = 0; step < 60; step += 1) week = previousIsoWeek(week);
|
||
expect(week).toEqual({ isoYear: 2026, isoWeek: 1 });
|
||
});
|
||
});
|
||
|
||
describe('weekDates', () => {
|
||
it('donne sept jours consécutifs du lundi au dimanche', () => {
|
||
expect(weekDates({ isoYear: 2026, isoWeek: 33 })).toEqual([
|
||
'2026-08-10',
|
||
'2026-08-11',
|
||
'2026-08-12',
|
||
'2026-08-13',
|
||
'2026-08-14',
|
||
'2026-08-15',
|
||
'2026-08-16',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('fuseau horaire', () => {
|
||
it('mesure le décalage de Paris en hiver et en été', () => {
|
||
expect(offsetAt(utc('2026-01-15T12:00:00Z'), 'Europe/Paris')).toBe(
|
||
3_600_000,
|
||
);
|
||
expect(offsetAt(utc('2026-07-15T12:00:00Z'), 'Europe/Paris')).toBe(
|
||
7_200_000,
|
||
);
|
||
});
|
||
|
||
it('place minuit local au bon instant absolu', () => {
|
||
expect(zonedMidnight('2026-08-10', 'Europe/Paris').toISOString()).toBe(
|
||
'2026-08-09T22:00:00.000Z',
|
||
);
|
||
expect(zonedMidnight('2026-01-05', 'Europe/Paris').toISOString()).toBe(
|
||
'2026-01-04T23:00:00.000Z',
|
||
);
|
||
});
|
||
|
||
it('tient au jour même du changement d’heure', () => {
|
||
// 25 octobre 2026 : retour à l'heure d'hiver. Minuit local est encore en
|
||
// UTC+2 ; une seule passe de correction donnerait une heure de trop.
|
||
expect(zonedMidnight('2026-10-25', 'Europe/Paris').toISOString()).toBe(
|
||
'2026-10-24T22:00:00.000Z',
|
||
);
|
||
expect(zonedMidnight('2026-03-29', 'Europe/Paris').toISOString()).toBe(
|
||
'2026-03-28T23:00:00.000Z',
|
||
);
|
||
});
|
||
|
||
it('mesure une semaine de changement d’heure à sa vraie durée', () => {
|
||
// La semaine du passage à l'heure d'hiver dure 169 h, pas 168. Un calcul
|
||
// en heures murales la croirait normale et fausserait tout compteur qui
|
||
// s'appuierait sur ses bornes.
|
||
const autumn = weekBounds({ isoYear: 2026, isoWeek: 43 }, 'Europe/Paris');
|
||
expect((autumn.to.getTime() - autumn.from.getTime()) / 3_600_000).toBe(169);
|
||
|
||
const spring = weekBounds({ isoYear: 2026, isoWeek: 13 }, 'Europe/Paris');
|
||
expect((spring.to.getTime() - spring.from.getTime()) / 3_600_000).toBe(167);
|
||
});
|
||
|
||
it('restitue la date et l’heure locales d’un instant', () => {
|
||
// 22 h UTC le 9 août, c'est déjà le 10 août à Paris : le créneau de lundi
|
||
// matin doit se ranger dans la colonne du lundi.
|
||
const instant = utc('2026-08-09T22:00:00Z');
|
||
expect(zonedDate(instant, 'Europe/Paris')).toBe('2026-08-10');
|
||
expect(zonedClock(instant, 'Europe/Paris')).toBe('00:00');
|
||
expect(zonedClock(utc('2026-08-10T07:30:00Z'), 'Europe/Paris')).toBe(
|
||
'09:30',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('libellés', () => {
|
||
it('compose les en-têtes de colonnes', () => {
|
||
expect(dayHeadings({ isoYear: 2026, isoWeek: 33 })).toEqual([
|
||
'Lun. 10',
|
||
'Mar. 11',
|
||
'Mer. 12',
|
||
'Jeu. 13',
|
||
'Ven. 14',
|
||
'Sam. 15',
|
||
'Dim. 16',
|
||
]);
|
||
});
|
||
|
||
it('compose le libellé de semaine', () => {
|
||
expect(weekLabel({ isoYear: 2026, isoWeek: 33 })).toBe(
|
||
'Semaine 33 · 10 – 16 août 2026',
|
||
);
|
||
});
|
||
|
||
it('nomme les deux mois quand la semaine est à cheval', () => {
|
||
expect(weekLabel({ isoYear: 2026, isoWeek: 31 })).toBe(
|
||
'Semaine 31 · 27 juillet – 2 août 2026',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('paramètre d’URL', () => {
|
||
it('fait un aller-retour', () => {
|
||
const week = { isoYear: 2026, isoWeek: 7 };
|
||
expect(parseWeekParam(formatWeekParam(week))).toEqual(week);
|
||
expect(formatWeekParam(week)).toBe('2026-W07');
|
||
});
|
||
|
||
it('rejette ce qui n’est pas une semaine', () => {
|
||
// Le paramètre vient de l'URL : il est fourni par le visiteur, donc
|
||
// toujours suspect. Une semaine 54 ne doit pas atteindre la base.
|
||
expect(parseWeekParam('2026-W54')).toBeNull();
|
||
expect(parseWeekParam('2026-W00')).toBeNull();
|
||
expect(parseWeekParam('août')).toBeNull();
|
||
expect(parseWeekParam(undefined)).toBeNull();
|
||
});
|
||
|
||
it('accepte la 53ᵉ semaine d’une année longue', () => {
|
||
expect(parseWeekParam('2026-W53')).toEqual({ isoYear: 2026, isoWeek: 53 });
|
||
expect(parseWeekParam('2025-W53')).toBeNull();
|
||
});
|
||
});
|