From a6b2388763bfee021d73637f21559003376dec26 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Mon, 3 Aug 2026 15:57:25 +0200 Subject: [PATCH] Improve timer interval refresh behavior --- DESIGN_SYSTEM.md | 2 + docs/STORAGE_SCHEMA.md | 13 +- tests/helpers/data-validation.mjs | 4 + website/public/data/library.json | 1 + website/public/data/site.json | 6 +- .../toolboxes/modules/TimerModule.jsx | 137 +++++++++++++++--- .../features/toolboxes/modules/timerUtils.js | 16 +- .../toolboxes/storage/toolboxStorage.js | 31 +++- website/src/styles/_toolboxes.scss | 134 ++++++++++++++++- 9 files changed, 305 insertions(+), 39 deletions(-) diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index f6ee504..8278c30 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -171,6 +171,8 @@ Regles UI : - types visibles : `Duree`, `Heure precise`, `Pattern horaire`, `Intervalle` ; - les libelles au-dessus des champs `hh:mm:ss` restent accessibles en `sr-only`, mais ne sont pas visibles ; - les boutons de controle utilisent les icones `play-circle`, `pause-circle`, `record-circle`, `stop-circle`, `refresh`, `sort-time`, `scrollable`. +- pour `Heure precise` et `Intervalle`, le refresh inline est un toggle compact de repetition automatique ; il est bloque si la frequence est inferieure a 5 minutes. +- le formulaire `Intervalle` affiche deux champs `hh:mm:ss` compacts : duree de repetition et depart optionnel ; un depart vide signifie `Maintenant`. Alertes timer : diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 8055eae..b03f5f0 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -258,6 +258,7 @@ Structure initiale : "label": "Boss", "type": "daily_time", "alertMode": "visible", + "autoRefresh": true, "time": "12:03:00", "targetAt": 1760000000000 }, @@ -271,8 +272,12 @@ Structure initiale : "id": "timer4", "label": "Event", "type": "interval", + "autoRefresh": true, "intervalMs": 1800000, - "anchorAt": 1760000000000 + "startMode": "time", + "startTime": "06:00:00", + "anchorAt": 1760000000000, + "targetAt": 1760001800000 } ] } @@ -281,9 +286,9 @@ Structure initiale : Types de compte à rebours : - `duration` : durée ponctuelle, avec `durationMs` et `targetAt`. -- `daily_time` : occurrence ponctuelle à une heure précise, avec `time` au format `HH:MM:SS` et `targetAt`. +- `daily_time` : occurrence à une heure précise, avec `time` au format `HH:MM:SS`, `targetAt` et `autoRefresh` optionnel. - `time_pattern` : prochaine occurrence automatique correspondant à un pattern `HH:MM:SS`, où chaque segment peut valoir `X`. -- `interval` : prochaine occurrence automatique toutes les X millisecondes, avec `intervalMs` et `anchorAt`. +- `interval` : intervalle basé sur `intervalMs`, `anchorAt`, `targetAt`, `startMode`, `startTime` et `autoRefresh` optionnel. `startMode` vaut `now` ou `time`; `startTime` est présent uniquement avec `time`. `autoRefresh` n'est conservé que si l'intervalle dure au moins 5 minutes ; les anciens intervalles sans `targetAt` restent interprétés comme répétables. Pour `time_pattern`, un segment `X` situé après un segment plus large fixé cible le premier instant de l'occurrence. Exemple : `X:24:X` cible `hh:24:00` et se répète toutes les heures. @@ -298,6 +303,8 @@ Stockage compact : - `laps` est omis tant qu'aucune étape n'est enregistrée. - `countdowns` est omis tant qu'aucun compte à rebours n'est configuré. - `countdowns[].alertMode` vaut `off`, `visible` ou `site`, et est omis si `off`. +- `countdowns[].autoRefresh` est omis si `false`. +- `countdowns[].startMode` et `countdowns[].startTime` sont omis pour les intervalles qui démarrent à la création. - L'activation d'une alerte est bloquée dans l'interface si la fréquence configurée est inférieure à 5 minutes pour les comptes à rebours récurrents (`time_pattern` et `interval`). Le blocage ne dépend pas du temps restant avant la prochaine échéance. ## Outil Task Planner diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index f24060d..4eea9fe 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -261,6 +261,9 @@ export function validateSiteContent(site) { "toolboxes.modules.timer.dailyTimeLabel", "toolboxes.modules.timer.timePatternLabel", "toolboxes.modules.timer.intervalLabel", + "toolboxes.modules.timer.intervalStartNowOption", + "toolboxes.modules.timer.intervalStartTimeOption", + "toolboxes.modules.timer.intervalStartTimeLabel", "toolboxes.modules.timer.addCountdownButton", "toolboxes.modules.timer.defaultCountdownLabel", "toolboxes.modules.timer.finishedLabel", @@ -270,6 +273,7 @@ export function validateSiteContent(site) { "toolboxes.modules.timer.countdownsTitle", "toolboxes.modules.timer.scrollableTitle", "toolboxes.modules.timer.sortTitle", + "toolboxes.modules.timer.autoRefreshTitle", "toolboxes.modules.timer.alertModeTitle", "toolboxes.modules.timer.alertOffTitle", "toolboxes.modules.timer.alertVisibleTitle", diff --git a/website/public/data/library.json b/website/public/data/library.json index 5a98ec6..8a6a573 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -1295,6 +1295,7 @@ "id": "z4", "label": "Boss Mondial", "type": "daily_time", + "autoRefresh": true, "time": "14:00:00", "targetAt": 1785758400000 }, diff --git a/website/public/data/site.json b/website/public/data/site.json index 5c8cb4a..363bca8 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -426,15 +426,19 @@ "dailyTimeLabel": "Heure", "timePatternLabel": "Heure / minute / seconde", "intervalLabel": "Toutes les", + "intervalStartNowOption": "Maintenant", + "intervalStartTimeOption": "Heure précise", + "intervalStartTimeLabel": "Départ", "addCountdownButton": "Ajouter", "defaultCountdownLabel": "Timer", "finishedLabel": "Terminé", - "targetLabel": "Prochaine occurrence", + "targetLabel": "Suivant", "stepLabel": "Étape", "stepsTitle": "Étapes", "countdownsTitle": "Temps restants", "scrollableTitle": "Liste scrollable", "sortTitle": "Trier par temps restant", + "autoRefreshTitle": "Répéter automatiquement", "alertModeTitle": "Mode d'alerte", "alertOffTitle": "Pas d'alerte", "alertVisibleTitle": "Alerte quand la toolbox est affichée", diff --git a/website/src/features/toolboxes/modules/TimerModule.jsx b/website/src/features/toolboxes/modules/TimerModule.jsx index d3cf3fd..dd55fbd 100644 --- a/website/src/features/toolboxes/modules/TimerModule.jsx +++ b/website/src/features/toolboxes/modules/TimerModule.jsx @@ -12,6 +12,7 @@ import { getCentiseconds, getCountdownTargetMs, getDailyTargetMs, + getIntervalAnchorMs, getNowMs, getTimePatternTargetMs, getTimePatternRecurrenceMs, @@ -32,7 +33,8 @@ const DEFAULT_COUNTDOWN_FORM = { duration: EMPTY_TIME_PARTS, time: EMPTY_TIME_PARTS, pattern: EMPTY_TIME_PARTS, - interval: EMPTY_TIME_PARTS + interval: EMPTY_TIME_PARTS, + intervalStartTime: EMPTY_TIME_PARTS }; const ALERT_MODES = [ { mode: "off", icon: "sound-mute" }, @@ -41,6 +43,7 @@ const ALERT_MODES = [ ]; 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; @@ -52,6 +55,10 @@ 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)}; @@ -64,12 +71,17 @@ function TimerValue({ ms, compact = false, showCentiseconds = true }) { ); } -function getCountdownMeta(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") return `${textContent.intervalType || "Intervalle"} · ${formatDuration(countdown.intervalMs || 0)}`; - return ""; +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.intervalStartNowOption || "Maintenant"; + return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel]; + } + return []; } function handleEditableKeyDown(event, callback) { @@ -78,6 +90,10 @@ function handleEditableKeyDown(event, callback) { callback(); } +function hasTimePartValue(parts) { + return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim()); +} + export function TimerModule({ toolboxId, moduleId, context }) { const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" })); const textContent = context.moduleText?.timer || {}; @@ -197,7 +213,23 @@ export function TimerModule({ toolboxId, moduleId, context }) { } if (type === "interval") { const intervalMs = timePartsToDurationMs(draft.interval); - if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now }; + 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; @@ -216,11 +248,39 @@ export function TimerModule({ toolboxId, moduleId, context }) { 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") nextCountdown = { ...countdown, anchorAt: 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, @@ -314,7 +374,7 @@ export function TimerModule({ toolboxId, moduleId, context }) { {activeTab === "stopwatch" ? ( ) : ( - + )} @@ -399,27 +459,36 @@ function CountdownControls({ textContent, form, onChange, onSubmit }) { {form.type === "duration" && ( )} {form.type === "daily_time" && ( )} {form.type === "time_pattern" && ( )} {form.type === "interval" && ( - +
+ + +
)} @@ -485,7 +554,7 @@ function StopwatchLapList({ laps, textContent, onRename, onDelete }) { ); } -function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onDelete }) { +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 ( @@ -493,9 +562,12 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled {countdowns.map((countdown) => { const remainingMs = countdown.targetMs - nowMs; const expired = remainingMs <= 0; - const canReset = countdown.type === "duration" - || countdown.type === "interval" - || (countdown.type === "daily_time" && expired); + 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" : "", @@ -508,7 +580,7 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled return (
  • -
    +
    {reorderEnabled && ( )} + {canToggleRefresh && ( +