// Rôle : fournit l'outil timer avec chronomètre, comptes à rebours et échange texte. 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 ""; } export function TimerModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" })); const textContent = context.moduleText?.timer || {}; const activeTab = data.activeTab; const [nowMs, setNowMs] = useState(getNowMs); const countdownDraft = useDraftForm(DEFAULT_COUNTDOWN_FORM); useEffect(() => { const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50); return () => window.clearInterval(intervalId); }, []); const stopwatchElapsedMs = data.stopwatch.startedAt ? data.stopwatch.elapsedMs + Math.max(0, nowMs - data.stopwatch.startedAt) : data.stopwatch.elapsedMs; const visibleCountdowns = useMemo(() => { const countdowns = data.countdowns.map((countdown) => ({ ...countdown, targetMs: getCountdownTargetMs(countdown, nowMs) })); if (!data.sortResults) return countdowns; return [...countdowns].sort((a, b) => (a.targetMs || Number.POSITIVE_INFINITY) - (b.targetMs || Number.POSITIVE_INFINITY)); }, [data.countdowns, data.sortResults, nowMs]); const countdownReorder = useGroupedReorder({ namespace: "timer-countdowns", items: data.countdowns, getItemId: (countdown) => countdown.id, getParentId: () => moduleId, canMoveItem: () => !data.sortResults, onItemMove: (operation) => { if (data.sortResults) return; save({ ...data, countdowns: moveItem(data.countdowns, operation.sourceId, operation.targetId, operation.placement) }); }, hierarchy: { enabled: true, stickyParents: true } }); function save(nextData) { context.setModuleData(toolboxId, moduleId, nextData); } function setActiveTab(nextActiveTab) { save({ ...data, activeTab: nextActiveTab }); } function setScrollResults(scrollResults) { save({ ...data, scrollResults }); } function setSortResults(sortResults) { save({ ...data, sortResults }); } function updateStopwatch(stopwatch) { save({ ...data, stopwatch }); } function startStopwatch() { if (data.stopwatch.startedAt) return; updateStopwatch({ ...data.stopwatch, startedAt: getNowMs() }); } function pauseStopwatch() { if (!data.stopwatch.startedAt) return; updateStopwatch({ ...data.stopwatch, startedAt: 0, elapsedMs: stopwatchElapsedMs }); } function resetStopwatch() { updateStopwatch({ ...data.stopwatch, elapsedMs: 0, startedAt: 0 }); } function addLap() { if (!stopwatchElapsedMs) return; updateStopwatch({ ...data.stopwatch, laps: [ { id: context.uid("timer"), label: "", elapsedMs: stopwatchElapsedMs }, ...data.stopwatch.laps ] }); } function deleteLap(lapId) { updateStopwatch({ ...data.stopwatch, laps: data.stopwatch.laps.filter((lap) => lap.id !== lapId) }); } function renameLap(lapId, label) { updateStopwatch({ ...data.stopwatch, laps: data.stopwatch.laps.map((lap) => lap.id === lapId ? { ...lap, label: label.trim() } : lap) }); } function createCountdown(event) { countdownDraft.handleSubmit(event, (draft, { updateValues }) => { const type = COUNTDOWN_TYPES.includes(draft.type) ? draft.type : "duration"; const validationError = getCountdownValidationError(type, draft, textContent); if (validationError) return validationError; const label = draft.label.trim() || textContent.defaultCountdownLabel || "Timer"; const now = getNowMs(); let countdown = null; if (type === "duration") { const durationMs = timePartsToDurationMs(draft.duration); if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs }; } if (type === "daily_time") { const time = timePartsToString(draft.time); const targetAt = getDailyTargetMs(time, now); if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt }; } if (type === "time_pattern") { const pattern = hasTimePartValue(draft.pattern) ? timePartsToString(draft.pattern, { allowWildcard: true }) : ""; if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern }; } if (type === "interval") { const intervalMs = timePartsToDurationMs(draft.interval); const startTime = hasTimePartValue(draft.intervalStartTime) ? timePartsToString(draft.intervalStartTime) : ""; const startMode = startTime ? "time" : "now"; const anchorAt = getIntervalAnchorMs(startMode, startTime, now); const targetAt = getCountdownTargetMs({ type, intervalMs, anchorAt, autoRefresh: true }, now); if (intervalMs > 0) { countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt, targetAt, startMode, ...(startTime ? { startTime } : {}), autoRefresh: intervalMs >= ALERT_MIN_DELAY_MS }; } } if (!countdown) return; save({ ...data, countdowns: [...data.countdowns, countdown] }); updateValues({ label: "" }); }); } function deleteCountdown(countdownId) { save({ ...data, countdowns: data.countdowns.filter((countdown) => countdown.id !== countdownId) }); } function resetCountdown(countdown) { if (countdown.type !== "duration" && countdown.type !== "daily_time" && countdown.type !== "interval") return; const now = getNowMs(); let nextCountdown = null; if (countdown.type === "duration") nextCountdown = { ...countdown, targetAt: now + countdown.durationMs }; if (countdown.type === "daily_time") nextCountdown = { ...countdown, targetAt: getDailyTargetMs(countdown.time, now) }; if (countdown.type === "interval") { const anchorAt = getIntervalAnchorMs(countdown.startMode, countdown.startTime, now); const targetAt = getCountdownTargetMs({ ...countdown, anchorAt, autoRefresh: true }, now); nextCountdown = { ...countdown, anchorAt, targetAt }; } if (!nextCountdown) return; save({ ...data, countdowns: data.countdowns.map((item) => item.id === countdown.id ? nextCountdown : item) }); } function toggleCountdownAutoRefresh(countdownId) { const countdown = data.countdowns.find((item) => item.id === countdownId); if (!TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type)) return; if (!countdown.autoRefresh && !canToggleAutoRefresh(countdown)) { context.notify?.(getAlertGuardMessage(textContent)); return; } const now = getNowMs(); save({ ...data, countdowns: data.countdowns.map((item) => { if (item.id !== countdownId) return item; const autoRefresh = !item.autoRefresh; if (item.type === "daily_time") return { ...item, autoRefresh, targetAt: getDailyTargetMs(item.time, now) }; if (item.type === "interval") { const anchorAt = getIntervalAnchorMs(item.startMode, item.startTime, now); const targetAt = getCountdownTargetMs({ ...item, anchorAt, autoRefresh: true }, now); return { ...item, autoRefresh, anchorAt, targetAt }; } return item; }) }); } function renameCountdown(countdownId, label) { save({ ...data, countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, label: label.trim() } : countdown) }); } function setCountdownAlertMode(countdownId, alertMode) { const countdown = data.countdowns.find((item) => item.id === countdownId); if (alertMode !== "off" && AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && isAlertGuarded(countdown)) { context.notify?.(getAlertGuardMessage(textContent)); return; } save({ ...data, countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, alertMode } : countdown) }); } function resetActiveResults() { if (activeTab === "stopwatch") { resetStopwatch(); return; } save({ ...data, countdowns: [] }); } return (
{textContent.emptySteps || "Aucune étape enregistrée."}
; return ({textContent.emptyCountdowns || "Aucun compte à rebours configuré."}
; return ({getCountdownMetaParts(countdown, textContent).map((part, index) => ( {part} ))} {expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}