new tool timer & alert revamp
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-26 11:41:42 +02:00
parent 0c7ae46b4d
commit 68005ef918
38 changed files with 2048 additions and 111 deletions

View file

@ -136,23 +136,23 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
</div>
</form>
<div
className={`calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
className={`tool-split-results calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
>
<div className="calculator-result-actions">
<div className="tool-split-actions">
{activeParent && (
<button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}>
<button className="tool-split-root-button" type="button" onClick={() => setActiveParentId("")}>
{textContent.rootButton || "Revenir à la racine"}
</button>
)}
<button className="calculator-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<button className="tool-split-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" />
</button>
<button className="calculator-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<button className="tool-split-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<Icon name="copy" />
</button>
<button
className={`calculator-scroll-toggle ${data.scrollResults ? "active" : ""}`}
className={`tool-split-scroll-toggle ${data.scrollResults ? "active" : ""}`}
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
@ -163,7 +163,7 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
<i aria-hidden="true" />
</button>
</div>
<div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{data.entries.length ? (
<CalculatorEntries entries={data.entries} parentId="" activeParentId={activeParentId} textContent={textContent} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
) : (
@ -181,19 +181,19 @@ function CalculatorEntries({ entries, parentId, activeParentId, textContent, onU
if (!children.length) return null;
return (
<ul className="calculator-entry-list">
<ul className="tool-split-entry-list calculator-entry-list">
{children.map((entry) => (
<li className={`${entry.id === activeParentId ? "active" : ""} ${entry.id === editingId ? "is-editing" : ""}`} key={entry.id}>
<div className="calculator-entry">
<div className="tool-split-entry calculator-entry">
{entry.id === editingId ? (
<>
<span className="calculator-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
<span className="tool-split-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
<strong>{formatResult(entry.value)}</strong>
</span>
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
</>
) : (
<button className="calculator-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<span><strong>{formatResult(entry.value)}</strong> {entry.label}</span>
</button>
)}
@ -228,7 +228,7 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
return (
<input
ref={inputRef}
className="calculator-entry-label"
className="tool-split-entry-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
onBlur={saveLabel}

View file

@ -83,7 +83,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
<div className="annotation-preview-actions">
<button
className="calculator-action-button"
className="tool-split-action-button"
type="button"
onClick={() => context.setImage({
dataUrl: data.image,
@ -103,7 +103,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
<Icon name="zoom" />
</button>
{editing && (
<button className="calculator-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
<button className="tool-split-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
<Icon name="trash" />
</button>
)}

View file

@ -0,0 +1,572 @@
// Rôle : fournit l'outil timer avec chronomètre à étapes et comptes à rebours multiples.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import {
formatDuration,
formatDurationWithCentiseconds,
formatTargetTime,
getCentiseconds,
getCountdownTargetMs,
getDailyTargetMs,
getNowMs,
getTimePatternTargetMs,
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
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 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;
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>;
return (
<span className="timer-value is-compact">
<span>{formatDuration(ms)}</span>
<small>{getCentiseconds(ms)}</small>
</span>
);
}
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 handleEditableKeyDown(event, callback) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
callback();
}
export function TimerModule({ toolboxId, moduleId, context }) {
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 [countdownForm, setCountdownForm] = useState({
label: "",
type: "duration",
duration: { hours: "", minutes: "", seconds: "" },
time: { hours: "", minutes: "", seconds: "" },
pattern: { hours: "", minutes: "", seconds: "" },
interval: { hours: "", minutes: "", seconds: "" }
});
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]);
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 updateCountdownForm(patch) {
setCountdownForm((current) => ({ ...current, ...patch }));
}
function createCountdown(event) {
event.preventDefault();
const type = COUNTDOWN_TYPES.includes(countdownForm.type) ? countdownForm.type : "duration";
const label = countdownForm.label.trim() || textContent.defaultCountdownLabel || "Timer";
const now = getNowMs();
let countdown = null;
if (type === "duration") {
const durationMs = timePartsToDurationMs(countdownForm.duration);
if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs };
}
if (type === "daily_time") {
const time = timePartsToString(countdownForm.time);
const targetAt = getDailyTargetMs(time, now);
if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt };
}
if (type === "time_pattern") {
const pattern = timePartsToString(countdownForm.pattern, { allowWildcard: true });
if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern };
}
if (type === "interval") {
const intervalMs = timePartsToDurationMs(countdownForm.interval);
if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now };
}
if (!countdown) return;
save({ ...data, countdowns: [...data.countdowns, countdown] });
setCountdownForm((current) => ({ ...current, 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") nextCountdown = { ...countdown, anchorAt: now };
if (!nextCountdown) return;
save({ ...data, countdowns: data.countdowns.map((item) => item.id === countdown.id ? nextCountdown : 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);
const targetMs = countdown ? getCountdownTargetMs(countdown, nowMs) : 0;
if (alertMode !== "off" && targetMs && targetMs - nowMs < ALERT_MIN_DELAY_MS) {
context.notify?.(textContent.alertTooSoonTitle || "Alerte indisponible à moins de 5 minutes");
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 (
<div className="timer-module">
<section className="timer-control-card">
<Tabs
className="timer-tabs"
tabs={[
{ ...TIMER_TABS[0], label: textContent.stopwatchTab || TIMER_TABS[0].label },
{ ...TIMER_TABS[1], label: textContent.countdownTab || TIMER_TABS[1].label }
]}
activeTab={activeTab}
onChange={setActiveTab}
iconOnly
/>
{activeTab === "stopwatch" ? (
<StopwatchControls
textContent={textContent}
elapsedMs={stopwatchElapsedMs}
running={Boolean(data.stopwatch.startedAt)}
onStart={startStopwatch}
onPause={pauseStopwatch}
onReset={resetStopwatch}
onLap={addLap}
/>
) : (
<CountdownControls
textContent={textContent}
form={countdownForm}
onChange={updateCountdownForm}
onSubmit={createCountdown}
/>
)}
</section>
<section className={`tool-split-results timer-results-card ${data.scrollResults ? "is-scrollable" : ""}`}>
<div className="tool-split-actions">
<button className="tool-split-action-button danger" type="button" onClick={resetActiveResults} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" />
</button>
{activeTab === "countdown" && (
<button
className={`tool-split-scroll-toggle timer-sort-toggle ${data.sortResults ? "active" : ""}`}
type="button"
onClick={() => setSortResults(!data.sortResults)}
aria-pressed={data.sortResults}
title={textContent.sortTitle || "Trier par temps restant"}
aria-label={textContent.sortTitle || "Trier par temps restant"}
>
<Icon name="sort-time" />
<i aria-hidden="true" />
</button>
)}
<button
className={`tool-split-scroll-toggle timer-scroll-toggle ${data.scrollResults ? "active" : ""}`}
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
title={textContent.scrollableTitle || "Liste scrollable"}
aria-label={textContent.scrollableTitle || "Liste scrollable"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
</div>
<div className={`tool-split-tree timer-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{activeTab === "stopwatch" ? (
<StopwatchLapList laps={data.stopwatch.laps} textContent={textContent} onRename={renameLap} onDelete={deleteLap} />
) : (
<CountdownList countdowns={visibleCountdowns} nowMs={nowMs} textContent={textContent} onRename={renameCountdown} onAlertModeChange={setCountdownAlertMode} onReset={resetCountdown} onDelete={deleteCountdown} />
)}
</div>
</section>
</div>
);
}
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 = textContent.alertTooSoonTitle || "Alerte indisponible à moins de 5 minutes";
return (
<div
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
role="group"
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
title={blocked ? blockedTitle : undefined}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = blocked && option.mode !== "off";
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
aria-pressed={mode === option.mode}
aria-label={disabled ? blockedTitle : label}
title={disabled ? blockedTitle : label}
>
<Icon name={option.icon} />
</button>
);
})}
</div>
);
}
function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
return (
<div className="timer-control-panel" role="tabpanel">
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
<strong><TimerValue ms={elapsedMs} compact /></strong>
<div className="timer-actions">
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
<Icon name={running ? "pause-circle" : "play-circle"} />
</button>
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
<Icon name="record-circle" />
</button>
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="stop-circle" />
</button>
</div>
</div>
);
}
function CountdownControls({ textContent, form, onChange, onSubmit }) {
return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit}>
<label>
<span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
</label>
<label>
<span>{textContent.countdownTypeLabel || "Type"}</span>
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
<option value="duration">{textContent.durationType || "Durée"}</option>
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
<option value="time_pattern">{textContent.timePatternType || "Horaire spécifique"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span>{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span>{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span>{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form>
);
}
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 (
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
</div>
);
}
function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{laps.map((lap, index) => (
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
<div className="tool-split-entry timer-entry has-single-action">
{editingId === lap.id ? (
<EditableTimerLabel
value={lap.label}
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
onRename={(label) => onRename(lap.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={lap.elapsedMs} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(lap.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
>
<span className="timer-entry-line">
<TimerValue ms={lap.elapsedMs} />
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
);
}
function CountdownList({ countdowns, nowMs, textContent, onRename, onAlertModeChange, onReset, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{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);
return (
<li key={countdown.id} className={`timer-list-item ${expired ? "is-expired" : ""} ${editingId === countdown.id ? "is-editing" : ""}`}>
<div className={`tool-split-entry timer-entry has-single-action ${editingId !== countdown.id ? "has-inline-controls" : ""} ${canReset && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{editingId === countdown.id ? (
<EditableTimerLabel
value={countdown.label}
placeholder={textContent.defaultCountdownLabel || "Timer"}
ariaLabel={textContent.countdownLabel || "Libellé"}
onRename={(label) => onRename(countdown.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={remainingMs} showCentiseconds={false} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(countdown.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
>
<span className="timer-entry-line">
<TimerValue ms={remainingMs} showCentiseconds={false} />
<span>{countdown.label}</span>
<span className="timer-inline-controls">
{canReset && (
<button
className="timer-inline-reset"
type="button"
onClick={(event) => {
event.stopPropagation();
onReset(countdown);
}}
aria-label={textContent.resetTitle || "Réinitialiser"}
title={textContent.resetTitle || "Réinitialiser"}
>
<Icon name="refresh" />
</button>
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={countdown.targetMs && countdown.targetMs - nowMs < ALERT_MIN_DELAY_MS}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>
</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</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>
</p>
</li>
);
})}
</ul>
);
}
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const [label, setLabel] = useState(value);
const inputRef = useRef(null);
useLayoutEffect(() => {
inputRef.current?.focus();
}, []);
function saveLabel() {
onRename(label);
onDone();
}
return (
<>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input
ref={inputRef}
className="tool-split-entry-label timer-lap-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
onBlur={saveLabel}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setLabel(value);
onDone();
}
}}
placeholder={placeholder}
aria-label={ariaLabel}
/>
</>
);
}

View file

@ -11,6 +11,7 @@ import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
@ -19,6 +20,7 @@ const MODULE_COMPONENTS = {
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
};

View file

@ -0,0 +1,108 @@
// Rôle : regroupe les calculs purs de formatage et d'échéance des timers.
export function getNowMs() {
return Date.now();
}
export function pad(value) {
return String(value).padStart(2, "0");
}
export function formatDuration(ms) {
const safeMs = Math.max(0, Math.floor(ms));
const totalSeconds = Math.floor(safeMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
export function getCentiseconds(ms) {
return pad(Math.floor((Math.max(0, Math.floor(ms)) % 1000) / 10));
}
export function formatDurationWithCentiseconds(ms) {
return `${formatDuration(ms)}:${getCentiseconds(ms)}`;
}
export function formatTargetTime(ms) {
if (!Number.isFinite(ms)) return "";
return new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
}).format(new Date(ms));
}
export function normalizeTimePart(value, max, allowWildcard = false) {
const cleanValue = String(value || "").trim().toUpperCase();
if (allowWildcard && !cleanValue) return "X";
if (allowWildcard && /^X+$/.test(cleanValue)) return "X";
const parsed = cleanValue ? Number.parseInt(cleanValue, 10) : 0;
if (!Number.isFinite(parsed) || parsed < 0 || parsed > max) return "";
return pad(parsed);
}
export function normalizeTimeParts(parts, { allowWildcard = false, allowLargeHours = false } = {}) {
const hours = normalizeTimePart(parts?.hours, allowLargeHours ? 999 : 23, allowWildcard);
const minutes = normalizeTimePart(parts?.minutes, 59, allowWildcard);
const seconds = normalizeTimePart(parts?.seconds, 59, allowWildcard);
if (!hours || !minutes || !seconds) return null;
return { hours, minutes, seconds };
}
export function timePartsToDurationMs(parts) {
const normalized = normalizeTimeParts(parts, { allowLargeHours: true });
if (!normalized) return 0;
return ((Number(normalized.hours) * 3600) + (Number(normalized.minutes) * 60) + Number(normalized.seconds)) * 1000;
}
export function timePartsToString(parts, options) {
const normalized = normalizeTimeParts(parts, options);
return normalized ? `${normalized.hours}:${normalized.minutes}:${normalized.seconds}` : "";
}
export function parseTimeString(value, options) {
const [hours = "", minutes = "", seconds = ""] = String(value || "").split(":");
return normalizeTimeParts({ hours, minutes, seconds }, options);
}
export function getDailyTargetMs(time, nowMs) {
const parsed = parseTimeString(time);
if (!parsed) return 0;
const target = new Date(nowMs);
target.setHours(Number(parsed.hours), Number(parsed.minutes), Number(parsed.seconds), 0);
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
return target.getTime();
}
export function getTimePatternTargetMs(pattern, nowMs) {
const parsed = parseTimeString(pattern, { allowWildcard: true });
if (!parsed) return 0;
const now = new Date(nowMs);
const candidate = new Date(nowMs);
candidate.setMilliseconds(0);
for (let offset = 1; offset <= 86400; offset += 1) {
candidate.setTime(now.getTime() + offset * 1000);
const hours = parsed.hours === "X" || Number(parsed.hours) === candidate.getHours();
const minutes = parsed.minutes === "X" || Number(parsed.minutes) === candidate.getMinutes();
const seconds = parsed.seconds === "X" || Number(parsed.seconds) === candidate.getSeconds();
if (hours && minutes && seconds) return candidate.getTime();
}
return 0;
}
export function getIntervalTargetMs(intervalMs, anchorAt, nowMs) {
const safeIntervalMs = Math.max(1000, Number(intervalMs) || 0);
const safeAnchor = Number.isFinite(anchorAt) && anchorAt > 0 ? anchorAt : nowMs;
if (safeAnchor > nowMs) return safeAnchor;
return safeAnchor + Math.ceil((nowMs - safeAnchor + 1) / safeIntervalMs) * safeIntervalMs;
}
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 === "time_pattern") return getTimePatternTargetMs(countdown.pattern, nowMs);
if (countdown.type === "interval") return getIntervalTargetMs(countdown.intervalMs, countdown.anchorAt, nowMs);
return 0;
}