diff --git a/countdown.php b/countdown.php
index ca8701d..685c826 100644
--- a/countdown.php
+++ b/countdown.php
@@ -20,7 +20,7 @@ setSecurityHeaders();
-
+
@@ -40,9 +40,7 @@ setSecurityHeaders();
@@ -54,15 +52,19 @@ setSecurityHeaders();
-
+
@@ -99,10 +101,8 @@ setSecurityHeaders();
-
La plateforme ouvrira ses portes le getTimestamp());
+ La plateforme ouvrira ses portes le (heure de La Réunion).
diff --git a/includes/structured-data.php b/includes/structured-data.php
index a4f8b12..83421e4 100644
--- a/includes/structured-data.php
+++ b/includes/structured-data.php
@@ -430,22 +430,62 @@ function formatDateISO8601($dateString) {
}
/**
- * Tronque un texte à une longueur donnée
+ * Formate une date en français (remplace strftime, déprécié depuis PHP 8.1)
+ *
+ * Utilise IntlDateFormatter quand l'extension intl est disponible,
+ * sinon replie sur un formatage manuel (mois français en toutes lettres).
+ *
+ * @param DateTimeInterface $date Date à formater
+ * @param bool $withTime true pour ajouter « à HH:mm »
+ * @return string Date formatée, ex. « 11 octobre 2025 » ou « 11 octobre 2025 à 00:00 »
+ */
+function formatDateFr($date, $withTime = false) {
+ if (class_exists('IntlDateFormatter')) {
+ $formatter = new IntlDateFormatter(
+ 'fr_FR',
+ IntlDateFormatter::FULL,
+ IntlDateFormatter::FULL,
+ $date->getTimezone(), // Le formateur ignore le fuseau du DateTime sans ça
+ IntlDateFormatter::GREGORIAN,
+ $withTime ? "d MMMM yyyy 'à' HH:mm" : 'd MMMM yyyy'
+ );
+ $formatted = $formatter->format($date);
+ if ($formatted !== false) {
+ return $formatted;
+ }
+ }
+
+ // Repli sans extension intl : mois français en toutes lettres
+ $months = [
+ 1 => 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
+ 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
+ ];
+ $formatted = $date->format('j') . ' ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
+
+ if ($withTime) {
+ $formatted .= ' à ' . $date->format('H:i');
+ }
+
+ return $formatted;
+}
+
+/**
+ * Tronque un texte à une longueur donnée (en caractères UTF-8)
*
* @param string $text Texte à tronquer
* @param int $length Longueur maximale
* @return string Texte tronqué
*/
function truncateText($text, $length = 200) {
- if (strlen($text) <= $length) {
+ if (mb_strlen($text) <= $length) {
return $text;
}
- $truncated = substr($text, 0, $length);
- $lastSpace = strrpos($truncated, ' ');
+ $truncated = mb_substr($text, 0, $length);
+ $lastSpace = mb_strrpos($truncated, ' ');
if ($lastSpace !== false) {
- $truncated = substr($truncated, 0, $lastSpace);
+ $truncated = mb_substr($truncated, 0, $lastSpace);
}
return $truncated . '...';
diff --git a/js/countdown.js b/js/countdown.js
index b2d8862..d7f98b8 100644
--- a/js/countdown.js
+++ b/js/countdown.js
@@ -2,9 +2,58 @@
* Script de compte à rebours pour la page de maintenance
*/
+/**
+ * Convertit une date ISO 8601 en timestamp, compatible Safari.
+ *
+ * Safari renvoie Invalid Date pour les formats non strictement ISO
+ * (ex. « 2025-10-11 00:00:00 » émis historiquement par PHP) : la chaîne
+ * est donc analysée manuellement. Sans fuseau explicite, la date est
+ * interprétée en UTC — PHP émet de toute façon toujours un décalage.
+ *
+ * @param {string|number} value Date ISO 8601 (ou timestamp déjà numérique)
+ * @returns {number} Timestamp en millisecondes
+ */
+function parseTargetDate(value) {
+ if (typeof value === 'number') {
+ return new Date(value).getTime();
+ }
+
+ const match = String(value).match(
+ /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/
+ );
+
+ if (!match) {
+ // Repli sur l'analyse native pour les formats inattendus
+ return new Date(value).getTime();
+ }
+
+ const [, year, month, day, hours = '0', minutes = '0', seconds = '0', offset] = match;
+
+ let timestamp = Date.UTC(
+ parseInt(year, 10),
+ parseInt(month, 10) - 1,
+ parseInt(day, 10),
+ parseInt(hours, 10),
+ parseInt(minutes, 10),
+ parseInt(seconds, 10)
+ );
+
+ if (offset && offset !== 'Z') {
+ // Un décalage « +02:00 » signifie « 2 h d'avance sur UTC » : on le soustrait
+ const sign = offset[0] === '+' ? 1 : -1;
+ const offsetDigits = offset.slice(1).replace(':', '');
+ const offsetMinutes = sign * (
+ parseInt(offsetDigits.slice(0, 2), 10) * 60 + parseInt(offsetDigits.slice(2, 4), 10)
+ );
+ timestamp -= offsetMinutes * 60 * 1000;
+ }
+
+ return timestamp;
+}
+
class CountdownTimer {
constructor(targetDate) {
- this.targetDate = new Date(targetDate).getTime();
+ this.targetDate = parseTargetDate(targetDate);
this.elements = {
days: document.getElementById('countdown-days'),
hours: document.getElementById('countdown-hours'),
diff --git a/tests/js/countdown-test.js b/tests/js/countdown-test.js
index de53e85..5e4996c 100644
--- a/tests/js/countdown-test.js
+++ b/tests/js/countdown-test.js
@@ -80,11 +80,17 @@ function loadCountdown({ elements = null, now = FIXED_NOW } = {}) {
};
vm.createContext(sandbox);
- const CountdownTimer = vm.runInContext(COUNTDOWN_SOURCE + '\nCountdownTimer;', sandbox);
+ const { CountdownTimer, parseTargetDate } = vm.runInContext(
+ COUNTDOWN_SOURCE + '\n({ CountdownTimer, parseTargetDate });',
+ sandbox
+ );
- return { CountdownTimer, window: windowMock, timers };
+ return { CountdownTimer, parseTargetDate, window: windowMock, timers };
}
+// Convertit un timestamp en chaîne ISO 8601, comme le fait PHP via DateTime::ATOM
+const toISO = (timestamp) => new Date(timestamp).toISOString();
+
describe('CountdownTimer', () => {
test('formatNumber complète les nombres sur deux chiffres', () => {
const { CountdownTimer } = loadCountdown();
@@ -104,7 +110,7 @@ describe('CountdownTimer', () => {
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
- const timer = new CountdownTimer(FIXED_NOW + distance);
+ const timer = new CountdownTimer(toISO(FIXED_NOW + distance));
try {
assert.strictEqual(elements['countdown-days'].textContent, '02');
@@ -121,7 +127,7 @@ describe('CountdownTimer', () => {
const elements = makeElements();
const { CountdownTimer } = loadCountdown({ elements });
- const timer = new CountdownTimer(FIXED_NOW + distance);
+ const timer = new CountdownTimer(toISO(FIXED_NOW + distance));
try {
assert.strictEqual(elements['countdown-days'].textContent, '01');
@@ -136,7 +142,7 @@ describe('CountdownTimer', () => {
test('une date passée arrête le compte à rebours et redirige vers /', () => {
const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements });
- const timer = new CountdownTimer(FIXED_NOW - 1000);
+ const timer = new CountdownTimer(toISO(FIXED_NOW - 1000));
try {
// onComplete() redirige vers la page principale
@@ -155,7 +161,7 @@ describe('CountdownTimer', () => {
test('une distance nulle affiche zéro partout sans rediriger', () => {
const elements = makeElements();
const { CountdownTimer, window } = loadCountdown({ elements });
- const timer = new CountdownTimer(FIXED_NOW);
+ const timer = new CountdownTimer(toISO(FIXED_NOW));
try {
assert.strictEqual(elements['countdown-days'].textContent, '00');
@@ -170,7 +176,7 @@ describe('CountdownTimer', () => {
test('fonctionne sans éléments DOM présents', () => {
const { CountdownTimer } = loadCountdown({ elements: null });
- const timer = new CountdownTimer(FIXED_NOW + 60 * 1000);
+ const timer = new CountdownTimer(toISO(FIXED_NOW + 60 * 1000));
// Aucune erreur attendue malgré l'absence des éléments
timer.stop();
@@ -178,7 +184,7 @@ describe('CountdownTimer', () => {
test('démarre un intervalle d\'une seconde et stop() le nettoie', () => {
const { CountdownTimer, timers } = loadCountdown();
- const timer = new CountdownTimer(FIXED_NOW + 60 * 1000);
+ const timer = new CountdownTimer(toISO(FIXED_NOW + 60 * 1000));
assert.strictEqual(timers.intervals.length, 1);
assert.strictEqual(timers.intervals[0].delay, 1000);
@@ -187,4 +193,78 @@ describe('CountdownTimer', () => {
timer.stop();
assert.deepStrictEqual(timers.cleared, [timer.interval]);
});
+
+ test('accepte une date ISO 8601 avec fuseau horaire (contrat PHP)', () => {
+ // FIXED_NOW = 2030-01-01T00:00:00Z
+ // Cible : 2030-01-02T05:00:00+04:00, soit 2030-01-02T01:00:00Z → 1 jour + 1 heure
+ const elements = makeElements();
+ const { CountdownTimer } = loadCountdown({ elements });
+ const timer = new CountdownTimer('2030-01-02T05:00:00+04:00');
+
+ try {
+ assert.strictEqual(elements['countdown-days'].textContent, '01');
+ assert.strictEqual(elements['countdown-hours'].textContent, '01');
+ assert.strictEqual(elements['countdown-minutes'].textContent, '00');
+ assert.strictEqual(elements['countdown-seconds'].textContent, '00');
+ } finally {
+ timer.stop();
+ }
+ });
+});
+
+describe('parseTargetDate', () => {
+ test('analyse une date ISO 8601 avec décalage horaire', () => {
+ const { parseTargetDate } = loadCountdown();
+
+ assert.strictEqual(
+ parseTargetDate('2025-10-11T00:00:00-04:00'),
+ Date.UTC(2025, 9, 11, 4, 0, 0)
+ );
+ assert.strictEqual(
+ parseTargetDate('2025-10-11T00:00:00+02:00'),
+ Date.UTC(2025, 9, 10, 22, 0, 0)
+ );
+ });
+
+ test('analyse une date ISO 8601 suffixée Z', () => {
+ const { parseTargetDate } = loadCountdown();
+
+ assert.strictEqual(
+ parseTargetDate('2025-10-11T00:00:00Z'),
+ Date.UTC(2025, 9, 11, 0, 0, 0)
+ );
+ });
+
+ test('accepte le format historique avec espace (cas Safari)', () => {
+ const { parseTargetDate } = loadCountdown();
+
+ // Sans fuseau explicite, la date est interprétée en UTC
+ assert.strictEqual(
+ parseTargetDate('2025-10-11 00:00:00'),
+ Date.UTC(2025, 9, 11, 0, 0, 0)
+ );
+ });
+
+ test('accepte les millisecondes et les décalages sans deux-points', () => {
+ const { parseTargetDate } = loadCountdown();
+
+ assert.strictEqual(parseTargetDate('2030-01-01T00:00:00.000Z'), FIXED_NOW);
+ assert.strictEqual(
+ parseTargetDate('2025-10-11T00:00:00-0400'),
+ Date.UTC(2025, 9, 11, 4, 0, 0)
+ );
+ });
+
+ test('accepte un timestamp numérique tel quel', () => {
+ const { parseTargetDate } = loadCountdown();
+
+ assert.strictEqual(parseTargetDate(FIXED_NOW), FIXED_NOW);
+ });
+
+ test('retourne le même instant que new Date pour une date ISO valide', () => {
+ const { parseTargetDate } = loadCountdown();
+ const iso = '2030-06-15T12:30:45+00:00';
+
+ assert.strictEqual(parseTargetDate(iso), new Date(iso).getTime());
+ });
});
diff --git a/tests/php/format-test.php b/tests/php/format-test.php
index 6187da7..06993c7 100644
--- a/tests/php/format-test.php
+++ b/tests/php/format-test.php
@@ -1,6 +1,6 @@