diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index c4b3204..f9982b1 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -24,3 +24,5 @@ {"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}} {"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}} {"id":"int-903a25fde459880bbbadc2764da920a2","kind":"field_change","created_at":"2026-09-10T16:58:56.754421196Z","actor":"Shinuwa","issue_id":"sokko-g-hf6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified confirmed-state queue for all writes; imports use current-state updaters; atomic creation/link/deletion and synchronous transaction abort. Removed obsolete checkpoints. Deterministic concurrency/failure tests and real IndexedDB abort E2E pass; full suite 15/15 and npm run check pass."}} +{"id":"int-16ba759581961e395e133df1c6019895","kind":"field_change","created_at":"2026-09-10T17:21:13.173981957Z","actor":"Shinuwa","issue_id":"sokko-g-ra9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}} +{"id":"int-df984435537f78d51a7a309feda1109c","kind":"field_change","created_at":"2026-09-10T17:21:18.79044889Z","actor":"Shinuwa","issue_id":"sokko-g-ckq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}} diff --git a/README.md b/README.md index dfdaa27..f2cd7ea 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ Le build est généré dans `website/dist`. ## Configuration -Le serveur lit automatiquement un fichier `.env` à la racine du projet. +Le serveur et Vite lisent automatiquement un fichier `.env` facultatif à la racine +du projet, avec le parseur natif de Node. Les variables déjà définies dans le +processus sont prioritaires, y compris lorsqu'elles sont vides. ```bash cp .env.example .env diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index b5d64f7..86524e9 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -52,6 +52,12 @@ sur sa clé et ne réécrivent pas les images des autres outils. Cette coordination est locale à l'instance React ; elle ne synchronise pas l'édition entre plusieurs onglets. +Les estimations de quota demandées après sauvegarde sont regroupées sur une +fenêtre de 250 ms, avec une seule estimation active. Une écriture intervenant +pendant le calcul déclenche une nouvelle estimation. Les sauvegardes ne sont +pas retardées ; en cas d'estimation indisponible, le dernier quota connu est +conservé. + Les toolboxes sont stockées dans `kv/toolboxes`. ```json diff --git a/scripts/load-project-env.mjs b/scripts/load-project-env.mjs new file mode 100644 index 0000000..e6e2d12 --- /dev/null +++ b/scripts/load-project-env.mjs @@ -0,0 +1,10 @@ +// Rôle : charge le fichier .env facultatif avec le parseur natif de Node. +import { loadEnvFile } from "node:process"; + +export function loadProjectEnv(file = ".env") { + try { + loadEnvFile(file); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } +} diff --git a/server.mjs b/server.mjs index 4ce822f..001525b 100644 --- a/server.mjs +++ b/server.mjs @@ -1,5 +1,6 @@ // Rôle : sert l'application Vite construite ou les sources publiques en local. -import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { loadProjectEnv } from "./scripts/load-project-env.mjs"; import { createServer } from "node:http"; import { extname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,24 +11,7 @@ const distRoot = join(websiteRoot, "dist"); const root = existsSync(distRoot) ? distRoot : websiteRoot; const publicRoot = existsSync(distRoot) ? distRoot : join(websiteRoot, "public"); -function loadEnvFile(file = ".env") { - if (!existsSync(file)) return; - - const lines = readFileSync(file, "utf8").split(/\r?\n/); - lines.forEach((line) => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) return; - - const separatorIndex = trimmed.indexOf("="); - if (separatorIndex < 1) return; - - const key = trimmed.slice(0, separatorIndex).trim(); - const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, ""); - if (!process.env[key]) process.env[key] = value; - }); -} - -loadEnvFile(); +loadProjectEnv(); const port = Number(process.env.PORT || 5173); diff --git a/tests/fixtures/project-env.txt b/tests/fixtures/project-env.txt new file mode 100644 index 0000000..f1e7da9 --- /dev/null +++ b/tests/fixtures/project-env.txt @@ -0,0 +1,4 @@ +# Rôle : fournit des variables non sensibles pour tester le chargement .env. +SOKKOG_ENV_FIXTURE_PORT=4321 +SOKKOG_ENV_FIXTURE_QUOTED="value # inside" +SOKKOG_ENV_FIXTURE_COMMENT=value # outside diff --git a/tests/project-env.test.mjs b/tests/project-env.test.mjs new file mode 100644 index 0000000..3fef838 --- /dev/null +++ b/tests/project-env.test.mjs @@ -0,0 +1,20 @@ +// Rôle : vérifie le parsing .env, la priorité du processus et les fichiers facultatifs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { loadProjectEnv } from "../scripts/load-project-env.mjs"; + +for (const existing of ["9999", ""]) { + test(`project env preserves an existing ${existing ? "nonempty" : "empty"} value`, (t) => { + const keys = ["SOKKOG_ENV_FIXTURE_PORT", "SOKKOG_ENV_FIXTURE_QUOTED", "SOKKOG_ENV_FIXTURE_COMMENT"]; + const previous = keys.map((key) => process.env[key]); + t.after(() => keys.forEach((key, index) => { + if (previous[index] === undefined) delete process.env[key]; + else process.env[key] = previous[index]; + })); + keys.forEach((key) => { delete process.env[key]; }); + process.env.SOKKOG_ENV_FIXTURE_PORT = existing; + loadProjectEnv("tests/fixtures/project-env.txt"); + loadProjectEnv("tests/fixtures/missing-env-file"); + assert.deepEqual(keys.map((key) => process.env[key]), [existing, "value # inside", "value"]); + }); +} diff --git a/tests/quota-refresh.test.mjs b/tests/quota-refresh.test.mjs new file mode 100644 index 0000000..80c085e --- /dev/null +++ b/tests/quota-refresh.test.mjs @@ -0,0 +1,90 @@ +// Rôle : teste les rafales d'estimations, la concurrence et le nettoyage du quota. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createQuotaRefresh } from "../website/src/features/toolboxes/storage/quotaRefresh.js"; + +function setup(estimate) { + const timers = new Map(); + const results = []; + let id = 0; + const refresh = createQuotaRefresh({ + estimate, + onResult: (result) => results.push(result), + schedule: (callback) => { timers.set(++id, callback); return id; }, + cancel: (key) => timers.delete(key) + }); + function tick() { + assert.equal(timers.size, 1); + const [key, callback] = timers.entries().next().value; + timers.delete(key); + return callback(); + } + return { ...refresh, timers, results, tick }; +} + +test("one hundred refresh requests trigger one estimate", async () => { + let calls = 0; + const refresh = setup(async () => { calls++; return { usage: 12 }; }); + for (let i = 0; i < 100; i++) refresh.request(); + await refresh.tick(); + assert.equal(calls, 1); + assert.deepEqual(refresh.results, [{ usage: 12 }]); + assert.equal(refresh.timers.size, 0); +}); + +test("writes during estimation schedule one follow-up and discard the stale result", async () => { + let finish; + let calls = 0; + const refresh = setup(() => { + calls++; + return new Promise((resolve) => { finish = resolve; }); + }); + refresh.request(); + const first = refresh.tick(); + for (let i = 0; i < 100; i++) refresh.request(); + assert.equal(calls, 1); + assert.equal(refresh.timers.size, 0); + finish({ usage: 1 }); + await first; + assert.deepEqual(refresh.results, []); + const second = refresh.tick(); + finish({ usage: 2 }); + await second; + assert.equal(calls, 2); + assert.deepEqual(refresh.results, [{ usage: 2 }]); +}); + +test("dispose cancels scheduled work and ignores in-flight results", async () => { + let finish; + const refresh = setup(() => new Promise((resolve) => { finish = resolve; })); + refresh.request(); + refresh.dispose(); + refresh.request(); + assert.equal(refresh.timers.size, 0); + const active = setup(() => new Promise((resolve) => { finish = resolve; })); + active.request(); + const task = active.tick(); + active.dispose(); + finish({ usage: 5 }); + await task; + assert.deepEqual(active.results, []); + assert.equal(active.timers.size, 0); +}); + +test("a failed estimate preserves the last result and allows recovery", async () => { + let fail = false; + const refresh = setup(async () => { + if (fail) throw new Error("unavailable"); + return { usage: 3 }; + }); + refresh.request(); + await refresh.tick(); + fail = true; + refresh.request(); + await refresh.tick(); + assert.deepEqual(refresh.results, [{ usage: 3 }]); + fail = false; + refresh.request(); + await refresh.tick(); + assert.equal(refresh.results.length, 2); +}); diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs index 346af9f..f7bafbb 100644 --- a/tests/static-app.test.mjs +++ b/tests/static-app.test.mjs @@ -113,9 +113,9 @@ test("server and vite support local env configuration", async () => { const viteConfig = await readFile("vite.config.js", "utf8"); const envExample = await readFile(".env.example", "utf8"); - assert.match(server, /function loadEnvFile/); + assert.match(server, /load-project-env\.mjs/); assert.match(server, /process\.env\.PORT/); - assert.match(viteConfig, /function loadEnvFile/); + assert.match(viteConfig, /load-project-env\.mjs/); assert.match(viteConfig, /process\.env\.PORT/); assert.match(envExample, /PORT=5173/); }); diff --git a/vite.config.js b/vite.config.js index c850759..74a61c9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,24 +1,9 @@ +// Rôle : configure Vite et charge les variables locales communes au serveur. import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import { existsSync, readFileSync } from "node:fs"; +import { loadProjectEnv } from "./scripts/load-project-env.mjs"; -function loadEnvFile(file = ".env") { - if (!existsSync(file)) return; - - readFileSync(file, "utf8").split(/\r?\n/).forEach((line) => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) return; - - const separatorIndex = trimmed.indexOf("="); - if (separatorIndex < 1) return; - - const key = trimmed.slice(0, separatorIndex).trim(); - const value = trimmed.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, ""); - if (!process.env[key]) process.env[key] = value; - }); -} - -loadEnvFile(); +loadProjectEnv(); export default defineConfig({ root: "website", diff --git a/website/src/components/ImageViewer.jsx b/website/src/components/ImageViewer.jsx index 30167d9..5b33b84 100644 --- a/website/src/components/ImageViewer.jsx +++ b/website/src/components/ImageViewer.jsx @@ -3,12 +3,10 @@ import { useEffect, useRef, useState } from "react"; import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx"; import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx"; import { lockBodyScroll } from "../utils/bodyScrollLock.js"; +import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js"; import { Icon } from "./Icon.jsx"; import { Tooltip } from "./Tooltip.jsx"; -const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; -const DRAWING_WIDTHS = [2, 4, 8, 12]; - function clampPercent(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return 0; diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index 008e381..774b2b1 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -1,4 +1,5 @@ // Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte. +import { toggleListValue } from "../../../utils/collectionUtils.js"; import { getGroupedEntries } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { CombosEditor } from "./CombosEditor.jsx"; @@ -40,9 +41,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing, textExchan } function toggleCategoryCollapsed(category) { - const collapsedCategories = data.collapsedCategories.includes(category) - ? data.collapsedCategories.filter((item) => item !== category) - : [...data.collapsedCategories, category]; + const collapsedCategories = toggleListValue(data.collapsedCategories, category); save({ ...data, collapsedCategories }); } diff --git a/website/src/features/toolboxes/modules/LinksModule.jsx b/website/src/features/toolboxes/modules/LinksModule.jsx index 7a69ebe..edffa7b 100644 --- a/website/src/features/toolboxes/modules/LinksModule.jsx +++ b/website/src/features/toolboxes/modules/LinksModule.jsx @@ -1,4 +1,5 @@ // Rôle : fournit l'outil liens avec ajout manuel et échange texte. +import { toggleListValue } from "../../../utils/collectionUtils.js"; import { useState } from "react"; import { CategoryAutocompleteInput } from "../../../components/CategoryAutocompleteInput.jsx"; import { Icon } from "../../../components/Icon.jsx"; @@ -143,9 +144,7 @@ export function LinksModule({ toolboxId, moduleId, context, editing, textExchang } function toggleCategoryCollapsed(category) { - const collapsedCategories = data.collapsedCategories.includes(category) - ? data.collapsedCategories.filter((item) => item !== category) - : [...data.collapsedCategories, category]; + const collapsedCategories = toggleListValue(data.collapsedCategories, category); save({ ...data, collapsedCategories }); } diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index 8016f6e..6444499 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -1,4 +1,5 @@ // Rôle : fournit l'outil task planner avec tâches récurrentes, parents, pré requis et échange texte. +import { toggleListValue } from "../../../utils/collectionUtils.js"; import { useEffect, useMemo, useRef, useState } from "react"; import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { TaskPlannerBranch } from "./TaskPlannerBranch.jsx"; @@ -131,9 +132,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing, textE } function toggleCategoryCollapsed(category) { - const collapsedCategories = data.collapsedCategories.includes(category) - ? data.collapsedCategories.filter((item) => item !== category) - : [...data.collapsedCategories, category]; + const collapsedCategories = toggleListValue(data.collapsedCategories, category); save({ ...data, collapsedCategories }); } diff --git a/website/src/features/toolboxes/modules/notepadUtils.js b/website/src/features/toolboxes/modules/notepadUtils.js index 3cf103f..6143afb 100644 --- a/website/src/features/toolboxes/modules/notepadUtils.js +++ b/website/src/features/toolboxes/modules/notepadUtils.js @@ -1,7 +1,6 @@ // Rôle : regroupe les constantes et helpers DOM du Bloc Note riche. -export const TEXT_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; +export { DRAWING_COLORS as TEXT_COLORS, DRAWING_WIDTHS } from "../../../utils/drawingOptions.js"; export const HIGHLIGHT_COLORS = ["#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#202745"]; -export const DRAWING_WIDTHS = [2, 4, 8, 12]; export function getPlainText(element) { return (element?.innerText || "").replace(/\n{3,}/g, "\n\n").trim(); diff --git a/website/src/features/toolboxes/storage/quotaRefresh.js b/website/src/features/toolboxes/storage/quotaRefresh.js new file mode 100644 index 0000000..2808043 --- /dev/null +++ b/website/src/features/toolboxes/storage/quotaRefresh.js @@ -0,0 +1,38 @@ +// Rôle : regroupe les estimations de quota et ignore les résultats devenus obsolètes. +export function createQuotaRefresh({ estimate, onResult, delay = 250, schedule = setTimeout, cancel = clearTimeout }) { + let timer = null; + let running = false; + let dirty = false; + let disposed = false; + + function request() { + if (disposed) return; + dirty = true; + if (!running && timer === null) timer = schedule(run, delay); + } + + async function run() { + timer = null; + if (disposed) return; + dirty = false; + running = true; + try { + const result = await estimate(); + if (!disposed && !dirty) onResult(result); + } catch { + // Une estimation indisponible ne remet pas à zéro le dernier quota connu. + } finally { + running = false; + if (!disposed && dirty) request(); + } + } + + return { + request, + dispose() { + disposed = true; + if (timer !== null) cancel(timer); + timer = null; + } + }; +} diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index 05594a5..f723188 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -1,5 +1,5 @@ // Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB. -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { getAllModuleData as dbGetAllModuleData, getLinks as dbGetLinks, @@ -22,6 +22,7 @@ import { } from "./toolboxStorage.js"; import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js"; import { createPersistenceQueue } from "./persistenceQueue.js"; +import { createQuotaRefresh } from "./quotaRefresh.js"; function normalizeToolboxes(toolboxes) { return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); @@ -32,20 +33,29 @@ export function useIndexedToolboxes(onError) { const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" }); const [queue] = useState(() => createPersistenceQueue(state, setState)); const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); + const quotaRefreshRef = useRef(null); const reportStorageError = useCallback((error) => { onError(error?.message || "Erreur de stockage IndexedDB."); }, [onError]); - const refreshQuota = useCallback(async () => { - try { - const estimate = await getStorageEstimate(); - const used = estimate.usage || 0; - const limit = estimate.quota || 0; - setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 }); - } catch { - setStorageUsage({ used: 0, limit: 0, ratio: 0 }); - } + useEffect(() => { + const refresh = createQuotaRefresh({ + estimate: getStorageEstimate, + onResult: (estimate) => { + const used = estimate.usage || 0; + const limit = estimate.quota || 0; + setStorageUsage((current) => current.used === used && current.limit === limit + ? current + : { used, limit, ratio: limit ? Math.min(1, used / limit) : 0 }); + } + }); + quotaRefreshRef.current = refresh; + return () => { + refresh.dispose(); + quotaRefreshRef.current = null; + }; }, []); + const refreshQuota = useCallback(() => quotaRefreshRef.current?.request(), []); useEffect(() => { let cancelled = false; diff --git a/website/src/utils/collectionUtils.js b/website/src/utils/collectionUtils.js new file mode 100644 index 0000000..b536afc --- /dev/null +++ b/website/src/utils/collectionUtils.js @@ -0,0 +1,4 @@ +// Rôle : fournit les modifications immuables communes aux listes de valeurs. +export function toggleListValue(values, value) { + return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]; +} diff --git a/website/src/utils/drawingOptions.js b/website/src/utils/drawingOptions.js new file mode 100644 index 0000000..31961b6 --- /dev/null +++ b/website/src/utils/drawingOptions.js @@ -0,0 +1,3 @@ +// Rôle : partage les couleurs et épaisseurs des outils de dessin. +export const DRAWING_COLORS = ["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef"]; +export const DRAWING_WIDTHS = [2, 4, 8, 12];