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 ( -
- - - {form.type === "duration" && ( - - )} - {form.type === "daily_time" && ( - - )} - {form.type === "time_pattern" && ( - - )} - {form.type === "interval" && ( -
- - -
- )} - {error && ( - - )} - -
- ); -} - -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 ( - - ); -} - -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 ( -