keepsafe rework & alert ajustments
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-26 17:48:14 +02:00
parent 68005ef918
commit 2e6d8a2c0c
11 changed files with 129 additions and 45 deletions

View file

@ -244,6 +244,8 @@ Types de compte à rebours :
- `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`.
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.
Stockage compact :
- `activeTab` est omis si la valeur vaut `stopwatch`.
@ -255,7 +257,7 @@ 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`.
- L'activation d'une alerte est bloquée dans l'interface si la prochaine échéance est à moins de 5 minutes.
- 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 Liens

View file

@ -2,6 +2,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
test("colon text import keeps urls intact after the first separator", () => {
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
@ -15,3 +16,13 @@ test("colon text import accepts labels without value", () => {
{ label: "Simple item", value: "" }
]);
});
test("time pattern recurrence uses configured frequency instead of next remaining delay", () => {
const now = new Date(2026, 0, 1, 13, 23, 45, 0).getTime();
const target = getTimePatternTargetMs("X:24:X", now);
assert.equal(new Date(target).getHours(), 13);
assert.equal(new Date(target).getMinutes(), 24);
assert.equal(new Date(target).getSeconds(), 0);
assert.equal(getTimePatternRecurrenceMs("X:24:X"), 60 * 60 * 1000);
assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000);
});

View file

@ -296,7 +296,7 @@
"countdownTypeLabel": "Type",
"durationType": "Durée",
"dailyTimeType": "Heure précise",
"timePatternType": "Horaire spécifique",
"timePatternType": "Pattern horaire",
"intervalType": "Intervalle",
"durationLabel": "Durée",
"dailyTimeLabel": "Heure",
@ -315,7 +315,7 @@
"alertOffTitle": "Pas d'alerte",
"alertVisibleTitle": "Alerte quand la toolbox est affichée",
"alertSiteTitle": "Alerte sur tout le site",
"alertTooSoonTitle": "Alerte indisponible à moins de 5 minutes",
"alertTooSoonTitle": "Alerte indisponible pour une répétition inférieure à 5 minutes",
"alertMessage": "{label} est terminé dans {toolbox}.",
"emptySteps": "Aucune étape enregistrée.",
"emptyCountdowns": "Aucun compte à rebours configuré.",

View file

@ -18,8 +18,8 @@ export function AppOverlays({
setStorageError,
image,
setImage,
notification,
setNotification,
notifications,
dismissNotification,
toastPosition,
getGame,
toolboxes,
@ -49,7 +49,18 @@ export function AppOverlays({
}} />}
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
{notification && <NotificationToast key={notification.id} message={notification.message} position={toastPosition} onClose={() => setNotification(null)} />}
{notifications.length > 0 && (
<div className={`notification-toast-stack is-${toastPosition === "left" ? "left" : "right"}`} aria-live="polite" aria-relevant="additions">
{notifications.map((notification) => (
<NotificationToast
key={notification.id}
id={notification.id}
message={notification.message}
onClose={dismissNotification}
/>
))}
</div>
)}
</>
);
}

View file

@ -24,11 +24,16 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance
);
}
export function NotificationToast({ message, position = "right", onClose }) {
export function NotificationToast({ id, message, onClose }) {
useEffect(() => {
const timeoutId = window.setTimeout(() => onClose(id), 120000);
return () => window.clearTimeout(timeoutId);
}, [id, onClose]);
return (
<div className={`notification-toast is-${position === "left" ? "left" : "right"}`} role="status" aria-live="polite">
<div className="notification-toast" role="status">
<span>{message}</span>
<button type="button" onClick={onClose} aria-label="Fermer l'alerte" title="Fermer">
<button type="button" onClick={() => onClose(id)} aria-label="Fermer l'alerte" title="Fermer">
<Icon name="close" />
</button>
</div>

View file

@ -11,6 +11,7 @@ import {
getDailyTargetMs,
getNowMs,
getTimePatternTargetMs,
getTimePatternRecurrenceMs,
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
@ -27,6 +28,17 @@ const ALERT_MODES = [
{ mode: "site", icon: "sound-max" }
];
const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
function isAlertGuarded(countdown) {
if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
return false;
}
function getAlertGuardMessage(textContent) {
return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
}
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
@ -204,9 +216,8 @@ export function TimerModule({ toolboxId, moduleId, context }) {
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");
if (alertMode !== "off" && AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && isAlertGuarded(countdown)) {
context.notify?.(getAlertGuardMessage(textContent));
return;
}
save({
@ -303,7 +314,7 @@ function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
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";
const blockedTitle = getAlertGuardMessage(textContent);
return (
<div
@ -316,11 +327,11 @@ function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = blocked && option.mode !== "off";
const disabled = Boolean(blocked);
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
@ -368,31 +379,31 @@ function CountdownControls({ textContent, form, onChange, onSubmit }) {
<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="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span>{textContent.durationLabel || "Durée"}</span>
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span>{textContent.dailyTimeLabel || "Heure"}</span>
<span className="sr-only">{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>
<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>{textContent.intervalLabel || "Toutes les"}</span>
<span className="sr-only">{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
)}
@ -512,7 +523,7 @@ function CountdownList({ countdowns, nowMs, textContent, onRename, onAlertModeCh
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={countdown.targetMs && countdown.targetMs - nowMs < ALERT_MIN_DELAY_MS}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>

View file

@ -81,17 +81,31 @@ export function getTimePatternTargetMs(pattern, nowMs) {
const now = new Date(nowMs);
const candidate = new Date(nowMs);
candidate.setMilliseconds(0);
const hasHour = parsed.hours !== "X";
const hasMinute = parsed.minutes !== "X";
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();
const minutes = parsed.minutes === "X"
? (!hasHour || candidate.getMinutes() === 0)
: Number(parsed.minutes) === candidate.getMinutes();
const seconds = parsed.seconds === "X"
? (!hasHour && !hasMinute) || candidate.getSeconds() === 0
: Number(parsed.seconds) === candidate.getSeconds();
if (hours && minutes && seconds) return candidate.getTime();
}
return 0;
}
export function getTimePatternRecurrenceMs(pattern) {
const baseMs = new Date(2026, 0, 1, 0, 0, 0, 0).getTime();
const firstTargetMs = getTimePatternTargetMs(pattern, baseMs);
if (!firstTargetMs) return 0;
const secondTargetMs = getTimePatternTargetMs(pattern, firstTargetMs);
return secondTargetMs ? secondTargetMs - firstTargetMs : 0;
}
export function getIntervalTargetMs(intervalMs, anchorAt, nowMs) {
const safeIntervalMs = Math.max(1000, Number(intervalMs) || 0);
const safeAnchor = Number.isFinite(anchorAt) && anchorAt > 0 ? anchorAt : nowMs;

View file

@ -23,12 +23,19 @@ function App() {
const [linkModalGameId, setLinkModalGameId] = useState("");
const [drawerGameId, setDrawerGameId] = useState("");
const [image, setImage] = useState(null);
const [notification, setNotification] = useState(null);
const [notifications, setNotifications] = useState([]);
const [storageError, setStorageError] = useState("");
const store = useIndexedToolboxes((message) => setStorageError(message));
const notify = useCallback((message) => {
setNotification({ id: Date.now(), message });
setNotifications((current) => [
{ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message },
...current
].slice(0, 5));
}, []);
const dismissNotification = useCallback((notificationId) => {
setNotifications((current) => current.filter((notification) => notification.id !== notificationId));
}, []);
useEffect(() => {
@ -40,12 +47,6 @@ function App() {
return () => window.removeEventListener("sokkog:notify", handleNotification);
}, [notify]);
useEffect(() => {
if (!notification) return undefined;
const timeoutId = window.setTimeout(() => setNotification(null), 120000);
return () => window.clearTimeout(timeoutId);
}, [notification]);
const t = (key, { capitalize = false } = {}) => {
const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " ");
return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;
@ -139,8 +140,8 @@ function App() {
setStorageError={setStorageError}
image={image}
setImage={setImage}
notification={notification}
setNotification={setNotification}
notifications={notifications}
dismissNotification={dismissNotification}
toastPosition={store.toastPosition}
getGame={getGame}
toolboxes={store.toolboxes}

View file

@ -3,6 +3,18 @@
box-sizing: border-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip: rect(0 0 0 0);
white-space: nowrap;
}
html {
min-height: 100%;
background: var(--color-bg-page);

View file

@ -327,16 +327,32 @@ body.is-resizing-drawer * {
initial-value: 0deg;
}
.notification-toast {
--notification-toast-angle: 0deg;
.notification-toast-stack {
position: fixed;
bottom: var(--space-5);
z-index: 360;
display: flex;
max-width: min(460px, calc(100vw - 32px));
flex-direction: column-reverse;
gap: var(--space-3);
pointer-events: none;
}
.notification-toast-stack.is-right {
right: var(--space-5);
}
.notification-toast-stack.is-left {
left: var(--space-5);
}
.notification-toast {
--notification-toast-angle: 0deg;
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
align-items: center;
gap: var(--space-3);
max-width: min(460px, calc(100vw - 32px));
width: 100%;
min-width: min(360px, calc(100vw - 32px));
padding: 14px 14px 14px 18px;
border: 2px solid transparent;
@ -359,21 +375,13 @@ body.is-resizing-drawer * {
font-size: var(--font-size-md);
font-weight: 900;
pointer-events: auto;
animation:
notification-toast-border-spin 2.4s linear infinite,
notification-toast-pulse 1.8s ease-in-out infinite;
}
.notification-toast.is-right {
right: var(--space-5);
animation:
notification-toast-enter-right 320ms var(--ease-standard),
notification-toast-border-spin 2.4s linear infinite,
notification-toast-pulse 1.8s ease-in-out infinite;
}
.notification-toast.is-left {
left: var(--space-5);
.notification-toast-stack.is-left .notification-toast {
animation:
notification-toast-enter-left 320ms var(--ease-standard),
notification-toast-border-spin 2.4s linear infinite,

View file

@ -1784,8 +1784,12 @@ textarea:focus {
.timer-alert-mode-switch.is-blocked {
opacity: 1;
cursor: not-allowed;
}
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:hover,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button.active,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled:hover,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button.active:disabled {
@ -1797,6 +1801,11 @@ textarea:focus {
opacity: 0.2;
}
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button .ui-icon,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled .ui-icon {
opacity: 1;
}
.checklist-section-collapse-button:hover {
border-color: rgba(246, 196, 83, 0.58);
background: