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

@ -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 <span className="timer-value">{formatDuration(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) {
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" ? (
<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>
</section>
@ -399,27 +459,36 @@ function CountdownControls({ textContent, form, onChange, onSubmit }) {
</label>
{form.type === "duration" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<label>
<span className="sr-only">{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
<div className="timer-interval-fields">
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</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>
</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("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
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 (
<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 && (
<button
className="timer-drag-handle"
@ -556,6 +628,23 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
<Icon name="refresh" />
</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
mode={countdown.alertMode}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
@ -571,8 +660,14 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
</button>
</div>
<p className="timer-entry-meta">
<span>{getCountdownMeta(countdown, textContent)}</span>
<em>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} · ${formatTargetTime(countdown.targetMs)}`}</em>
<span>
{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>
</li>
);

View file

@ -113,10 +113,22 @@ export function getIntervalTargetMs(intervalMs, anchorAt, nowMs) {
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) {
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 === "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;
}