Author SHA1 Message Date
Antho af464cfcb7 test(front): étend la couverture de 8 à 41 tests
Les fonctionnalités ajoutées récemment n'étaient couvertes par aucun test,
et deux utilitaires historiques ne l'avaient jamais été.

- statutMonitoring : bornes du seuil testées de part et d'autre, là où une
  confusion entre < et <= se verrait.
- dates : protège la correction du décalage de fuseau (toISOString basculait
  en UTC, le 1er janvier local devenait le 31 décembre). Les helpers sont
  sortis de Historique.jsx vers utils/dates.js, un fichier de composant ne
  pouvant pas exporter autre chose sans casser le rafraîchissement à chaud.
- exportToCSV : échappement des virgules, guillemets et sauts de ligne, et
  conservation du zéro, qui est falsy alors que « 0 erreur » est justement
  l'information utile. Cet utilitaire sert quatre écrans et n'avait aucun test.
- Dashboard : les menus ne proposent que les valeurs ayant des résultats, les
  catégories se restreignent au service choisi, et une catégorie devenue sans
  résultat est réinitialisée. Vérifié par mutation : casser la cascade fait
  bien échouer deux tests.
- Admin : création, modification pré-remplie, confirmation avant suppression,
  remontée du refus 409 de l'API, désactivation et non suppression d'un
  monitoring, affichage des libellés à la place des identifiants.

Corrige un défaut révélé par ces tests : referentielsConfig capturait les
fonctions de l'API à l'import, figeant l'implémentation. Elles sont désormais
résolues à l'appel, ce qui découple la configuration du module d'API.
2026-08-15 15:02:00 +02:00
Antho b5e4741178 merge: intègre fix/tracabilite-build dans dev
Build & Deploy / build (push) Successful in 23s
2026-08-15 14:47:00 +02:00
Antho 4fd0f5dd37 chore(ci): expose le SHA du build sur /version.json
Rien ne permettait de vérifier quelle version du front était réellement
servie : un déploiement non appliqué était indiscernable d'un déploiement
réussi, d'autant que le cache navigateur masque le problème.

La CI injecte désormais le SHA du commit au build, écrit dans
dist/version.json et servi par nginx. Le pendant côté API est le champ
"build" de GET /health.
2026-08-15 14:47:00 +02:00
10 changed files with 488 additions and 37 deletions
+1
View File
@@ -23,6 +23,7 @@ jobs:
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker build --build-arg VITE_API_URL="${VITE_API_URL}" \
--build-arg APP_BUILD="${GITHUB_SHA::12}" \
-t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" ./DataSentinel
docker push --all-tags "${IMAGE}"
echo "pushed ${IMAGE}"
+5
View File
@@ -11,6 +11,11 @@ ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm build
# SHA du commit construit, servi sur /version.json : permet de vérifier quelle
# version est réellement déployée sans se fier au cache du navigateur.
ARG APP_BUILD=local
RUN printf '{"build":"%s"}' "$APP_BUILD" > /app/dist/version.json
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
+174
View File
@@ -0,0 +1,174 @@
/* Écran d'administration des référentiels.
Avant correction, les boutons Modifier / Désactiver n'appelaient rien
(console.log) et « Enregistrer » se contentait de fermer la modale. Ces
tests vérifient que chaque action atteint réellement l'API et que les
erreurs métier remontent à l'utilisateur. */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Admin from '../pages/Admin/Admin';
import * as api from '../services/api';
const SERVICES = [
{ id_service: 1, nom_service: 'Contrat' },
{ id_service: 2, nom_service: 'Fournisseur' },
];
const CATEGORIES = [{ id_categorie: 1, intitule_categorie: 'Tiers-payeurs' }];
const MONITORINGS = [{
id_monito: 1, monito_intitule: 'Erreurs tiers payeurs', id_service: 1,
id_categorie: 1, table_source: 'MONITO_TIERS_PAYEURS', bdd_source: 'Sage',
}];
function stubLectures() {
vi.spyOn(api, 'getServices').mockResolvedValue(SERVICES);
vi.spyOn(api, 'getCategories').mockResolvedValue(CATEGORIES);
vi.spyOn(api, 'getContacts').mockResolvedValue([]);
vi.spyOn(api, 'getMonitorings').mockResolvedValue(MONITORINGS);
}
async function ouvrirOnglet(utilisateur, nom) {
await utilisateur.click(screen.getByRole('button', { name: nom }));
}
describe('Admin — CRUD des référentiels', () => {
beforeEach(() => {
vi.restoreAllMocks();
stubLectures();
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
it('crée un service via lAPI et recharge la liste', async () => {
const creation = vi.spyOn(api, 'createService').mockResolvedValue({ id_service: 3 });
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
await utilisateur.click(screen.getByRole('button', { name: /Ajouter/ }));
await utilisateur.type(screen.getByLabelText('Nom du service'), 'Comptabilité');
await utilisateur.click(screen.getByRole('button', { name: 'Enregistrer' }));
await waitFor(() => {
expect(creation).toHaveBeenCalledWith({ nom_service: 'Comptabilité' });
});
/* Rechargement après écriture : la liste doit être redemandée. */
expect(api.getServices).toHaveBeenCalledTimes(2);
});
it('pré-remplit le formulaire de modification avec la ligne choisie', async () => {
vi.spyOn(api, 'updateService').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Fournisseur').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Modifier/ }));
expect(screen.getByLabelText('Nom du service')).toHaveValue('Fournisseur');
});
it('envoie lidentifiant de la ligne lors dune modification', async () => {
const modification = vi.spyOn(api, 'updateService').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Fournisseur').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Modifier/ }));
await utilisateur.click(screen.getByRole('button', { name: 'Enregistrer' }));
await waitFor(() => {
expect(modification).toHaveBeenCalledWith(2, { nom_service: 'Fournisseur' });
});
});
it('demande confirmation avant de supprimer, et nappelle rien si on refuse', async () => {
window.confirm.mockReturnValue(false);
const suppression = vi.spyOn(api, 'deleteService').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Contrat').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Supprimer/ }));
expect(window.confirm).toHaveBeenCalled();
expect(suppression).not.toHaveBeenCalled();
});
it('affiche le refus de lAPI quand des enregistrements sont rattachés', async () => {
/* Cas réel : l'API renvoie 409 avec le décompte des rattachements. */
vi.spyOn(api, 'deleteService').mockRejectedValue(
new Error('Erreur 409: Suppression impossible : 5 monitoring(s) rattachés.'),
);
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Contrat').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Supprimer/ }));
expect(await screen.findByText(/5 monitoring\(s\) rattachés/)).toBeInTheDocument();
});
it('parle de désactivation, pas de suppression, pour un monitoring', async () => {
/* TABLE_FINAL référence le monitoring : il n'est jamais supprimé. */
const desactivation = vi.spyOn(api, 'desactiverMonitoring').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
const ligne = screen.getByText('Erreurs tiers payeurs').closest('tr');
expect(within(ligne).getByRole('button', { name: /Désactiver/ })).toBeInTheDocument();
await utilisateur.click(within(ligne).getByRole('button', { name: /Désactiver/ }));
await waitFor(() => expect(desactivation).toHaveBeenCalledWith(1));
});
it('affiche le libellé du service plutôt que son identifiant brut', async () => {
render(<Admin />);
await screen.findByText('Administration');
const ligne = screen.getByText('Erreurs tiers payeurs').closest('tr');
/* id_service = 1 doit s'afficher « Contrat ». */
expect(within(ligne).getByText('Contrat')).toBeInTheDocument();
});
it('convertit en entier les identifiants issus des listes déroulantes', async () => {
/* Un <select> renvoie une chaîne ; l'API attend un entier. */
const creation = vi.spyOn(api, 'createMonitoring').mockResolvedValue({ id_monito: 9 });
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await utilisateur.click(screen.getByRole('button', { name: /Ajouter/ }));
await utilisateur.type(screen.getByLabelText('Nom'), 'Nouveau monitoring');
await utilisateur.selectOptions(screen.getByLabelText('Service'), '2');
await utilisateur.selectOptions(screen.getByLabelText('Catégorie'), '1');
await utilisateur.type(screen.getByLabelText('Table source'), 'MONITO_TEST');
await utilisateur.type(screen.getByLabelText('Base source'), 'Sage');
await utilisateur.click(screen.getByRole('button', { name: 'Enregistrer' }));
await waitFor(() => {
expect(creation).toHaveBeenCalledWith({
monito_intitule: 'Nouveau monitoring',
id_service: 2,
id_categorie: 1,
table_source: 'MONITO_TEST',
bdd_source: 'Sage',
});
});
});
});
@@ -0,0 +1,108 @@
/* Filtres du dashboard.
Le comportement testé ici est la correction apportée aux menus déroulants :
ils étaient alimentés par les référentiels SERVICE / CATEGORIE complets,
dont certaines entrées (« Business Intelligence ») n'ont aucun monitoring
rattaché et menaient sur un écran vide. */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import Dashboard from '../pages/Dashboard/Dashboard';
import * as api from '../services/api';
/* Ce que renvoie GET /dashboard/filtres : uniquement les valeurs présentes
dans VUE_CONSO. Contrat porte 2 catégories, Fournisseur une seule. */
const FILTRES = {
services: ['Contrat', 'Fournisseur'],
categories: ['Comptes fournisseurs', 'DOM-TOM', 'Tiers-payeurs'],
combinaisons: [
{ service: 'Contrat', categorie: 'DOM-TOM' },
{ service: 'Contrat', categorie: 'Tiers-payeurs' },
{ service: 'Fournisseur', categorie: 'Comptes fournisseurs' },
],
};
function renduDashboard() {
return render(<MemoryRouter><Dashboard /></MemoryRouter>);
}
describe('Dashboard — menus de filtre', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'getDashboardFiltres').mockResolvedValue(FILTRES);
vi.spyOn(api, 'getDashboardSummary').mockResolvedValue({
total: 3, ok: 0, critical: 3, total_erreurs: 12,
});
vi.spyOn(api, 'getDashboard').mockResolvedValue([]);
});
it('ne propose que les services ayant au moins un monitoring', async () => {
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
const options = [...selectService.options].map(o => o.value);
expect(options).toContain('Contrat');
expect(options).toContain('Fournisseur');
/* Le cœur du correctif : ces entrées du référentiel ne doivent pas
apparaître puisqu'elles ramèneraient un dashboard vide. */
expect(options).not.toContain('Business Intelligence');
expect(options).not.toContain('Comptabilité');
});
it('restreint les catégories au service sélectionné', async () => {
const utilisateur = userEvent.setup();
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
const selectCategorie = screen.getByDisplayValue('Toutes les catégories');
/* Sans filtre de service : toutes les catégories utilisées */
expect([...selectCategorie.options].map(o => o.value)).toEqual(
expect.arrayContaining(['DOM-TOM', 'Tiers-payeurs', 'Comptes fournisseurs']),
);
await utilisateur.selectOptions(selectService, 'Fournisseur');
await waitFor(() => {
const restantes = [...selectCategorie.options].map(o => o.value).filter(Boolean);
expect(restantes).toEqual(['Comptes fournisseurs']);
});
});
it('réinitialise la catégorie devenue sans résultat après changement de service', async () => {
const utilisateur = userEvent.setup();
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
const selectCategorie = screen.getByDisplayValue('Toutes les catégories');
await utilisateur.selectOptions(selectCategorie, 'DOM-TOM');
expect(selectCategorie.value).toBe('DOM-TOM');
/* DOM-TOM n'existe pas pour Fournisseur : garder la sélection
produirait une combinaison vide. */
await utilisateur.selectOptions(selectService, 'Fournisseur');
await waitFor(() => expect(selectCategorie.value).toBe(''));
});
it('interroge lAPI avec les filtres sélectionnés', async () => {
const utilisateur = userEvent.setup();
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
await utilisateur.selectOptions(selectService, 'Contrat');
await waitFor(() => {
expect(api.getDashboard).toHaveBeenLastCalledWith('Contrat', '');
});
});
it('affiche les KPI renvoyés par lAPI', async () => {
renduDashboard();
expect(await screen.findByText('12')).toBeInTheDocument();
expect(screen.getByText('Total erreurs')).toBeInTheDocument();
});
});
+50
View File
@@ -0,0 +1,50 @@
/* Formatage des bornes de période de la page Historique.
Ces tests protègent une régression précise : la version d'origine utilisait
toISOString(), qui convertit en UTC. Depuis un fuseau en avance sur UTC
(Paris = UTC+1 en hiver), le 1er janvier local devient le 31 décembre de
l'année précédente, et la période affichée est décalée d'un jour. */
import { describe, it, expect } from 'vitest';
import { formatDateISO, periodeParDefaut } from '../utils/dates';
describe('formatDateISO', () => {
it('formate une date en YYYY-MM-DD', () => {
expect(formatDateISO(new Date(2026, 7, 15))).toBe('2026-08-15');
});
it('complète le mois et le jour à deux chiffres', () => {
expect(formatDateISO(new Date(2026, 0, 5))).toBe('2026-01-05');
});
it('ne décale pas la date à minuit en heure locale', () => {
/* Le cas qui cassait : minuit local est la veille en UTC dès UTC+1. */
const premierJanvierMinuit = new Date(2026, 0, 1, 0, 0, 0);
expect(formatDateISO(premierJanvierMinuit)).toBe('2026-01-01');
});
it('ne décale pas non plus en fin de journée', () => {
/* Symétrique : 23h local bascule au lendemain pour les fuseaux négatifs. */
const finDeJournee = new Date(2026, 11, 31, 23, 30, 0);
expect(formatDateISO(finDeJournee)).toBe('2026-12-31');
});
});
describe('periodeParDefaut', () => {
it('démarre au 1er janvier de lannée de la date fournie', () => {
expect(periodeParDefaut(new Date(2026, 7, 15)).debut).toBe('2026-01-01');
});
it('se termine à la date fournie', () => {
expect(periodeParDefaut(new Date(2026, 7, 15)).fin).toBe('2026-08-15');
});
it('reste cohérente le 1er janvier (début = fin)', () => {
const periode = periodeParDefaut(new Date(2026, 0, 1));
expect(periode.debut).toBe('2026-01-01');
expect(periode.fin).toBe('2026-01-01');
});
it('suit le changement dannée', () => {
expect(periodeParDefaut(new Date(2027, 2, 9)).debut).toBe('2027-01-01');
});
});
@@ -0,0 +1,83 @@
/* Export CSV, utilisé par les pages Vue consolidée, Contrat, Fournisseur,
Historique et Détail monitoring. Les données exportées viennent de Sage et
du CRM : elles contiennent des intitulés avec virgules et apostrophes, d'où
l'importance de l'échappement. */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { exportToCSV } from '../services/api';
/* Capture le contenu du Blob passé à URL.createObjectURL, sans télécharger. */
function capturerCSV(data, filename) {
let contenu = '';
let nomFichier = '';
vi.stubGlobal('Blob', class {
constructor(parties) { contenu = parties.join(''); }
});
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(document, 'createElement').mockReturnValue({
set download(valeur) { nomFichier = valeur; },
get download() { return nomFichier; },
href: '',
click: () => {},
});
exportToCSV(data, filename);
return { contenu, nomFichier };
}
describe('exportToCSV', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('écrit une ligne den-têtes puis une ligne par enregistrement', () => {
const { contenu } = capturerCSV(
[{ nom: 'Tiers payeurs', erreurs: 5 }, { nom: 'DOM-TOM', erreurs: 3 }],
'export.csv',
);
const lignes = contenu.split('\n');
expect(lignes[0]).toBe('nom,erreurs');
expect(lignes[1]).toBe('Tiers payeurs,5');
expect(lignes[2]).toBe('DOM-TOM,3');
});
it('entoure de guillemets une valeur contenant une virgule', () => {
/* Sans échappement, la virgule créerait une colonne fantôme. */
const { contenu } = capturerCSV([{ intitule: 'XEFI Lyon, agence Est' }], 'x.csv');
expect(contenu.split('\n')[1]).toBe('"XEFI Lyon, agence Est"');
});
it('double les guillemets internes', () => {
const { contenu } = capturerCSV([{ intitule: 'Client "Test"' }], 'x.csv');
expect(contenu.split('\n')[1]).toBe('"Client ""Test"""');
});
it('protège une valeur contenant un saut de ligne', () => {
const { contenu } = capturerCSV([{ adresse: 'Ligne 1\nLigne 2' }], 'x.csv');
expect(contenu).toContain('"Ligne 1\nLigne 2"');
});
it('rend les valeurs nulles ou absentes par une cellule vide', () => {
const { contenu } = capturerCSV([{ a: null, b: undefined, c: 'ok' }], 'x.csv');
expect(contenu.split('\n')[1]).toBe(',,ok');
});
it('conserve le zéro au lieu de le traiter comme une valeur vide', () => {
/* 0 est falsy : un test de vérité naïf leffacerait alors que
« 0 erreur » est justement linformation utile. */
const { contenu } = capturerCSV([{ nb_erreurs: 0 }], 'x.csv');
expect(contenu.split('\n')[1]).toBe('0');
});
it('utilise le nom de fichier demandé', () => {
const { nomFichier } = capturerCSV([{ a: 1 }], 'historique_2026.csv');
expect(nomFichier).toBe('historique_2026.csv');
});
it('nexporte rien si la liste est vide', () => {
const creerElement = vi.spyOn(document, 'createElement');
exportToCSV([], 'vide.csv');
expect(creerElement).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
/* Règle métier de classement d'un monitoring selon son nombre d'erreurs.
Elle était dupliquée dans trois composants avant d'être centralisée : ces
tests fixent le contrat pour que les trois écrans restent cohérents. */
import { describe, it, expect } from 'vitest';
import { getStatutMonitoring, SEUIL_ATTENTION } from '../utils/statutMonitoring';
describe('getStatutMonitoring', () => {
it('classe OK un monitoring sans erreur', () => {
expect(getStatutMonitoring(0).label).toBe('OK');
expect(getStatutMonitoring(0).variant).toBe('ok');
});
it('classe Attention en dessous du seuil', () => {
expect(getStatutMonitoring(1).label).toBe('Attention');
expect(getStatutMonitoring(SEUIL_ATTENTION - 1).label).toBe('Attention');
});
it('bascule en Critique à partir du seuil, pas avant', () => {
/* La borne est le point où une erreur de type < / <= se verrait. */
expect(getStatutMonitoring(SEUIL_ATTENTION - 1).label).toBe('Attention');
expect(getStatutMonitoring(SEUIL_ATTENTION).label).toBe('Critique');
expect(getStatutMonitoring(SEUIL_ATTENTION + 1).label).toBe('Critique');
});
it('fournit toujours un libellé, une variante et une couleur', () => {
for (const nb of [0, 5, 50]) {
const statut = getStatutMonitoring(nb);
expect(statut.label).toBeTruthy();
expect(statut.variant).toBeTruthy();
expect(statut.color).toMatch(/^var\(--/);
}
});
});
@@ -1,9 +1,7 @@
import {
createService, updateService, deleteService,
createCategorie, updateCategorie, deleteCategorie,
createContact, updateContact, deleteContact,
createMonitoring, updateMonitoring, desactiverMonitoring,
} from '../../services/api';
/* Les appels API sont référencés via l'espace de noms et résolus au moment de
l'appel, et non capturés à l'import : la configuration ne fige donc pas une
implémentation, ce qui la découple du module d'API. */
import * as api from '../../services/api';
/* Description déclarative des 4 référentiels administrables.
Les colonnes du tableau, les champs du formulaire et les appels API sont
@@ -31,9 +29,9 @@ export const REFERENTIELS = [
{ name: 'table_source', label: 'Table source', type: 'text', required: true },
{ name: 'bdd_source', label: 'Base source', type: 'text', required: true },
],
creer: createMonitoring,
modifier: updateMonitoring,
supprimer: desactiverMonitoring,
creer: body => api.createMonitoring(body),
modifier: (id, body) => api.updateMonitoring(id, body),
supprimer: id => api.desactiverMonitoring(id),
},
{
key: 'services',
@@ -42,9 +40,9 @@ export const REFERENTIELS = [
idField: 'id_service',
colonnes: [{ key: 'nom_service', label: 'Nom' }],
champs: [{ name: 'nom_service', label: 'Nom du service', type: 'text', required: true }],
creer: createService,
modifier: updateService,
supprimer: deleteService,
creer: body => api.createService(body),
modifier: (id, body) => api.updateService(id, body),
supprimer: id => api.deleteService(id),
},
{
key: 'categories',
@@ -53,9 +51,9 @@ export const REFERENTIELS = [
idField: 'id_categorie',
colonnes: [{ key: 'intitule_categorie', label: 'Nom' }],
champs: [{ name: 'intitule_categorie', label: 'Intitulé', type: 'text', required: true }],
creer: createCategorie,
modifier: updateCategorie,
supprimer: deleteCategorie,
creer: body => api.createCategorie(body),
modifier: (id, body) => api.updateCategorie(id, body),
supprimer: id => api.deleteCategorie(id),
},
{
key: 'contacts',
@@ -76,9 +74,9 @@ export const REFERENTIELS = [
{ name: 'mail', label: 'Email', type: 'email', required: true },
{ name: 'id_service', label: 'Service', type: 'select', options: 'services', required: true },
],
creer: createContact,
modifier: updateContact,
supprimer: deleteContact,
creer: body => api.createContact(body),
modifier: (id, body) => api.updateContact(id, body),
supprimer: id => api.deleteContact(id),
},
];
@@ -8,27 +8,9 @@ import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button';
import { periodeParDefaut } from '../../utils/dates';
import styles from './Historique.module.css';
/* Formate une date au format attendu par <input type="date"> (YYYY-MM-DD).
On lit les composantes en heure locale plutôt que d'utiliser toISOString(),
qui bascule en UTC : un 1er janvier saisi depuis Paris (UTC+1) y devient le
31 décembre de l'année précédente. */
function formatDateISO(date) {
const mois = String(date.getMonth() + 1).padStart(2, '0');
const jour = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mois}-${jour}`;
}
/* Période affichée par défaut : du 1er janvier de l'année courante à aujourd'hui. */
function periodeParDefaut() {
const aujourdhui = new Date();
return {
debut: formatDateISO(new Date(aujourdhui.getFullYear(), 0, 1)),
fin: formatDateISO(aujourdhui),
};
}
/* Récupère le tableau d'historique et la série du graphique pour les filtres
donnés. Sans monitoring sélectionné, on prend la courbe globale.
Fonction pure : elle ne touche à aucun état, ce qui permet de l'appeler
+17
View File
@@ -0,0 +1,17 @@
/* Formate une date au format attendu par <input type="date"> (YYYY-MM-DD).
On lit les composantes en heure locale plutôt que d'utiliser toISOString(),
qui bascule en UTC : un 1er janvier saisi depuis Paris (UTC+1) y devient le
31 décembre de l'année précédente. */
export function formatDateISO(date) {
const mois = String(date.getMonth() + 1).padStart(2, '0');
const jour = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mois}-${jour}`;
}
/* Période affichée par défaut : du 1er janvier de l'année courante à aujourd'hui. */
export function periodeParDefaut(aujourdhui = new Date()) {
return {
debut: formatDateISO(new Date(aujourdhui.getFullYear(), 0, 1)),
fin: formatDateISO(aujourdhui),
};
}