diff --git a/src/app/(app)/bientot/page.tsx b/src/app/(app)/bientot/page.tsx new file mode 100644 index 0000000..3c40457 --- /dev/null +++ b/src/app/(app)/bientot/page.tsx @@ -0,0 +1,25 @@ +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { EmptyState } from '@/components/ui/Card'; + +export const metadata = { title: 'Écran à venir · PlanFlow' }; + +export default async function BientotPage({ + searchParams, +}: { + searchParams: Promise<{ ecran?: string }>; +}) { + const { ecran } = await searchParams; + const label = ecran ?? 'Cet écran'; + + return ( + + +
+ +
+
+ ); +} diff --git a/src/app/(app)/conges/page.tsx b/src/app/(app)/conges/page.tsx new file mode 100644 index 0000000..0171e7c --- /dev/null +++ b/src/app/(app)/conges/page.tsx @@ -0,0 +1,148 @@ +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader } from '@/components/ui/Card'; +import { cx } from '@/lib/cx'; +import { + CALENDAR_ABSENCES, + MONTH_LABEL, + MONTH_LENGTH, + MONTH_OFFSET, + PENDING_REQUESTS, +} from '@/lib/demo/conges'; + +export const metadata = { title: 'Calendrier des absences · PlanFlow' }; + +const WEEKDAYS = [ + 'Lundi', + 'Mardi', + 'Mercredi', + 'Jeudi', + 'Vendredi', + 'Samedi', + 'Dimanche', +]; + +const KIND_STYLES: Record = { + cp: 'bg-info-soft text-info-soft-ink border-info', + rtt: 'bg-accent-soft text-accent-soft-ink border-accent', + maladie: 'bg-surface-3 text-ink-2 border-line-3', + 'sans-solde': 'bg-surface-2 text-ink-2 border-line-3', + attente: 'bg-warn-soft text-warn-soft-ink border-warn', +}; + +export default function CongesPage() { + const cells = Array.from({ length: 42 }, (_, index) => { + const dayNumber = index - MONTH_OFFSET + 1; + const inMonth = dayNumber >= 1 && dayNumber <= MONTH_LENGTH; + const items = inMonth + ? CALENDAR_ABSENCES.filter( + (absence) => dayNumber >= absence.from && dayNumber <= absence.to, + ) + : []; + return { index, dayNumber, inMonth, items, weekend: index % 7 > 4 }; + }); + + return ( + + + + + + } + /> + + +
+ {WEEKDAYS.map((day) => ( +
+ {day} +
+ ))} +
+
+ {cells.map((cell) => ( +
+ + {cell.inMonth ? cell.dayNumber : ''} + +
    + {cell.items.slice(0, 3).map((absence) => ( +
  • + {absence.who.split(' ')[0]} · {absence.type} +
  • + ))} + {cell.items.length > 3 ? ( +
  • + +{cell.items.length - 3} +
  • + ) : null} +
+
+ ))} +
+
+ + + + {PENDING_REQUESTS.length}} + /> +
    + {PENDING_REQUESTS.map((request) => ( +
  • + + + {request.who} · {request.type} + + + {request.note} + + + + {request.range} + + {request.days} + + + + +
  • + ))} +
+
+
+ ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx new file mode 100644 index 0000000..a20dbd1 --- /dev/null +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -0,0 +1,147 @@ +import { notFound } from 'next/navigation'; + +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card, CardHeader } from '@/components/ui/Card'; +import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes'; +import { EMPLOYEES, findEmployee } from '@/lib/demo/equipe'; +import { + FICHE_COUNTERS, + FICHE_DOCUMENTS, + FICHE_SHIFTS, +} from '@/lib/demo/fiche'; +import { fullName, initials } from '@/lib/demo/types'; + +export function generateStaticParams() { + return EMPLOYEES.map((employee) => ({ id: employee.id })); +} + +export default async function FichePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const employee = findEmployee(id); + if (!employee) notFound(); + + return ( + + + + + + } + /> + +
+ + {initials(employee)} + +
+

{employee.contract}

+

+ Poste principal · {POSTE_LABELS[employee.poste]} +

+
+ + {employee.forfaitJours ? ( + Forfait jours · 218 j + ) : null} +
    + {FICHE_COUNTERS.map((counter) => ( +
  • + + {counter.label} + + + {counter.value} + +
  • + ))} +
+
+ +
+ + + Voir le planning + + } + /> +
    + {FICHE_SHIFTS.map((shift, index) => { + const tokens = posteTokens(shift.poste); + return ( +
  • + + {shift.day} + + + {posteShort(shift.poste)} + + {shift.time} + + {shift.worked} + + {shift.state} +
  • + ); + })} +
+
+ + + +
    + {FICHE_DOCUMENTS.map((document) => ( +
  • + + + {document.label} + + + {document.date} + + + {document.state} +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx new file mode 100644 index 0000000..ad06049 --- /dev/null +++ b/src/app/(app)/equipe/page.tsx @@ -0,0 +1,118 @@ +import Link from 'next/link'; + +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge, type Tone } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes'; +import { EMPLOYEES } from '@/lib/demo/equipe'; +import { fullName, initials } from '@/lib/demo/types'; + +export const metadata = { title: 'Équipe · PlanFlow' }; + +const STATUS: Record = { + actif: { label: 'Actif', tone: 'ok' }, + essai: { label: 'Période d’essai', tone: 'info' }, + sortie: { label: 'Sortie prévue', tone: 'warn' }, +}; + +export default function EquipePage() { + const actifs = EMPLOYEES.filter((e) => e.status !== 'sortie').length; + const essai = EMPLOYEES.filter((e) => e.status === 'essai').length; + + return ( + + + + + + } + /> + +
+ + + + + + + + + + + + {EMPLOYEES.map((employee) => { + const tokens = posteTokens(employee.poste); + const status = STATUS[employee.status] ?? STATUS.actif!; + return ( + + + + + + + + ); + })} + +
+ Collaborateur + + Poste principal + + Contrat + + Entrée + + Statut +
+ + + {initials(employee)} + + + + {fullName(employee)} + + + {employee.job} + + + + + + + {posteShort(employee.poste)} + + {POSTE_LABELS[employee.poste]} + + + {employee.contract} + + {employee.since} + + {status.label} +
+
+
+ ); +} diff --git a/src/app/(app)/planning/jour/page.tsx b/src/app/(app)/planning/jour/page.tsx new file mode 100644 index 0000000..e86fe87 --- /dev/null +++ b/src/app/(app)/planning/jour/page.tsx @@ -0,0 +1,48 @@ +import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { UNASSIGNED_ROW, WEEK_ROWS } from '@/lib/demo/semaine'; + +export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' }; + +/** Mercredi = index 2 dans la semaine de démonstration. */ +const DAY_INDEX = 2; + +export default function JourPage() { + const lanes: DayLane[] = WEEK_ROWS.map((row) => ({ + employee: row.employee, + shifts: row.days[DAY_INDEX] ?? [], + })).filter((lane) => lane.shifts.length > 0); + + const unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? []; + if (unassigned.length > 0) { + lanes.push({ + employee: UNASSIGNED_ROW.employee, + shifts: unassigned, + unassigned: true, + }); + } + + return ( + + + + + + } + /> + +
+ {lanes.length} salariés présents + Creux de couverture à 13 h +
+ + +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index bd6e860..21b3b22 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -142,6 +142,117 @@ --post-net-t: 0; --post-for-h: 68; --post-for-t: 0.045; + + /* Dérivation : une seule formule pour les douze postes. Ajouter un poste + revient à déclarer sa teinte et son palier, puis ces trois lignes. */ + --post-cai-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-cai-t)) var(--chip-bg-c) + var(--post-cai-h) + ); + --post-cai-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-cai-h)); + --post-cai-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-cai-t)) var(--chip-edge-c) + var(--post-cai-h) + ); + --post-vte-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-vte-t)) var(--chip-bg-c) + var(--post-vte-h) + ); + --post-vte-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-vte-h)); + --post-vte-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-vte-t)) var(--chip-edge-c) + var(--post-vte-h) + ); + --post-res-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-res-t)) var(--chip-bg-c) + var(--post-res-h) + ); + --post-res-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-res-h)); + --post-res-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-res-t)) var(--chip-edge-c) + var(--post-res-h) + ); + --post-acc-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-acc-t)) var(--chip-bg-c) + var(--post-acc-h) + ); + --post-acc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-acc-h)); + --post-acc-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-acc-t)) var(--chip-edge-c) + var(--post-acc-h) + ); + --post-cab-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-cab-t)) var(--chip-bg-c) + var(--post-cab-h) + ); + --post-cab-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-cab-h)); + --post-cab-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-cab-t)) var(--chip-edge-c) + var(--post-cab-h) + ); + --post-rss-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-rss-t)) var(--chip-bg-c) + var(--post-rss-h) + ); + --post-rss-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-rss-h)); + --post-rss-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-rss-t)) var(--chip-edge-c) + var(--post-rss-h) + ); + --post-inv-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-inv-t)) var(--chip-bg-c) + var(--post-inv-h) + ); + --post-inv-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-inv-h)); + --post-inv-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-inv-t)) var(--chip-edge-c) + var(--post-inv-h) + ); + --post-liv-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-liv-t)) var(--chip-bg-c) + var(--post-liv-h) + ); + --post-liv-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-liv-h)); + --post-liv-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-liv-t)) var(--chip-edge-c) + var(--post-liv-h) + ); + --post-enc-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-enc-t)) var(--chip-bg-c) + var(--post-enc-h) + ); + --post-enc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-enc-h)); + --post-enc-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-enc-t)) var(--chip-edge-c) + var(--post-enc-h) + ); + --post-clc-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-clc-t)) var(--chip-bg-c) + var(--post-clc-h) + ); + --post-clc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-clc-h)); + --post-clc-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-clc-t)) var(--chip-edge-c) + var(--post-clc-h) + ); + --post-net-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-net-t)) var(--chip-bg-c) + var(--post-net-h) + ); + --post-net-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-net-h)); + --post-net-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-net-t)) var(--chip-edge-c) + var(--post-net-h) + ); + --post-for-bg: oklch( + calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-for-t)) var(--chip-bg-c) + var(--post-for-h) + ); + --post-for-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-for-h)); + --post-for-edge: oklch( + calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-for-t)) var(--chip-edge-c) + var(--post-for-h) + ); } /* Thème sombre : redéfinit les mêmes variables. --tier-dir s'inverse pour que diff --git a/src/components/planning/DayTimeline.tsx b/src/components/planning/DayTimeline.tsx new file mode 100644 index 0000000..a43425d --- /dev/null +++ b/src/components/planning/DayTimeline.tsx @@ -0,0 +1,176 @@ +import { posteShort, posteTokens } from '@/lib/design/postes'; +import { cx } from '@/lib/cx'; +import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types'; + +export interface DayLane { + employee: DemoEmployee; + shifts: DemoShift[]; + unassigned?: boolean; +} + +export interface DayTimelineProps { + lanes: DayLane[]; + /** Amplitude affichée, en heures locales. */ + fromHour: number; + toHour: number; +} + +/** + * Vue jour : une chronologie horizontale par salarié. + * + * Là où la vue semaine répond à « qui travaille combien », celle-ci répond à + * « qui est présent à 14 h » — d'où la bande d'effectif sous l'échelle, qui + * rend les creux de couverture visibles sans avoir à lire chaque ligne. + */ +export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) { + const span = toHour - fromHour; + const hours = Array.from({ length: span + 1 }, (_, i) => fromHour + i); + + const coverage = hours.slice(0, -1).map((hour) => { + const start = hour * 60; + const end = start + 60; + return lanes.filter( + (lane) => + !lane.unassigned && + lane.shifts.some((shift) => shift.start < end && shift.end > start), + ).length; + }); + const peak = Math.max(1, ...coverage); + + const offset = (minutes: number) => + `${((minutes - fromHour * 60) / (span * 60)) * 100}%`; + + return ( +
+
+
+
+ Salarié +
+
+ {hours.map((hour) => ( + + {String(hour).padStart(2, '0')}h + + ))} +
+
+
+ +
+
+ Effectif présent +
+
+ {coverage.map((count, index) => ( +
1 ? 's' : ''} à ${String(hours[index]).padStart(2, '0')} h`} + > + + {count} + +
+ ))} +
+
+ + {lanes.map((lane) => ( +
+
+ + {lane.unassigned ? '?' : initials(lane.employee)} + + + + {lane.unassigned + ? 'Non assigné' + : `${lane.employee.firstName} ${lane.employee.lastName}`} + + + {lane.employee.job} + + +
+ +
+ {hours.slice(1, -1).map((hour) => ( + + ))} + + {lane.shifts.map((shift, index) => { + const tokens = posteTokens(shift.poste); + const ghost = shift.state === 'unassigned'; + return ( +
+ + {posteShort(shift.poste)} + + + {clock(shift.start)}–{clock(shift.end)} + +
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/shell/ThemeToggle.tsx b/src/components/shell/ThemeToggle.tsx index fdc7511..10d36fd 100644 --- a/src/components/shell/ThemeToggle.tsx +++ b/src/components/shell/ThemeToggle.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useSyncExternalStore } from 'react'; import { Button } from '@/components/ui/Button'; @@ -29,20 +29,34 @@ export const THEME_INIT_SCRIPT = ` })(); `.trim(); -function readTheme(): Theme { - if (typeof document === 'undefined') return 'light'; +/** + * L'attribut `data-theme` du document est la source de vérité : il est posé par + * le script ci-dessus avant l'hydratation, donc avant que React n'existe. + * On s'y abonne plutôt que d'en tenir une copie, qui serait fausse au premier + * rendu et devrait être rattrapée après coup. + */ +function subscribe(onChange: () => void): () => void { + const observer = new MutationObserver(onChange); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + return () => observer.disconnect(); +} + +function getSnapshot(): Theme { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; } -export function ThemeToggle() { - const [theme, setTheme] = useState('light'); +/** Le serveur rend toujours le thème clair, que le script corrige aussitôt. */ +function getServerSnapshot(): Theme { + return 'light'; +} - // Le thème réel est posé par THEME_INIT_SCRIPT avant l'hydratation ; on le - // lit après montage plutôt que de le deviner au rendu serveur, où - // localStorage n'existe pas. - useEffect(() => setTheme(readTheme()), []); +export function ThemeToggle() { + const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); function toggle() { const next: Theme = theme === 'dark' ? 'light' : 'dark'; @@ -51,9 +65,8 @@ export function ThemeToggle() { localStorage.setItem(THEME_STORAGE_KEY, next); } catch { // Stockage indisponible (navigation privée) : le thème reste valable - // pour la session, ce qui est préférable à une erreur. + // pour la session, ce qui vaut mieux qu'une erreur. } - setTheme(next); } return ( diff --git a/src/lib/demo/conges.ts b/src/lib/demo/conges.ts new file mode 100644 index 0000000..1585abf --- /dev/null +++ b/src/lib/demo/conges.ts @@ -0,0 +1,58 @@ +import type { AbsenceKind } from '@/components/planning/AbsenceBar'; + +export interface DemoCalendarAbsence { + who: string; + type: string; + kind: AbsenceKind; + /** Jours du mois, inclusifs. */ + from: number; + to: number; +} + +export const MONTH_LABEL = 'Août 2026'; + +/** Le 1er août 2026 tombe un samedi : la grille démarre le lundi 27 juillet. */ +export const MONTH_OFFSET = 5; +export const MONTH_LENGTH = 31; + +export const CALENDAR_ABSENCES: DemoCalendarAbsence[] = [ + { who: 'Nour Zaidi', type: 'CP', kind: 'cp', from: 10, to: 14 }, + { who: 'Malik Ouedraogo', type: 'Maladie', kind: 'maladie', from: 13, to: 14 }, + { who: 'Rémi Chartier', type: 'RTT', kind: 'rtt', from: 18, to: 19 }, + { who: 'Elsa Tavares', type: 'CP', kind: 'cp', from: 24, to: 28 }, + { who: 'Inès Bakhti', type: 'Sans solde', kind: 'sans-solde', from: 21, to: 21 }, + { who: 'Alix Perron', type: 'Maladie', kind: 'maladie', from: 5, to: 6 }, + { who: 'Chloé Vasseur', type: 'CP', kind: 'cp', from: 26, to: 28 }, +]; + +export interface DemoPendingRequest { + who: string; + type: string; + range: string; + days: string; + note: string; +} + +export const PENDING_REQUESTS: DemoPendingRequest[] = [ + { + who: 'Nour Zaidi', + type: 'Congés payés', + range: '10 – 14 août', + days: '5 j', + note: 'Solde restant après validation : 9,5 j', + }, + { + who: 'Rémi Chartier', + type: 'RTT', + range: '18 – 19 août', + days: '2 j', + note: 'Chevauche un jour de forte affluence', + }, + { + who: 'Alix Perron', + type: 'Sans solde', + range: '31 août', + days: '1 j', + note: 'Aucune contrainte de planning', + }, +]; diff --git a/src/lib/demo/fiche.ts b/src/lib/demo/fiche.ts new file mode 100644 index 0000000..dd6d43b --- /dev/null +++ b/src/lib/demo/fiche.ts @@ -0,0 +1,96 @@ +import type { Tone } from '@/components/ui/Badge'; +import type { PosteCode } from '@/lib/design/postes'; + +export interface FicheShift { + day: string; + poste: PosteCode; + time: string; + worked: string; + state: string; + tone: Tone; +} + +export const FICHE_SHIFTS: FicheShift[] = [ + { + day: 'Lun. 10 août', + poste: 'enc', + time: '08:00–16:00', + worked: '8 h 00', + state: 'Validé', + tone: 'ok', + }, + { + day: 'Mar. 11 août', + poste: 'enc', + time: '08:00–16:00', + worked: '8 h 00', + state: 'Validé', + tone: 'ok', + }, + { + day: 'Mer. 12 août', + poste: 'for', + time: '09:00–12:30', + worked: '3 h 30', + state: 'Validé', + tone: 'ok', + }, + { + day: 'Mer. 12 août', + poste: 'enc', + time: '13:30–17:00', + worked: '3 h 30', + state: 'Heures réelles manquantes', + tone: 'warn', + }, + { + day: 'Jeu. 13 août', + poste: 'enc', + time: '11:00–19:00', + worked: '8 h 00', + state: 'Publié', + tone: 'neutral', + }, +]; + +export interface FicheDocument { + label: string; + date: string; + state: string; + tone: Tone; +} + +export const FICHE_DOCUMENTS: FicheDocument[] = [ + { + label: 'Contrat de travail — avenant temps partiel', + date: '2 janv. 2026', + state: 'Signé', + tone: 'ok', + }, + { + label: 'Entretien professionnel 2026', + date: '18 mai 2026', + state: 'Signé', + tone: 'ok', + }, + { + label: 'Attestation mutuelle', + date: '—', + state: 'En attente', + tone: 'warn', + }, +]; + +export interface FicheCounter { + label: string; + value: string; + tone?: Tone; +} + +export const FICHE_COUNTERS: FicheCounter[] = [ + { label: 'Contrat', value: '39 h' }, + { label: 'Planifié S33', value: '39 h 00' }, + { label: 'Écart', value: '+0 h 00' }, + { label: 'CP acquis', value: '14,5 j' }, + { label: 'Récupération', value: '4 h 30', tone: 'warn' }, +]; diff --git a/tests/unit/counters.test.ts b/tests/unit/counters.test.ts new file mode 100644 index 0000000..62f2f6f --- /dev/null +++ b/tests/unit/counters.test.ts @@ -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); + }); +}); diff --git a/tests/unit/postes.test.ts b/tests/unit/postes.test.ts new file mode 100644 index 0000000..26ac07d --- /dev/null +++ b/tests/unit/postes.test.ts @@ -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'); + }); +});