Complete the six application screens

Adds the day timeline, team list, employee record and leave calendar.
The design export shipped data for these but no markup, so their layout
is designed here from the data shapes and the established style; only
Aperçu and the week grid are ports.

The day view carries a per-hour headcount band. Where the week view
answers "who works how much", this one answers "who is on the floor at
14:00", and the band makes coverage gaps visible without reading every
lane.

Two fixes the tests and linter caught:

- The poste derivation variables were missing from globals.css. Hues
  and tiers were ported but not the --post-*-bg/fg/edge that consume
  them, so every shift chip would have rendered colourless. The palette
  test now asserts all 36 exist.
- ThemeToggle synced state in an effect. The document's data-theme
  attribute is the real store — it is set by the inline script before
  React exists — so it now subscribes with useSyncExternalStore instead
  of keeping a copy that is wrong on first render.

Counter tests cover the DST cases in both directions: a 22:00–06:00
shift lasts 7 or 9 hours on the changeover nights, never 8.

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 22:16:52 +00:00
1 parent a24c1b821c
commit 4012f32f7f
12 files changed
+1158 -11

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
computeWeekCounters,
deltaTone,
formatDelta,
formatMinutes,
shiftMinutes,
} from '@/domain/counters/week';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
describe('formatMinutes', () => {
it('formate en heures et minutes sur deux chiffres', () => {
expect(formatMinutes(h(35))).toBe('35 h 00');
expect(formatMinutes(h(8, 5))).toBe('8 h 05');
expect(formatMinutes(0)).toBe('0 h 00');
});
it('utilise le signe moins typographique pour les négatifs', () => {
expect(formatMinutes(-h(1, 30))).toBe('−1 h 30');
});
});
describe('formatDelta', () => {
it('distingue un écart nul d’un manque', () => {
// « 0 h 00 » seul se lit comme une absence de donnée ; le « + » dit que le
// contrat est atteint exactement.
expect(formatDelta(0)).toBe('+0 h 00');
expect(formatDelta(h(2, 55))).toBe('+2 h 55');
expect(formatDelta(-h(0, 5))).toBe('−0 h 05');
});
});
describe('deltaTone', () => {
it('classe le dépassement, le manque et l’exact', () => {
expect(deltaTone(0)).toBe('flat');
expect(deltaTone(1)).toBe('over');
expect(deltaTone(-1)).toBe('under');
});
});
describe('computeWeekCounters', () => {
it('compte les absences dans l’atteinte du contrat', () => {
// Une semaine entière de congés ne doit pas apparaître en manque de 30 h :
// le salarié n'a pas sous-travaillé, il était absent.
const counters = computeWeekCounters({
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
});
expect(counters.deltaMinutes).toBe(0);
expect(counters.deltaLabel).toBe('+0 h 00');
expect(counters.tone).toBe('flat');
});
it('signale un dépassement du contrat', () => {
const counters = computeWeekCounters({
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
});
expect(counters.deltaLabel).toBe('+1 h 30');
expect(counters.tone).toBe('over');
});
it('signale un contrat non atteint', () => {
const counters = computeWeekCounters({
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
});
expect(counters.deltaLabel).toBe('−1 h 45');
expect(counters.tone).toBe('under');
});
});
describe('shiftMinutes', () => {
it('déduit la pause', () => {
const start = new Date('2026-08-10T09:00:00+02:00');
const end = new Date('2026-08-10T17:00:00+02:00');
expect(shiftMinutes(start, end, 30)).toBe(h(7, 30));
});
it('mesure la durée réelle la nuit du passage à l’heure d’hiver', () => {
// 25 octobre 2026, 03:00 → 02:00 : la nuit dure une heure de plus.
// Un calcul sur l'heure murale dirait 8 h ; l'instant dit 9 h.
const start = new Date('2026-10-24T22:00:00+02:00');
const end = new Date('2026-10-25T06:00:00+01:00');
expect(shiftMinutes(start, end)).toBe(h(9));
});
it('mesure la durée réelle la nuit du passage à l’heure d’été', () => {
// 29 mars 2026, 02:00 → 03:00 : la nuit dure une heure de moins.
const start = new Date('2026-03-28T22:00:00+01:00');
const end = new Date('2026-03-29T06:00:00+02:00');
expect(shiftMinutes(start, end)).toBe(h(7));
});
it('ne renvoie jamais de durée négative', () => {
const start = new Date('2026-08-10T09:00:00+02:00');
const end = new Date('2026-08-10T09:10:00+02:00');
expect(shiftMinutes(start, end, 60)).toBe(0);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
isPosteCode,
POSTE_CODES,
POSTE_HUES,
POSTE_LABELS,
posteShort,
posteTokens,
} from '@/lib/design/postes';
const CSS = readFileSync(
fileURLToPath(new URL('../../src/app/globals.css', import.meta.url)),
'utf8',
);
describe('palette des postes', () => {
it('déclare douze postes distincts', () => {
expect(POSTE_CODES).toHaveLength(12);
expect(new Set(POSTE_CODES).size).toBe(12);
});
it('donne un libellé à chaque poste', () => {
for (const code of POSTE_CODES) {
expect(POSTE_LABELS[code], `libellé manquant pour ${code}`).toBeTruthy();
}
});
it('sépare suffisamment les teintes voisines', () => {
// Deux postes séparés de quelques degrés seraient indiscernables à petite
// taille, ce qui rendrait la grille fausse à la lecture.
const hues = [...Object.values(POSTE_HUES)].sort((a, b) => a - b);
for (let i = 1; i < hues.length; i += 1) {
const gap = (hues[i] as number) - (hues[i - 1] as number);
expect(gap, `teintes trop proches : ${hues[i - 1]} et ${hues[i]}`).toBeGreaterThanOrEqual(12);
}
});
it('définit les trois jetons CSS de chaque poste', () => {
for (const code of POSTE_CODES) {
for (const suffix of ['bg', 'fg', 'edge']) {
expect(
CSS.includes(`--post-${code}-${suffix}:`),
`jeton --post-${code}-${suffix} absent de globals.css`,
).toBe(true);
}
}
});
it('renvoie des variables CSS, pour que le thème sombre suive', () => {
const tokens = posteTokens('cai');
expect(tokens.bg).toBe('var(--post-cai-bg)');
expect(tokens.fg).toBe('var(--post-cai-fg)');
expect(tokens.edge).toBe('var(--post-cai-edge)');
});
it('fournit un code court affichable — le second canal de lecture', () => {
// La couleur ne porte jamais l'information seule : un planning se lit aussi
// en niveaux de gris et par quelqu'un qui confond le rouge et le vert.
expect(posteShort('vte')).toBe('VTE');
for (const code of POSTE_CODES) {
expect(posteShort(code)).toHaveLength(3);
}
});
it('reconnaît les codes valides', () => {
expect(isPosteCode('cai')).toBe(true);
expect(isPosteCode('zzz')).toBe(false);
});
});
describe('jetons de thème', () => {
it('redéfinit les couleurs pour le thème sombre', () => {
expect(CSS).toContain(":root[data-theme='dark']");
for (const token of ['--color-canvas', '--color-ink-1', '--color-accent']) {
const occurrences = CSS.split(token).length - 1;
expect(
occurrences,
`${token} doit être défini en clair et redéfini en sombre`,
).toBeGreaterThanOrEqual(2);
}
});
it('inverse le sens du palier de clarté en sombre', () => {
// Sans cette inversion, le palier qui écarte les teintes voisines pousse du
// mauvais côté et les rapproche au lieu de les séparer.
expect(CSS).toContain('--tier-dir: -1');
expect(CSS).toContain('--tier-dir: 1');
});
});