Improve timer interval refresh behavior
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-03 15:57:25 +02:00
parent fdc81373c6
commit a6b2388763
9 changed files with 305 additions and 39 deletions

View file

@ -171,6 +171,8 @@ Regles UI :
- types visibles : `Duree`, `Heure precise`, `Pattern horaire`, `Intervalle` ; - 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 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`. - 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 : Alertes timer :

View file

@ -258,6 +258,7 @@ Structure initiale :
"label": "Boss", "label": "Boss",
"type": "daily_time", "type": "daily_time",
"alertMode": "visible", "alertMode": "visible",
"autoRefresh": true,
"time": "12:03:00", "time": "12:03:00",
"targetAt": 1760000000000 "targetAt": 1760000000000
}, },
@ -271,8 +272,12 @@ Structure initiale :
"id": "timer4", "id": "timer4",
"label": "Event", "label": "Event",
"type": "interval", "type": "interval",
"autoRefresh": true,
"intervalMs": 1800000, "intervalMs": 1800000,
"anchorAt": 1760000000000 "startMode": "time",
"startTime": "06:00:00",
"anchorAt": 1760000000000,
"targetAt": 1760001800000
} }
] ]
} }
@ -281,9 +286,9 @@ Structure initiale :
Types de compte à rebours : Types de compte à rebours :
- `duration` : durée ponctuelle, avec `durationMs` et `targetAt`. - `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`. - `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. 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. - `laps` est omis tant qu'aucune étape n'est enregistrée.
- `countdowns` est omis tant qu'aucun compte à rebours n'est configuré. - `countdowns` est omis tant qu'aucun compte à rebours n'est configuré.
- `countdowns[].alertMode` vaut `off`, `visible` ou `site`, et est omis si `off`. - `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. - 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 ## Outil Task Planner

View file

@ -261,6 +261,9 @@ export function validateSiteContent(site) {
"toolboxes.modules.timer.dailyTimeLabel", "toolboxes.modules.timer.dailyTimeLabel",
"toolboxes.modules.timer.timePatternLabel", "toolboxes.modules.timer.timePatternLabel",
"toolboxes.modules.timer.intervalLabel", "toolboxes.modules.timer.intervalLabel",
"toolboxes.modules.timer.intervalStartNowOption",
"toolboxes.modules.timer.intervalStartTimeOption",
"toolboxes.modules.timer.intervalStartTimeLabel",
"toolboxes.modules.timer.addCountdownButton", "toolboxes.modules.timer.addCountdownButton",
"toolboxes.modules.timer.defaultCountdownLabel", "toolboxes.modules.timer.defaultCountdownLabel",
"toolboxes.modules.timer.finishedLabel", "toolboxes.modules.timer.finishedLabel",
@ -270,6 +273,7 @@ export function validateSiteContent(site) {
"toolboxes.modules.timer.countdownsTitle", "toolboxes.modules.timer.countdownsTitle",
"toolboxes.modules.timer.scrollableTitle", "toolboxes.modules.timer.scrollableTitle",
"toolboxes.modules.timer.sortTitle", "toolboxes.modules.timer.sortTitle",
"toolboxes.modules.timer.autoRefreshTitle",
"toolboxes.modules.timer.alertModeTitle", "toolboxes.modules.timer.alertModeTitle",
"toolboxes.modules.timer.alertOffTitle", "toolboxes.modules.timer.alertOffTitle",
"toolboxes.modules.timer.alertVisibleTitle", "toolboxes.modules.timer.alertVisibleTitle",

View file

@ -1295,6 +1295,7 @@
"id": "z4", "id": "z4",
"label": "Boss Mondial", "label": "Boss Mondial",
"type": "daily_time", "type": "daily_time",
"autoRefresh": true,
"time": "14:00:00", "time": "14:00:00",
"targetAt": 1785758400000 "targetAt": 1785758400000
}, },

View file

@ -426,15 +426,19 @@
"dailyTimeLabel": "Heure", "dailyTimeLabel": "Heure",
"timePatternLabel": "Heure / minute / seconde", "timePatternLabel": "Heure / minute / seconde",
"intervalLabel": "Toutes les", "intervalLabel": "Toutes les",
"intervalStartNowOption": "Maintenant",
"intervalStartTimeOption": "Heure précise",
"intervalStartTimeLabel": "Départ",
"addCountdownButton": "Ajouter", "addCountdownButton": "Ajouter",
"defaultCountdownLabel": "Timer", "defaultCountdownLabel": "Timer",
"finishedLabel": "Terminé", "finishedLabel": "Terminé",
"targetLabel": "Prochaine occurrence", "targetLabel": "Suivant",
"stepLabel": "Étape", "stepLabel": "Étape",
"stepsTitle": "Étapes", "stepsTitle": "Étapes",
"countdownsTitle": "Temps restants", "countdownsTitle": "Temps restants",
"scrollableTitle": "Liste scrollable", "scrollableTitle": "Liste scrollable",
"sortTitle": "Trier par temps restant", "sortTitle": "Trier par temps restant",
"autoRefreshTitle": "Répéter automatiquement",
"alertModeTitle": "Mode d'alerte", "alertModeTitle": "Mode d'alerte",
"alertOffTitle": "Pas d'alerte", "alertOffTitle": "Pas d'alerte",
"alertVisibleTitle": "Alerte quand la toolbox est affichée", "alertVisibleTitle": "Alerte quand la toolbox est affichée",

View file

@ -12,6 +12,7 @@ import {
getCentiseconds, getCentiseconds,
getCountdownTargetMs, getCountdownTargetMs,
getDailyTargetMs, getDailyTargetMs,
getIntervalAnchorMs,
getNowMs, getNowMs,
getTimePatternTargetMs, getTimePatternTargetMs,
getTimePatternRecurrenceMs, getTimePatternRecurrenceMs,
@ -32,7 +33,8 @@ const DEFAULT_COUNTDOWN_FORM = {
duration: EMPTY_TIME_PARTS, duration: EMPTY_TIME_PARTS,
time: EMPTY_TIME_PARTS, time: EMPTY_TIME_PARTS,
pattern: EMPTY_TIME_PARTS, pattern: EMPTY_TIME_PARTS,
interval: EMPTY_TIME_PARTS interval: EMPTY_TIME_PARTS,
intervalStartTime: EMPTY_TIME_PARTS
}; };
const ALERT_MODES = [ const ALERT_MODES = [
{ mode: "off", icon: "sound-mute" }, { mode: "off", icon: "sound-mute" },
@ -41,6 +43,7 @@ const ALERT_MODES = [
]; ];
const ALERT_MIN_DELAY_MS = 5 * 60 * 1000; const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]); const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
function isAlertGuarded(countdown) { function isAlertGuarded(countdown) {
if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS; 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"; 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 }) { function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>; if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>; if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
@ -64,12 +71,17 @@ function TimerValue({ ms, compact = false, showCentiseconds = true }) {
); );
} }
function getCountdownMeta(countdown, textContent) { function getCountdownMetaParts(countdown, textContent) {
if (countdown.type === "duration") return `${textContent.durationType || "Durée"} · ${formatDuration(countdown.durationMs || 0)}`; 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 === "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 === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
if (countdown.type === "interval") return `${textContent.intervalType || "Intervalle"} · ${formatDuration(countdown.intervalMs || 0)}`; if (countdown.type === "interval") {
return ""; 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) { function handleEditableKeyDown(event, callback) {
@ -78,6 +90,10 @@ function handleEditableKeyDown(event, callback) {
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 }) { export function TimerModule({ toolboxId, moduleId, context }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" })); const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {}; const textContent = context.moduleText?.timer || {};
@ -197,7 +213,23 @@ export function TimerModule({ toolboxId, moduleId, context }) {
} }
if (type === "interval") { if (type === "interval") {
const intervalMs = timePartsToDurationMs(draft.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; if (!countdown) return;
@ -216,11 +248,39 @@ export function TimerModule({ toolboxId, moduleId, context }) {
let nextCountdown = null; let nextCountdown = null;
if (countdown.type === "duration") nextCountdown = { ...countdown, targetAt: now + countdown.durationMs }; 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 === "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; if (!nextCountdown) return;
save({ ...data, countdowns: data.countdowns.map((item) => item.id === countdown.id ? nextCountdown : item) }); 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) { function renameCountdown(countdownId, label) {
save({ save({
...data, ...data,
@ -314,7 +374,7 @@ export function TimerModule({ toolboxId, moduleId, context }) {
{activeTab === "stopwatch" ? ( {activeTab === "stopwatch" ? (
<StopwatchLapList laps={data.stopwatch.laps} textContent={textContent} onRename={renameLap} onDelete={deleteLap} /> <StopwatchLapList laps={data.stopwatch.laps} textContent={textContent} onRename={renameLap} onDelete={deleteLap} />
) : ( ) : (
<CountdownList countdowns={visibleCountdowns} nowMs={nowMs} textContent={textContent} reorder={countdownReorder} reorderEnabled={!data.sortResults} parentId={moduleId} onRename={renameCountdown} onAlertModeChange={setCountdownAlertMode} onReset={resetCountdown} onDelete={deleteCountdown} /> <CountdownList countdowns={visibleCountdowns} nowMs={nowMs} textContent={textContent} reorder={countdownReorder} reorderEnabled={!data.sortResults} parentId={moduleId} onRename={renameCountdown} onAlertModeChange={setCountdownAlertMode} onReset={resetCountdown} onAutoRefreshToggle={toggleCountdownAutoRefresh} onDelete={deleteCountdown} />
)} )}
</div> </div>
</section> </section>
@ -399,27 +459,36 @@ function CountdownControls({ textContent, form, onChange, onSubmit }) {
</label> </label>
{form.type === "duration" && ( {form.type === "duration" && (
<label> <label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.durationLabel || "Durée"}</span> <span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} /> <TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label> </label>
)} )}
{form.type === "daily_time" && ( {form.type === "daily_time" && (
<label> <label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span> <span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} /> <TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label> </label>
)} )}
{form.type === "time_pattern" && ( {form.type === "time_pattern" && (
<label> <label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span> <span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard /> <TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label> </label>
)} )}
{form.type === "interval" && ( {form.type === "interval" && (
<div className="timer-interval-fields">
<label> <label>
<span className="sr-only">{textContent.intervalLabel || "Toutes les"}</span> <span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} /> <TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label> </label>
<label>
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
</label>
</div>
)} )}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button> <button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form> </form>
@ -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(""); const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>; if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return ( return (
@ -493,9 +562,12 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
{countdowns.map((countdown) => { {countdowns.map((countdown) => {
const remainingMs = countdown.targetMs - nowMs; const remainingMs = countdown.targetMs - nowMs;
const expired = remainingMs <= 0; const expired = remainingMs <= 0;
const canReset = countdown.type === "duration" const canReset = countdown.type === "duration";
|| countdown.type === "interval" const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
|| (countdown.type === "daily_time" && expired); const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
const refreshTitle = refreshBlocked
? getAlertGuardMessage(textContent)
: textContent.autoRefreshTitle || "Répéter automatiquement";
const className = [ const className = [
"timer-list-item", "timer-list-item",
expired ? "is-expired" : "", expired ? "is-expired" : "",
@ -508,7 +580,7 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
return ( return (
<li key={countdown.id} className={className} {...reorderProps}> <li key={countdown.id} className={className} {...reorderProps}>
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${canReset && editingId !== countdown.id ? "has-inline-reset" : ""}`}> <div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{reorderEnabled && ( {reorderEnabled && (
<button <button
className="timer-drag-handle" className="timer-drag-handle"
@ -556,6 +628,23 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
<Icon name="refresh" /> <Icon name="refresh" />
</button> </button>
)} )}
{canToggleRefresh && (
<button
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onAutoRefreshToggle(countdown.id);
}}
disabled={refreshBlocked}
aria-pressed={Boolean(countdown.autoRefresh)}
aria-label={refreshTitle}
title={refreshTitle}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
)}
<TimerAlertModeSwitch <TimerAlertModeSwitch
mode={countdown.alertMode} mode={countdown.alertMode}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)} blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
@ -571,8 +660,14 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
</button> </button>
</div> </div>
<p className="timer-entry-meta"> <p className="timer-entry-meta">
<span>{getCountdownMeta(countdown, textContent)}</span> <span>
<em>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} · ${formatTargetTime(countdown.targetMs)}`}</em> {getCountdownMetaParts(countdown, textContent).map((part, index) => (
<b key={`${part}-${index}`}>{part}</b>
))}
</span>
<em>
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
</em>
</p> </p>
</li> </li>
); );

View file

@ -113,10 +113,22 @@ export function getIntervalTargetMs(intervalMs, anchorAt, nowMs) {
return safeAnchor + Math.ceil((nowMs - safeAnchor + 1) / safeIntervalMs) * safeIntervalMs; return safeAnchor + Math.ceil((nowMs - safeAnchor + 1) / safeIntervalMs) * safeIntervalMs;
} }
export function getIntervalAnchorMs(startMode, startTime, nowMs) {
if (startMode !== "time") return nowMs;
const parsed = parseTimeString(startTime);
if (!parsed) return nowMs;
const anchor = new Date(nowMs);
anchor.setHours(Number(parsed.hours), Number(parsed.minutes), Number(parsed.seconds), 0);
return anchor.getTime();
}
export function getCountdownTargetMs(countdown, nowMs) { export function getCountdownTargetMs(countdown, nowMs) {
if (countdown.type === "duration") return Number(countdown.targetAt) || 0; if (countdown.type === "duration") return Number(countdown.targetAt) || 0;
if (countdown.type === "daily_time") return Number(countdown.targetAt) || 0; if (countdown.type === "daily_time") return countdown.autoRefresh ? getDailyTargetMs(countdown.time, nowMs) : Number(countdown.targetAt) || 0;
if (countdown.type === "time_pattern") return getTimePatternTargetMs(countdown.pattern, nowMs); if (countdown.type === "time_pattern") return getTimePatternTargetMs(countdown.pattern, nowMs);
if (countdown.type === "interval") return getIntervalTargetMs(countdown.intervalMs, countdown.anchorAt, nowMs); if (countdown.type === "interval") {
if (countdown.autoRefresh) return getIntervalTargetMs(countdown.intervalMs, countdown.anchorAt, nowMs);
return Number(countdown.targetAt) || 0;
}
return 0; return 0;
} }

View file

@ -16,6 +16,8 @@ const NOTEPAD_RGB_COLOR_MAP = {
const TIMER_TABS = new Set(["stopwatch", "countdown"]); const TIMER_TABS = new Set(["stopwatch", "countdown"]);
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]); const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]); const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
const TIMER_INTERVAL_START_MODES = new Set(["now", "time"]);
const TIMER_MIN_AUTO_REFRESH_MS = 5 * 60 * 1000;
const TASK_TYPES = new Set(["unique", "daily", "weekly"]); const TASK_TYPES = new Set(["unique", "daily", "weekly"]);
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]); const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]); const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
@ -553,7 +555,7 @@ export function normalizeTimerData(data) {
if (type === "daily_time") { if (type === "daily_time") {
const time = String(countdown?.time || "").trim(); const time = String(countdown?.time || "").trim();
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0); const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt } : null; return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt, autoRefresh: countdown?.autoRefresh === true } : null;
} }
if (type === "time_pattern") { if (type === "time_pattern") {
const pattern = String(countdown?.pattern || "").trim().toUpperCase(); const pattern = String(countdown?.pattern || "").trim().toUpperCase();
@ -562,7 +564,21 @@ export function normalizeTimerData(data) {
if (type === "interval") { if (type === "interval") {
const intervalMs = Math.max(0, Number(countdown?.intervalMs) || 0); const intervalMs = Math.max(0, Number(countdown?.intervalMs) || 0);
const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0); const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0);
return intervalMs > 0 ? { ...normalized, intervalMs, anchorAt } : null; const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
const startMode = TIMER_INTERVAL_START_MODES.has(countdown?.startMode) ? countdown.startMode : "now";
const startTime = String(countdown?.startTime || "").trim();
const autoRefresh = countdown?.autoRefresh === true || (countdown?.autoRefresh !== false && !targetAt && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS);
return intervalMs > 0
? {
...normalized,
intervalMs,
anchorAt,
targetAt: targetAt || anchorAt + intervalMs,
startMode: startMode === "time" && isTimeString(startTime) ? "time" : "now",
...(startMode === "time" && isTimeString(startTime) ? { startTime } : {}),
autoRefresh: autoRefresh && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS
}
: null;
} }
return null; return null;
}) })
@ -822,11 +838,14 @@ export function compactModuleDataForStorage(type, value) {
} }
if (normalized.countdowns.length) { if (normalized.countdowns.length) {
compact.countdowns = normalized.countdowns.map((countdown) => { compact.countdowns = normalized.countdowns.map((countdown) => {
if (countdown.alertMode === "off") { const { alertMode, autoRefresh, startMode, startTime, ...compactCountdown } = countdown;
const { alertMode, ...compactCountdown } = countdown; if (alertMode !== "off") compactCountdown.alertMode = alertMode;
return compactCountdown; if (autoRefresh) compactCountdown.autoRefresh = true;
if (startMode === "time" && startTime) {
compactCountdown.startMode = startMode;
compactCountdown.startTime = startTime;
} }
return countdown; return compactCountdown;
}); });
} }
return Object.keys(compact).length ? compact : null; return Object.keys(compact).length ? compact : null;

View file

@ -3453,6 +3453,11 @@ button.combo-input-token.combo-value-grab .ui-icon {
gap: 7px; gap: 7px;
} }
.timer-field-spacer {
display: block;
min-height: 17px;
}
.timer-countdown-form input, .timer-countdown-form input,
.timer-countdown-form select, .timer-countdown-form select,
.timer-control-panel input { .timer-control-panel input {
@ -3578,6 +3583,32 @@ button.combo-input-token.combo-value-grab .ui-icon {
text-align: center; text-align: center;
} }
.timer-interval-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
min-width: 0;
}
.timer-interval-fields label {
min-width: 0;
gap: 5px;
}
.timer-interval-fields .time-parts-input {
grid-template-columns: minmax(22px, 1fr) 4px minmax(22px, 1fr) 4px minmax(22px, 1fr);
min-height: 38px;
}
.timer-interval-fields .time-parts-input input {
min-height: 36px;
font-size: var(--font-size-xs);
}
.timer-interval-fields .time-parts-input span {
font-size: var(--font-size-xs);
}
.timer-control-panel > span, .timer-control-panel > span,
.timer-control-panel label span { .timer-control-panel label span {
color: var(--color-text-secondary); color: var(--color-text-secondary);
@ -3658,12 +3689,13 @@ button.combo-input-token.combo-value-grab .ui-icon {
.timer-entry-meta { .timer-entry-meta {
display: flex; display: flex;
grid-column: 1 / -2; grid-column: 1 / -2;
flex-wrap: wrap; flex-wrap: nowrap;
min-width: 0; min-width: 0;
gap: 4px 8px; gap: 4px 8px;
margin: 0; margin: 0;
padding: 1px 10px 0; padding: 1px 10px 0;
opacity: 0.68; opacity: 0.68;
overflow: hidden;
} }
.timer-entry-summary .timer-value, .timer-entry-summary .timer-value,
@ -3735,7 +3767,49 @@ button.combo-input-token.combo-value-grab .ui-icon {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.timer-inline-reset .ui-icon { .timer-inline-refresh-toggle {
display: inline-grid;
grid-template-columns: 13px 18px;
width: 42px;
min-width: 42px;
min-height: 26px;
align-items: center;
gap: 3px;
padding: 0 4px;
border-color: rgba(165, 180, 252, 0.08);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.26);
color: var(--color-text-muted);
}
.timer-inline-refresh-toggle i {
position: relative;
display: block;
width: 18px;
height: 12px;
border: 1px solid rgba(165, 180, 252, 0.18);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.72);
}
.timer-inline-refresh-toggle i::before {
content: "";
position: absolute;
top: 50%;
left: 2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-text-muted);
transform: translateY(-50%);
transition:
left var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.timer-inline-reset .ui-icon,
.timer-inline-refresh-toggle .ui-icon {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
@ -3766,16 +3840,47 @@ button.combo-input-token.combo-value-grab .ui-icon {
} }
.timer-entry-meta span { .timer-entry-meta span {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
overflow: hidden;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 700; font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
} }
.timer-list-item em { .timer-list-item em {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
white-space: nowrap;
}
.timer-entry-meta b {
min-width: 0;
overflow: hidden;
font: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
.timer-entry-meta b + b::before,
.timer-entry-meta em::before {
content: "";
display: inline-block;
width: 1px;
height: 10px;
margin-right: 7px;
background: rgba(165, 180, 252, 0.22);
vertical-align: -1px;
} }
.timer-results-card { .timer-results-card {
@ -4185,14 +4290,16 @@ button.combo-input-token.combo-value-grab .ui-icon {
} }
.timer-alert-mode-button:hover, .timer-alert-mode-button:hover,
.timer-alert-mode-button.active { .timer-alert-mode-button.active,
.timer-inline-refresh-toggle:hover:not(:disabled) {
border-color: rgba(196, 181, 253, 0.2); border-color: rgba(196, 181, 253, 0.2);
background: rgba(21, 26, 48, 0.42); background: rgba(21, 26, 48, 0.42);
color: var(--color-text-primary); color: var(--color-text-primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
} }
.timer-alert-mode-button.active { .timer-alert-mode-button.active,
.timer-inline-refresh-toggle.active {
border-color: rgba(246, 196, 83, 0.26); border-color: rgba(246, 196, 83, 0.26);
background: background:
linear-gradient(135deg, rgba(246, 196, 83, 0.08), rgba(139, 92, 246, 0.08)), linear-gradient(135deg, rgba(246, 196, 83, 0.08), rgba(139, 92, 246, 0.08)),
@ -4200,9 +4307,23 @@ button.combo-input-token.combo-value-grab .ui-icon {
color: var(--color-accent-gold); color: var(--color-accent-gold);
} }
.timer-inline-refresh-toggle.active i {
border-color: rgba(246, 196, 83, 0.36);
background: rgba(246, 196, 83, 0.12);
}
.timer-inline-refresh-toggle.active i::before {
left: 7px;
background: var(--color-accent-gold);
box-shadow: 0 0 10px rgba(246, 196, 83, 0.28);
}
.timer-alert-mode-button:disabled, .timer-alert-mode-button:disabled,
.timer-alert-mode-button:disabled:hover, .timer-alert-mode-button:disabled:hover,
.timer-alert-mode-button.active:disabled { .timer-alert-mode-button.active:disabled,
.timer-inline-refresh-toggle:disabled,
.timer-inline-refresh-toggle:disabled:hover,
.timer-inline-refresh-toggle.active:disabled {
cursor: not-allowed; cursor: not-allowed;
border-color: rgba(165, 180, 252, 0.025); border-color: rgba(165, 180, 252, 0.025);
background: rgba(5, 7, 17, 0.08); background: rgba(5, 7, 17, 0.08);
@ -4211,7 +4332,8 @@ button.combo-input-token.combo-value-grab .ui-icon {
box-shadow: none; box-shadow: none;
} }
.timer-alert-mode-button:disabled .ui-icon { .timer-alert-mode-button:disabled .ui-icon,
.timer-inline-refresh-toggle:disabled .ui-icon {
opacity: 0.34; opacity: 0.34;
} }