diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index dc411e8..543207e 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -6,6 +6,7 @@ import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "
import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js";
+import { canToggleAutoRefresh, getCountdownValidationError, isAlertGuarded } from "../website/src/features/toolboxes/modules/timerOptions.js";
test("toolbox storage, cards and pages are wired", async () => {
const source = await readFile("website/src/main.jsx", "utf8");
@@ -124,6 +125,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
+ const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
+ const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
@@ -267,7 +270,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /Tabs/);
assert.match(timerModule, /stopwatch/);
assert.match(timerModule, /countdown/);
- assert.match(timerModule, /useInlineEdit/);
+ assert.match(timerParts, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/);
@@ -371,7 +374,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
assert.match(timerModule, /parentId=\{moduleId\}/);
- assert.match(timerModule, /timer-drag-handle/);
+ assert.match(timerParts, /timer-drag-handle/);
+ assert.match(timerParts, /export function StopwatchControls/);
+ assert.match(timerParts, /export function CountdownControls/);
+ assert.match(timerParts, /export function CountdownList/);
+ assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
assert.match(textImport, /export function parseColonImportLines/);
assert.match(textImport, /export function exportModuleText/);
assert.match(textImport, /export function importModuleText/);
@@ -451,3 +458,12 @@ test("table helpers keep selection, movement and TSV behavior stable", () => {
const pasted = pasteCellBlock({}, parseTsvBlock("A\t\nC\tD"), 0, 0, 2, 2);
assert.deepEqual(pasted.cells, { A1: "A", A2: "C", B2: "D" });
});
+
+test("timer options keep alert guards and countdown validation stable", () => {
+ assert.equal(isAlertGuarded({ type: "interval", intervalMs: 60_000 }), true);
+ assert.equal(isAlertGuarded({ type: "interval", intervalMs: 600_000 }), false);
+ assert.equal(canToggleAutoRefresh({ type: "daily_time" }), true);
+ assert.equal(canToggleAutoRefresh({ type: "interval", intervalMs: 60_000 }), false);
+ assert.equal(getCountdownValidationError("duration", { duration: { hours: "", minutes: "", seconds: "" } }, {}), "Valeur requise.");
+ assert.equal(getCountdownValidationError("daily_time", { time: { hours: "12", minutes: "30", seconds: "" } }, {}), "");
+});
diff --git a/website/src/features/toolboxes/modules/TimerModule.jsx b/website/src/features/toolboxes/modules/TimerModule.jsx
index 363d2c6..aed72a0 100644
--- a/website/src/features/toolboxes/modules/TimerModule.jsx
+++ b/website/src/features/toolboxes/modules/TimerModule.jsx
@@ -3,118 +3,31 @@ import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
-import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import {
- formatDuration,
- formatDurationWithCentiseconds,
- formatTargetTime,
- getCentiseconds,
getCountdownTargetMs,
getDailyTargetMs,
getIntervalAnchorMs,
getNowMs,
getTimePatternTargetMs,
- getTimePatternRecurrenceMs,
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
-
-const TIMER_TABS = [
- { id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
- { id: "countdown", label: "Compte à rebours", icon: "hourglass" }
-];
-
-const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
-const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
-const DEFAULT_COUNTDOWN_FORM = {
- label: "",
- type: "duration",
- duration: EMPTY_TIME_PARTS,
- time: EMPTY_TIME_PARTS,
- pattern: EMPTY_TIME_PARTS,
- interval: EMPTY_TIME_PARTS,
- intervalStartTime: EMPTY_TIME_PARTS
-};
-const ALERT_MODES = [
- { mode: "off", icon: "sound-mute" },
- { mode: "visible", icon: "sound-min" },
- { mode: "site", icon: "sound-max" }
-];
-const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
-const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
-const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
-
-function isAlertGuarded(countdown) {
- if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
- if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
- return false;
-}
-
-function getAlertGuardMessage(textContent) {
- return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
-}
-
-function canToggleAutoRefresh(countdown) {
- return TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && !isAlertGuarded(countdown);
-}
-
-function TimerValue({ ms, compact = false, showCentiseconds = true }) {
- if (!showCentiseconds) return {formatDuration(ms)};
- if (!compact) return {formatDurationWithCentiseconds(ms)};
-
- return (
-
- {formatDuration(ms)}
- {getCentiseconds(ms)}
-
- );
-}
-
-function getCountdownMetaParts(countdown, textContent) {
- if (countdown.type === "duration") return [`${textContent.durationType || "Durée"} ${formatDuration(countdown.durationMs || 0)}`];
- if (countdown.type === "daily_time") return [`${textContent.dailyTimeType || "Heure précise"} ${countdown.time}`];
- if (countdown.type === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
- if (countdown.type === "interval") {
- const startLabel = countdown.startMode === "time" && countdown.startTime
- ? `${textContent.intervalStartTimeLabel || "Départ"} ${countdown.startTime}`
- : `${textContent.intervalStartTimeLabel || "Départ"} ${formatTargetTime(countdown.anchorAt)}`;
- return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel];
- }
- return [];
-}
-
-function handleEditableKeyDown(event, callback) {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- callback();
-}
-
-function hasTimePartValue(parts) {
- return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
-}
-
-function getCountdownValidationError(type, draft, textContent) {
- const requiredError = textContent.requiredError || "Valeur requise.";
- const invalidError = textContent.invalidError || "Valeur invalide.";
- if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
- return requiredError;
- }
- if (type === "daily_time" && !timePartsToString(draft.time)) {
- return invalidError;
- }
- if (type === "time_pattern") {
- if (!hasTimePartValue(draft.pattern)) return requiredError;
- if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
- }
- if (type === "interval") {
- const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
- if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
- if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
- }
- return "";
-}
+import { CountdownControls, CountdownList, StopwatchControls, StopwatchLapList } from "./TimerParts.jsx";
+import {
+ ALERT_MIN_DELAY_MS,
+ AUTO_REFRESH_COUNTDOWN_TYPES,
+ COUNTDOWN_TYPES,
+ DEFAULT_COUNTDOWN_FORM,
+ TIMER_TABS,
+ TOGGLE_REFRESH_COUNTDOWN_TYPES,
+ canToggleAutoRefresh,
+ getAlertGuardMessage,
+ getCountdownValidationError,
+ hasTimePartValue,
+ isAlertGuarded
+} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
@@ -411,326 +324,3 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
);
}
-
-function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
- const labels = {
- off: textContent.alertOffTitle || "Pas d'alerte",
- visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
- site: textContent.alertSiteTitle || "Alerte sur tout le site"
- };
- const blockedTitle = getAlertGuardMessage(textContent);
-
- return (
-
event.stopPropagation()}
- onKeyDown={(event) => event.stopPropagation()}
- >
- {ALERT_MODES.map((option) => {
- const label = labels[option.mode];
- const disabled = Boolean(blocked);
- return (
-
- );
- })}
-
- );
-}
-
-function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
- return (
-
-
{textContent.stopwatchTitle || "Chronomètre à étapes"}
-
-
-
-
-
-
-
- );
-}
-
-function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
- return (
-
- );
-}
-
-function TimePartsInput({ value, onChange, allowWildcard = false }) {
- function updatePart(part, nextValue) {
- const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
- onChange({ ...value, [part]: cleanValue });
- }
-
- return (
-
- updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
- :
- updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
- :
- updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
-
- );
-}
-
-function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
- const [editingId, setEditingId] = useState("");
- if (!laps.length) return {textContent.emptySteps || "Aucune étape enregistrée."}
;
- return (
-
- {laps.map((lap, index) => (
- -
-
- {editingId === lap.id ? (
- onRename(lap.id, label)}
- onDone={() => setEditingId("")}
- >
-
-
- ) : (
- setEditingId(lap.id)}
- onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
- >
-
-
- {lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}
-
-
- )}
-
-
-
- ))}
-
- );
-}
-
-function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
- const [editingId, setEditingId] = useState("");
- if (!countdowns.length) return {textContent.emptyCountdowns || "Aucun compte à rebours configuré."}
;
- return (
-
- {countdowns.map((countdown) => {
- const remainingMs = countdown.targetMs - nowMs;
- const expired = remainingMs <= 0;
- const canReset = countdown.type === "duration";
- const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
- const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
- const refreshTitle = refreshBlocked
- ? getAlertGuardMessage(textContent)
- : textContent.autoRefreshTitle || "Répéter automatiquement";
- const className = [
- "timer-list-item",
- expired ? "is-expired" : "",
- editingId === countdown.id ? "is-editing" : "",
- reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
- reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
- reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
- ].filter(Boolean).join(" ");
- const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
-
- return (
- -
-
- {reorderEnabled && (
-
- )}
- {editingId === countdown.id ? (
- onRename(countdown.id, label)}
- onDone={() => setEditingId("")}
- >
-
-
- ) : (
- setEditingId(countdown.id)}
- onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
- >
-
-
- {countdown.label}
-
- {canReset && (
-
- )}
- {canToggleRefresh && (
-
- )}
- onAlertModeChange(countdown.id, alertMode)}
- />
-
-
-
- )}
-
-
-
-
- {getCountdownMetaParts(countdown, textContent).map((part, index) => (
- {part}
- ))}
-
-
- {expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}
-
-
-
- );
- })}
-
- );
-}
-
-function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
- const inlineEdit = useInlineEdit({
- value,
- onCommit: onRename,
- onDone,
- autoFocus: true,
- commitUnchanged: true
- });
-
- return (
- <>
- {children}
-
- >
- );
-}
diff --git a/website/src/features/toolboxes/modules/TimerParts.jsx b/website/src/features/toolboxes/modules/TimerParts.jsx
new file mode 100644
index 0000000..2741d16
--- /dev/null
+++ b/website/src/features/toolboxes/modules/TimerParts.jsx
@@ -0,0 +1,350 @@
+// Rôle : rend les contrôles, valeurs et listes de résultats du module Timer.
+import { useState } from "react";
+import { Icon } from "../../../components/Icon.jsx";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
+import { formatDuration, formatDurationWithCentiseconds, formatTargetTime, getCentiseconds } from "./timerUtils.js";
+import {
+ ALERT_MODES,
+ AUTO_REFRESH_COUNTDOWN_TYPES,
+ TOGGLE_REFRESH_COUNTDOWN_TYPES,
+ canToggleAutoRefresh,
+ getAlertGuardMessage,
+ getCountdownMetaParts,
+ handleEditableKeyDown,
+ isAlertGuarded
+} from "./timerOptions.js";
+
+export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
+ return (
+
+
{textContent.stopwatchTitle || "Chronomètre à étapes"}
+
+
+
+
+
+
+
+ );
+}
+
+export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
+ return (
+
+ );
+}
+
+export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
+ const [editingId, setEditingId] = useState("");
+ if (!laps.length) return {textContent.emptySteps || "Aucune étape enregistrée."}
;
+ return (
+
+ {laps.map((lap, index) => (
+ -
+
+ {editingId === lap.id ? (
+ onRename(lap.id, label)}
+ onDone={() => setEditingId("")}
+ >
+
+
+ ) : (
+ setEditingId(lap.id)}
+ onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
+ >
+
+
+ {lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}
+
+
+ )}
+
+
+
+ ))}
+
+ );
+}
+
+export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
+ const [editingId, setEditingId] = useState("");
+ if (!countdowns.length) return {textContent.emptyCountdowns || "Aucun compte à rebours configuré."}
;
+ return (
+
+ {countdowns.map((countdown) => {
+ const remainingMs = countdown.targetMs - nowMs;
+ const expired = remainingMs <= 0;
+ const canReset = countdown.type === "duration";
+ const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
+ const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
+ const refreshTitle = refreshBlocked
+ ? getAlertGuardMessage(textContent)
+ : textContent.autoRefreshTitle || "Répéter automatiquement";
+ const className = [
+ "timer-list-item",
+ expired ? "is-expired" : "",
+ editingId === countdown.id ? "is-editing" : "",
+ reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
+ reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
+ reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
+ ].filter(Boolean).join(" ");
+ const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
+
+ return (
+ -
+
+ {reorderEnabled && (
+
+ )}
+ {editingId === countdown.id ? (
+ onRename(countdown.id, label)}
+ onDone={() => setEditingId("")}
+ >
+
+
+ ) : (
+ setEditingId(countdown.id)}
+ onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
+ >
+
+
+ {countdown.label}
+
+ {canReset && (
+
+ )}
+ {canToggleRefresh && (
+
+ )}
+ onAlertModeChange(countdown.id, alertMode)}
+ />
+
+
+
+ )}
+
+
+
+
+ {getCountdownMetaParts(countdown, textContent).map((part, index) => (
+ {part}
+ ))}
+
+
+ {expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function TimerValue({ ms, compact = false, showCentiseconds = true }) {
+ if (!showCentiseconds) return {formatDuration(ms)};
+ if (!compact) return {formatDurationWithCentiseconds(ms)};
+
+ return (
+
+ {formatDuration(ms)}
+ {getCentiseconds(ms)}
+
+ );
+}
+
+function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
+ const labels = {
+ off: textContent.alertOffTitle || "Pas d'alerte",
+ visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
+ site: textContent.alertSiteTitle || "Alerte sur tout le site"
+ };
+ const blockedTitle = getAlertGuardMessage(textContent);
+
+ return (
+ event.stopPropagation()}
+ onKeyDown={(event) => event.stopPropagation()}
+ >
+ {ALERT_MODES.map((option) => {
+ const label = labels[option.mode];
+ const disabled = Boolean(blocked);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function TimePartsInput({ value, onChange, allowWildcard = false }) {
+ function updatePart(part, nextValue) {
+ const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
+ onChange({ ...value, [part]: cleanValue });
+ }
+
+ return (
+
+ updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
+ :
+ updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
+ :
+ updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
+
+ );
+}
+
+function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit: onRename,
+ onDone,
+ autoFocus: true,
+ commitUnchanged: true
+ });
+
+ return (
+ <>
+ {children}
+
+ >
+ );
+}
diff --git a/website/src/features/toolboxes/modules/timerOptions.js b/website/src/features/toolboxes/modules/timerOptions.js
new file mode 100644
index 0000000..b3b5ec3
--- /dev/null
+++ b/website/src/features/toolboxes/modules/timerOptions.js
@@ -0,0 +1,85 @@
+// Rôle : centralise les options, validations et helpers purs du module Timer.
+import { formatDuration, formatTargetTime, getTimePatternRecurrenceMs, timePartsToDurationMs, timePartsToString } from "./timerUtils.js";
+
+export const TIMER_TABS = [
+ { id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
+ { id: "countdown", label: "Compte à rebours", icon: "hourglass" }
+];
+
+export const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
+export const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
+export const DEFAULT_COUNTDOWN_FORM = {
+ label: "",
+ type: "duration",
+ duration: EMPTY_TIME_PARTS,
+ time: EMPTY_TIME_PARTS,
+ pattern: EMPTY_TIME_PARTS,
+ interval: EMPTY_TIME_PARTS,
+ intervalStartTime: EMPTY_TIME_PARTS
+};
+export const ALERT_MODES = [
+ { mode: "off", icon: "sound-mute" },
+ { mode: "visible", icon: "sound-min" },
+ { mode: "site", icon: "sound-max" }
+];
+export const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
+export const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
+export const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
+
+export function isAlertGuarded(countdown) {
+ if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
+ if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
+ return false;
+}
+
+export function getAlertGuardMessage(textContent) {
+ return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
+}
+
+export function canToggleAutoRefresh(countdown) {
+ return TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && !isAlertGuarded(countdown);
+}
+
+export function getCountdownMetaParts(countdown, textContent) {
+ if (countdown.type === "duration") return [`${textContent.durationType || "Durée"} ${formatDuration(countdown.durationMs || 0)}`];
+ if (countdown.type === "daily_time") return [`${textContent.dailyTimeType || "Heure précise"} ${countdown.time}`];
+ if (countdown.type === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
+ if (countdown.type === "interval") {
+ const startLabel = countdown.startMode === "time" && countdown.startTime
+ ? `${textContent.intervalStartTimeLabel || "Départ"} ${countdown.startTime}`
+ : `${textContent.intervalStartTimeLabel || "Départ"} ${formatTargetTime(countdown.anchorAt)}`;
+ return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel];
+ }
+ return [];
+}
+
+export function handleEditableKeyDown(event, callback) {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ callback();
+}
+
+export function hasTimePartValue(parts) {
+ return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
+}
+
+export function getCountdownValidationError(type, draft, textContent) {
+ const requiredError = textContent.requiredError || "Valeur requise.";
+ const invalidError = textContent.invalidError || "Valeur invalide.";
+ if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
+ return requiredError;
+ }
+ if (type === "daily_time" && !timePartsToString(draft.time)) {
+ return invalidError;
+ }
+ if (type === "time_pattern") {
+ if (!hasTimePartValue(draft.pattern)) return requiredError;
+ if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
+ }
+ if (type === "interval") {
+ const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
+ if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
+ if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
+ }
+ return "";
+}