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

@ -1,6 +1,9 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k" };
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [
"toolbox.png",
@ -30,6 +33,7 @@ const DEFAULT_MODULE_TITLES = {
links: "Liens",
counters: "Compteurs",
calculator: "Calculateur",
timer: "Timer",
imageAnnotation: "Annotation d'images"
};
@ -210,6 +214,16 @@ function normalizeCalculatorValue(value) {
return Number.isFinite(parsed) ? parsed : 0;
}
function isTimeString(value, allowWildcard = false) {
const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/;
if (!pattern.test(String(value || ""))) return false;
return String(value).split(":").every((part, index) => {
if (part === "X") return allowWildcard;
const number = Number(part);
return Number.isInteger(number) && number >= 0 && number <= (index === 0 ? 23 : 59);
});
}
export function labelFromFileName(name) {
if (!name || name === "image.png") return "";
return name.replace(/\.[^.]+$/, "").trim();
@ -230,6 +244,62 @@ export function normalizeCalculatorData(data) {
};
}
export function normalizeTimerData(data) {
const stopwatch = data?.stopwatch || {};
const legacyAlertMode = TIMER_ALERT_MODES.has(data?.alertMode) ? data.alertMode : "off";
const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : [])
.map((lap) => ({
id: lap?.id || uid("timer"),
label: String(lap?.label || "").trim(),
elapsedMs: Math.max(0, Number(lap?.elapsedMs) || 0)
}))
.filter((lap) => lap.elapsedMs > 0);
const countdowns = (Array.isArray(data?.countdowns) ? data.countdowns : [])
.map((countdown) => {
const type = COUNTDOWN_TYPES.has(countdown?.type) ? countdown.type : "duration";
const normalized = {
id: countdown?.id || uid("timer"),
label: String(countdown?.label || "Timer").trim() || "Timer",
type,
alertMode: TIMER_ALERT_MODES.has(countdown?.alertMode) ? countdown.alertMode : legacyAlertMode
};
if (type === "duration") {
const durationMs = Math.max(0, Number(countdown?.durationMs) || 0);
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return durationMs > 0 && targetAt > 0 ? { ...normalized, durationMs, targetAt } : null;
}
if (type === "daily_time") {
const time = String(countdown?.time || "").trim();
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt } : null;
}
if (type === "time_pattern") {
const pattern = String(countdown?.pattern || "").trim().toUpperCase();
return isTimeString(pattern, true) ? { ...normalized, pattern } : null;
}
if (type === "interval") {
const legacyIntervalMs = (Number.parseInt(countdown?.intervalMinutes, 10) || 0) * 60000;
const intervalMs = Math.max(0, Number(countdown?.intervalMs) || legacyIntervalMs);
const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0);
return intervalMs > 0 ? { ...normalized, intervalMs, anchorAt } : null;
}
return null;
})
.filter(Boolean);
return {
activeTab: TIMER_TABS.has(data?.activeTab) ? data.activeTab : "stopwatch",
scrollResults: data?.scrollResults === true,
sortResults: data?.sortResults === true,
stopwatch: {
elapsedMs: Math.max(0, Number(stopwatch.elapsedMs) || 0),
startedAt: Math.max(0, Number(stopwatch.startedAt) || 0),
laps
},
countdowns
};
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
@ -339,6 +409,30 @@ export function compactModuleDataForStorage(type, value) {
if (!entries.length && !normalized.scrollResults) return null;
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
}
if (type === "timer") {
const normalized = normalizeTimerData(value);
const compact = {};
if (normalized.activeTab !== "stopwatch") compact.activeTab = normalized.activeTab;
if (normalized.scrollResults) compact.scrollResults = true;
if (normalized.sortResults) compact.sortResults = true;
if (normalized.stopwatch.elapsedMs || normalized.stopwatch.startedAt || normalized.stopwatch.laps.length) {
compact.stopwatch = {
...(normalized.stopwatch.elapsedMs ? { elapsedMs: normalized.stopwatch.elapsedMs } : {}),
...(normalized.stopwatch.startedAt ? { startedAt: normalized.stopwatch.startedAt } : {}),
...(normalized.stopwatch.laps.length ? { laps: normalized.stopwatch.laps } : {})
};
}
if (normalized.countdowns.length) {
compact.countdowns = normalized.countdowns.map((countdown) => {
if (countdown.alertMode === "off") {
const { alertMode, ...compactCountdown } = countdown;
return compactCountdown;
}
return countdown;
});
}
return Object.keys(compact).length ? compact : null;
}
return value;
}
@ -403,6 +497,20 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "timer") {
const remapped = { ...compact };
if (compact.stopwatch?.laps) {
remapped.stopwatch = {
...compact.stopwatch,
laps: compact.stopwatch.laps.map((lap) => ({ ...lap, id: nextId("timer") }))
};
}
if (compact.countdowns) {
remapped.countdowns = compact.countdowns.map((countdown) => ({ ...countdown, id: nextId("timer") }));
}
return remapped;
}
return compact;
}

View file

@ -3,12 +3,14 @@ import { useEffect, useState } from "react";
import {
getAllModuleData as dbGetAllModuleData,
getLinks as dbGetLinks,
getSetting as dbGetSetting,
getStorageEstimate,
getToolboxes as dbGetToolboxes,
removeModuleData as dbRemoveModuleData,
removeModuleDataKeys as dbRemoveModuleDataKeys,
setLinks as dbSetLinks,
setModuleData as dbSetModuleData,
setSetting as dbSetSetting,
setToolboxes as dbSetToolboxes
} from "../../../utils/indexedDbStorage.js";
import {
@ -23,6 +25,7 @@ export function useIndexedToolboxes(onError) {
const [toolboxes, setToolboxesState] = useState([]);
const [links, setLinksState] = useState({});
const [moduleData, setModuleDataState] = useState({});
const [toastPosition, setToastPositionState] = useState("right");
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
async function refreshQuota() {
@ -40,15 +43,17 @@ export function useIndexedToolboxes(onError) {
let cancelled = false;
async function loadStore() {
try {
const [storedToolboxes, storedLinks, storedModules] = await Promise.all([
const [storedToolboxes, storedLinks, storedModules, storedToastPosition] = await Promise.all([
dbGetToolboxes(),
dbGetLinks(),
dbGetAllModuleData()
dbGetAllModuleData(),
dbGetSetting("toastPosition", "right")
]);
if (cancelled) return;
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
setToastPositionState(storedToastPosition === "left" ? "left" : "right");
setReady(true);
refreshQuota();
} catch (error) {
@ -118,11 +123,20 @@ export function useIndexedToolboxes(onError) {
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
}
function setToastPosition(position) {
const normalized = position === "left" ? "left" : "right";
setToastPositionState(normalized);
dbSetSetting("toastPosition", normalized)
.then(refreshQuota)
.catch((error) => onError(error.message));
}
return {
ready,
toolboxes,
links,
moduleData,
toastPosition,
storageUsage,
setToolboxes: persistToolboxes,
setLinks: persistLinks,
@ -130,6 +144,7 @@ export function useIndexedToolboxes(onError) {
updateModuleData,
removeModuleData,
removeToolboxModuleData,
setToastPosition,
refreshQuota
};
}