diff --git a/server.mjs b/server.mjs
index 4ce822f..fb8ec24 100644
--- a/server.mjs
+++ b/server.mjs
@@ -67,7 +67,7 @@ function resolvePath(url) {
const publicCandidate = fileIfReadable(resolve(publicRoot, `.${requested}`));
if (publicCandidate && isInsideRoot(publicRoot, publicCandidate)) return publicCandidate;
- return extname(requested) ? null : join(root, "index.html");
+ return join(root, "index.html");
}
const server = createServer((req, res) => {
@@ -78,15 +78,9 @@ const server = createServer((req, res) => {
return;
}
- const extension = extname(file);
- const cacheControl = extension === ".html"
- ? "no-store"
- : extension === ".json"
- ? "no-cache"
- : "public, max-age=31536000, immutable";
res.writeHead(200, {
"Content-Type": types[extname(file)] || "application/octet-stream",
- "Cache-Control": cacheControl
+ "Cache-Control": "no-store"
});
createReadStream(file).pipe(res);
});
diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index 40b6463..0e8f8e3 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -200,10 +200,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
+ const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
const timerCountdown = await readFile("website/src/features/toolboxes/modules/TimerCountdown.jsx", "utf8");
const timerSharedControls = await readFile("website/src/features/toolboxes/modules/TimerSharedControls.jsx", "utf8");
const timerStopwatch = await readFile("website/src/features/toolboxes/modules/TimerStopwatch.jsx", "utf8");
- const timerBundle = `${timerModule}\n${timerCountdown}\n${timerSharedControls}\n${timerStopwatch}`;
+ const timerBundle = `${timerModule}\n${timerParts}\n${timerCountdown}\n${timerSharedControls}\n${timerStopwatch}`;
const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
const calendarModule = await readFile("website/src/features/toolboxes/modules/CalendarModule.jsx", "utf8");
const calendarController = await readFile("website/src/features/toolboxes/modules/useCalendarController.js", "utf8");
@@ -220,13 +221,14 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const calendarBundle = `${calendarModule}\n${calendarController}\n${calendarDraftControls}\n${calendarEventActions}\n${calendarLifecycle}\n${calendarModeChoice}\n${calendarContent}\n${calendarDraft}\n${calendarEventForm}\n${calendarEventControls}\n${calendarMonthDatePicker}\n${calendarViews}`;
const calendarUtils = await readFile("website/src/features/toolboxes/modules/calendarUtils.js", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
+ const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
const taskPlannerBranch = await readFile("website/src/features/toolboxes/modules/TaskPlannerBranch.jsx", "utf8");
const taskPlannerItem = await readFile("website/src/features/toolboxes/modules/TaskPlannerItem.jsx", "utf8");
const taskPlannerItemPanels = await readFile("website/src/features/toolboxes/modules/TaskPlannerItemPanels.jsx", "utf8");
const taskPlannerReorder = await readFile("website/src/features/toolboxes/modules/useTaskPlannerReorder.js", "utf8");
const taskPlannerTopbar = await readFile("website/src/features/toolboxes/modules/TaskPlannerTopbar.jsx", "utf8");
const taskPlannerSettings = await readFile("website/src/features/toolboxes/modules/TaskPlannerSettings.jsx", "utf8");
- const taskPlannerBundle = `${taskPlannerBranch}\n${taskPlannerItem}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
+ const taskPlannerBundle = `${taskPlannerItems}\n${taskPlannerBranch}\n${taskPlannerItem}\n${taskPlannerItemPanels}\n${taskPlannerReorder}\n${taskPlannerTopbar}\n${taskPlannerSettings}`;
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
const goalTreeModule = await readFile("website/src/features/toolboxes/modules/GoalTreeModule.jsx", "utf8");
const goalTreeEditor = await readFile("website/src/features/toolboxes/modules/GoalTreeEditor.jsx", "utf8");
@@ -277,6 +279,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
["CalendarModule.jsx", calendarModule],
["modules/index.jsx", moduleRegistry],
["GoalTreeParts.jsx", goalTreeParts],
+ ["TimerParts.jsx", timerParts],
+ ["TaskPlannerItems.jsx", taskPlannerItems],
["RandomizerModule.jsx", randomizerModule]
].forEach(([fileName, fileSource]) => {
assert.ok(countLines(fileSource) <= 220, `${fileName} must remain a short orchestrator/barrel`);
diff --git a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx
index 918acb2..0c741cd 100644
--- a/website/src/features/toolboxes/modules/RngSimulatorModule.jsx
+++ b/website/src/features/toolboxes/modules/RngSimulatorModule.jsx
@@ -146,8 +146,7 @@ function getToneFromDelta(value) {
}
export function RngSimulatorModule({ toolboxId, moduleId, context, editing = false, setEditing = () => {}, textExchangeOpen }) {
- const rawData = context.getModuleData(toolboxId, moduleId, {});
- const data = useMemo(() => context.normalizeRngSimulatorData(rawData), [context, rawData]);
+ const data = context.normalizeRngSimulatorData(context.getModuleData(toolboxId, moduleId, {}));
const textContent = context.moduleText?.rngSimulator || {};
const tabs = useMemo(() => [
{ ...RNG_TABS[0], label: textContent.trackingTabLabel || RNG_TABS[0].label },
diff --git a/website/src/features/toolboxes/modules/TaskPlannerItems.jsx b/website/src/features/toolboxes/modules/TaskPlannerItems.jsx
new file mode 100644
index 0000000..180d5c7
--- /dev/null
+++ b/website/src/features/toolboxes/modules/TaskPlannerItems.jsx
@@ -0,0 +1,3 @@
+// Rôle : conserve les exports publics historiques des sous-composants task planner.
+export { TaskPlannerBranch } from "./TaskPlannerBranch.jsx";
+export { TaskPlannerItem } from "./TaskPlannerItem.jsx";
diff --git a/website/src/features/toolboxes/modules/TimerModule.jsx b/website/src/features/toolboxes/modules/TimerModule.jsx
index b65b491..fa18b52 100644
--- a/website/src/features/toolboxes/modules/TimerModule.jsx
+++ b/website/src/features/toolboxes/modules/TimerModule.jsx
@@ -31,20 +31,16 @@ import {
} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, textExchangeOpen }) {
- const rawData = context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" });
- const data = useMemo(() => context.normalizeTimerData(rawData), [context, rawData]);
+ 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 countdownDraft = useDraftForm(DEFAULT_COUNTDOWN_FORM);
- const clockActive = Boolean(data.stopwatch.startedAt || data.countdowns.some((countdown) => countdown.autoRefresh));
-
useEffect(() => {
- if (!clockActive) return undefined;
const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50);
return () => window.clearInterval(intervalId);
- }, [clockActive]);
+ }, []);
const stopwatchElapsedMs = data.stopwatch.startedAt
? data.stopwatch.elapsedMs + Math.max(0, nowMs - data.stopwatch.startedAt)
diff --git a/website/src/features/toolboxes/modules/TimerParts.jsx b/website/src/features/toolboxes/modules/TimerParts.jsx
new file mode 100644
index 0000000..1e0b24c
--- /dev/null
+++ b/website/src/features/toolboxes/modules/TimerParts.jsx
@@ -0,0 +1,4 @@
+// Rôle : conserve les exports publics historiques des sous-composants timer.
+export { CountdownControls, CountdownList } from "./TimerCountdown.jsx";
+export { EditableTimerLabel, TimerAlertModeSwitch, TimerValue, TimePartsInput } from "./TimerSharedControls.jsx";
+export { StopwatchControls, StopwatchLapList } from "./TimerStopwatch.jsx";
diff --git a/website/src/features/toolboxes/modules/calendarUtils.js b/website/src/features/toolboxes/modules/calendarUtils.js
index 8f1608c..6e87aa4 100644
--- a/website/src/features/toolboxes/modules/calendarUtils.js
+++ b/website/src/features/toolboxes/modules/calendarUtils.js
@@ -191,6 +191,11 @@ export function getEventDurationMs(event) {
return Number.isFinite(startMinutes) && Number.isFinite(endMinutes) && endMinutes > startMinutes ? (endMinutes - startMinutes) * MINUTE_MS : 0;
}
+export function getEventEndMs(event, startMs) {
+ const durationMs = getEventDurationMs(event);
+ return durationMs > 0 ? startMs + durationMs : startMs;
+}
+
export function getCalendarAlertOffsets(event) {
const rawOffsets = Array.isArray(event?.alertOffsetMinutes) ? event.alertOffsetMinutes : [event?.alertOffsetMinutes];
const offsets = [...new Set(rawOffsets.map(Number).filter((offset) => CALENDAR_ALERT_OFFSETS.includes(offset)))];
diff --git a/website/src/features/toolboxes/modules/useGoalTreeController.js b/website/src/features/toolboxes/modules/useGoalTreeController.js
index 0024ea5..f64b7d2 100644
--- a/website/src/features/toolboxes/modules/useGoalTreeController.js
+++ b/website/src/features/toolboxes/modules/useGoalTreeController.js
@@ -20,8 +20,7 @@ import {
} from "./goalTreeUtils.js";
export function useGoalTreeController({ toolboxId, moduleId, context, editing, setEditing, openEditor }) {
- const rawData = context.getModuleData(toolboxId, moduleId, { nodes: [] });
- const data = useMemo(() => context.normalizeGoalTreeData(rawData), [context, rawData]);
+ const data = context.normalizeGoalTreeData(context.getModuleData(toolboxId, moduleId, { nodes: [] }));
const textContent = context.moduleText?.goalTree || {};
const treeOrientation = data.orientation || "horizontal";
const verticalView = treeOrientation === "vertical";
diff --git a/website/src/features/toolboxes/storage/modules/table.js b/website/src/features/toolboxes/storage/modules/table.js
index fae875b..395733a 100644
--- a/website/src/features/toolboxes/storage/modules/table.js
+++ b/website/src/features/toolboxes/storage/modules/table.js
@@ -1,11 +1,20 @@
// Rôle : normalise et compacte les cellules et libellés de l'outil table.
-import { columnIndexToName } from "../../modules/tableFormulaEngine.js";
-
function clampTableSize(value, fallback, max) {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback;
}
+function columnIndexToName(index) {
+ let value = Math.max(0, Number(index) || 0) + 1;
+ let name = "";
+ while (value > 0) {
+ const remainder = (value - 1) % 26;
+ name = String.fromCharCode(65 + remainder) + name;
+ value = Math.floor((value - 1) / 26);
+ }
+ return name;
+}
+
function tableCellAddress(rowIndex, columnIndex) {
return `${columnIndexToName(columnIndex)}${rowIndex + 1}`;
}
diff --git a/website/src/hooks/useAppData.js b/website/src/hooks/useAppData.js
index 84a4e37..0b66985 100644
--- a/website/src/hooks/useAppData.js
+++ b/website/src/hooks/useAppData.js
@@ -19,10 +19,7 @@ export function useAppData(route) {
setSiteContentError(error.message);
return null;
}),
- fetch("/data/games.json").then((response) => {
- if (!response.ok) throw new Error("Impossible de charger la liste des jeux.");
- return response.json();
- }).catch((error) => {
+ fetch("/data/games.json").then((response) => response.ok ? response.json() : { games: [] }).catch((error) => {
setGamesError(error.message);
return { games: [] };
})
diff --git a/website/src/hooks/useTimerAlerts.js b/website/src/hooks/useTimerAlerts.js
new file mode 100644
index 0000000..899f85b
--- /dev/null
+++ b/website/src/hooks/useTimerAlerts.js
@@ -0,0 +1,63 @@
+// Rôle : surveille globalement les comptes à rebours actifs et déclenche les alertes internes autorisées.
+import { useEffect, useRef } from "react";
+import { getCountdownTargetMs } from "../features/toolboxes/modules/timerUtils.js";
+import { moduleStorageKey } from "../features/toolboxes/storage/toolboxStorage.js";
+
+function getVisibleToolboxIds(route, drawerGameId, links) {
+ const visibleIds = new Set();
+ const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
+ if (toolboxMatch?.[1]) visibleIds.add(toolboxMatch[1]);
+ const drawerToolboxId = drawerGameId ? links[drawerGameId] : "";
+ if (drawerToolboxId) visibleIds.add(drawerToolboxId);
+ return visibleIds;
+}
+
+function getAlertMessage(template, toolbox, countdown) {
+ return template
+ .replaceAll("{label}", countdown.label)
+ .replaceAll("{toolbox}", toolbox.name);
+}
+
+export function useTimerAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, textContent, notify }) {
+ const previousTargetsRef = useRef(new Map());
+ const lastTickRef = useRef(Date.now());
+
+ useEffect(() => {
+ const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links);
+ const alertMessageTemplate = textContent?.alertMessage || "Compte à rebours terminé : {label}";
+
+ function tick() {
+ const nowMs = Date.now();
+ const lastTickMs = lastTickRef.current;
+ const nextTargets = new Map();
+
+ toolboxes.forEach((toolbox) => {
+ toolbox.modules.forEach((module) => {
+ if (module.type !== "timer") return;
+
+ const data = normalizeTimerData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
+
+ data.countdowns.forEach((countdown) => {
+ if (countdown.alertMode === "off") return;
+ if (countdown.alertMode === "visible" && !visibleToolboxIds.has(toolbox.id)) return;
+
+ const key = `${toolbox.id}:${module.id}:${countdown.id}`;
+ const targetMs = getCountdownTargetMs(countdown, nowMs);
+ const previousTargetMs = previousTargetsRef.current.get(key) || targetMs;
+ nextTargets.set(key, targetMs);
+
+ if (!targetMs || previousTargetMs <= lastTickMs || previousTargetMs > nowMs) return;
+ notify(getAlertMessage(alertMessageTemplate, toolbox, countdown));
+ });
+ });
+ });
+
+ previousTargetsRef.current = nextTargets;
+ lastTickRef.current = nowMs;
+ }
+
+ tick();
+ const intervalId = window.setInterval(tick, 500);
+ return () => window.clearInterval(intervalId);
+ }, [drawerGameId, links, moduleData, normalizeTimerData, notify, route, textContent, toolboxes]);
+}
diff --git a/website/src/router/RouteContent.jsx b/website/src/router/RouteContent.jsx
index 76d39da..f07f595 100644
--- a/website/src/router/RouteContent.jsx
+++ b/website/src/router/RouteContent.jsx
@@ -1,5 +1,5 @@
// Rôle : choisit la page à rendre selon la route active.
-import { lazy, Suspense, useEffect } from "react";
+import { lazy, Suspense } from "react";
import { AboutPage } from "../pages/AboutPage.jsx";
import { HomePage } from "../pages/HomePage.jsx";
import { navigate } from "./hashRouter.js";
@@ -17,13 +17,6 @@ function RouteLoader() {
return ;
}
-function UnknownRouteRedirect() {
- useEffect(() => {
- navigate("/");
- }, []);
- return ;
-}
-
export function RouteContent(props) {
const { route } = props;
if (route === "/") return ;
@@ -34,5 +27,6 @@ export function RouteContent(props) {
if (route.startsWith("/toolbox/")) return }>;
if (route === "/games") return }>;
if (route.startsWith("/games/")) return }>;
- return ;
+ navigate("/");
+ return null;
}