From f61b9025ebcbfbafe730e997867061dd235c7ff0 Mon Sep 17 00:00:00 2001 From: "Adrien W." Date: Sun, 6 Sep 2026 13:50:58 +0200 Subject: [PATCH] First working prototype --- .dockerignore | 11 + .env.example | 11 + .gitignore | 3 + Dockerfile | 28 + README.md | 129 +- docker-compose.yml | 31 + docker/Caddyfile | 46 + index.html | 10 +- jsconfig.json | 27 +- package-lock.json | 1847 ++++++++++++++++++++++------- package.json | 10 +- public/icon.svg | 13 + public/icons.svg | 24 - src/App.svelte | 139 ++- src/app.css | 454 ++++--- src/assets/hero.png | Bin 13057 -> 0 bytes src/assets/svelte.svg | 1 - src/assets/vite.svg | 1 - src/components/EntrySheet.svelte | 118 ++ src/components/LogList.svelte | 16 - src/components/Sheet.svelte | 90 ++ src/components/SmartField.svelte | 149 +++ src/components/TimeField.svelte | 134 +++ src/components/TimeLogForm.svelte | 86 -- src/components/Timeline.svelte | 124 ++ src/lib/Counter.svelte | 5 - src/lib/boundary.test.js | 65 + src/lib/day.svelte.js | 306 +++++ src/lib/db.js | 251 +++- src/lib/predict.js | 319 +++++ src/lib/predict.test.js | 218 ++++ src/lib/resume.js | 105 ++ src/lib/resume.test.js | 107 ++ src/lib/router.svelte.js | 29 + src/lib/seed.js | 99 ++ src/lib/settings.svelte.js | 74 ++ src/lib/time.js | 80 ++ src/lib/time.test.js | 102 ++ src/lib/updates.svelte.js | 38 + src/routes/Resume.svelte | 148 +++ src/routes/Settings.svelte | 210 ++++ src/routes/Today.svelte | 293 +++++ src/vite-env.d.ts | 6 + vite.config.js | 66 +- 44 files changed, 5118 insertions(+), 905 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/Caddyfile create mode 100644 public/icon.svg delete mode 100644 public/icons.svg delete mode 100644 src/assets/hero.png delete mode 100644 src/assets/svelte.svg delete mode 100644 src/assets/vite.svg create mode 100644 src/components/EntrySheet.svelte delete mode 100644 src/components/LogList.svelte create mode 100644 src/components/Sheet.svelte create mode 100644 src/components/SmartField.svelte create mode 100644 src/components/TimeField.svelte delete mode 100644 src/components/TimeLogForm.svelte create mode 100644 src/components/Timeline.svelte delete mode 100644 src/lib/Counter.svelte create mode 100644 src/lib/boundary.test.js create mode 100644 src/lib/day.svelte.js create mode 100644 src/lib/predict.js create mode 100644 src/lib/predict.test.js create mode 100644 src/lib/resume.js create mode 100644 src/lib/resume.test.js create mode 100644 src/lib/router.svelte.js create mode 100644 src/lib/seed.js create mode 100644 src/lib/settings.svelte.js create mode 100644 src/lib/time.js create mode 100644 src/lib/time.test.js create mode 100644 src/lib/updates.svelte.js create mode 100644 src/routes/Resume.svelte create mode 100644 src/routes/Settings.svelte create mode 100644 src/routes/Today.svelte create mode 100644 src/vite-env.d.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5adbcc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.idea +.junie +.env +*.log +Dockerfile +docker-compose.yml +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23efff3 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Copy to .env and edit before `docker compose up -d`. + +# The public domain this instance answers on. Caddy obtains and renews a +# Let's Encrypt certificate for it automatically. +# +# The domain must already resolve to this server, and ports 80 and 443 must be +# reachable from the internet — port 80 is what the ACME challenge uses. +SITE_ADDRESS=timelog.example.com + +# Notified by Let's Encrypt before a certificate expires. Optional but advised. +ACME_EMAIL=you@example.com diff --git a/.gitignore b/.gitignore index a547bf3..9d60660 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ lerna-debug.log* node_modules dist dist-ssr + +# Deployment config: copy .env.example instead. +.env *.local # Editor directories and files diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33fd7d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# ---- build ------------------------------------------------------------------ +# Debian rather than Alpine on purpose. The PWA icon generator pulls in sharp, +# whose native dependencies differ between glibc and musl, so a lockfile +# committed from a glibc machine can never satisfy `npm ci` on Alpine. This +# stage is discarded, so its size does not reach the published image. +FROM node:24-slim AS build + +WORKDIR /app + +# Dependencies first, so a source-only change does not re-install them. +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ---- serve ------------------------------------------------------------------ +# Caddy rather than nginx: the app needs a secure context for its service worker, +# and Caddy obtains and renews the certificate on its own. +FROM caddy:2-alpine + +COPY docker/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv + +EXPOSE 80 443 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index 54a2631..e20683e 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,96 @@ -# Svelte + Vite +# TimeLog -This template should help get you started developing with Svelte in Vite. +Offline-first PWA for tracking hours on site. The day starts and ends on its own, each +task begins where the previous one ended, and the app's main output is a concise +summary of the last working day, ready to copy into a paper logbook. -## Recommended IDE Setup +Built for use outdoors: high contrast, large targets, usable with gloves on. All data +stays on the device (IndexedDB); there is no backend and nothing is uploaded. -[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). +## Development -## Need an official Svelte framework? - -Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. - -## Technical considerations - -**Why use this over SvelteKit?** - -- It brings its own routing solution which might not be preferable for some users. -- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. - -This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. - -Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. - -**Why include `.vscode/extensions.json`?** - -Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. - -**Why enable `checkJs` in the JS template?** - -It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. - -**Why is HMR not preserving my local component state?** - -HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). - -If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. - -```js -// store.js -// An extremely simple external store -import { writable } from 'svelte/store' -export default writable(0) +```sh +npm install +npm run dev # http://localhost:5173 +npm test # unit tests for the time, prediction, resume and boundary logic +npm run check # svelte-check +npm run build # production build into dist/ ``` + +In dev the console exposes `timelog.seed()` to generate 20 days of realistic history +(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it. + +## Deployment + +The stack is a single container: a multi-stage build compiles the app and Caddy serves +the static output, obtaining and renewing a Let's Encrypt certificate on its own. + +```sh +cp .env.example .env +$EDITOR .env # set SITE_ADDRESS and ACME_EMAIL +docker compose up -d --build +``` + +Requirements: + +- `SITE_ADDRESS` must already resolve to the server. +- Ports **80 and 443** must be reachable from the internet. Port 80 is not optional — + it is what the ACME challenge uses to prove domain ownership. + +Certificates are stored in the `caddy_data` volume. Keep it: without it Caddy re-issues +on every recreate and will hit the Let's Encrypt rate limit after a handful of restarts. + +To update, pull and rebuild: + +```sh +git pull && docker compose up -d --build +``` + +Running clients do not reload on their own. Each app checks hourly for a new build and +offers an **"Installer"** banner — but only while no working day is in progress, so an +update can never interrupt someone mid-day. + +### HTTPS is not optional + +Browsers only register a service worker in a secure context. Served over plain `http://` +to an IP or hostname, the app still loads but has **no offline support, cannot be +installed, and never updates** — which removes the point of the design. `localhost` is +the only exception, which is why `npm run dev` works without certificates. + +### Behind an existing reverse proxy + +If something upstream already terminates TLS, set `SITE_ADDRESS=:80`, drop the `443` +port mappings, and let the proxy forward to port 80. `ACME_EMAIL` is then unused but +must still be non-empty — Caddy will not parse a blank value. + +The app must be served from the root of its domain. `start_url` and the service worker +scope are both `/`, so hosting it under a subpath will not work without changing the +manifest and Vite's `base`. + +## Architecture + +Two decisions shape everything else. + +**Raw timestamps are stored; rounding is applied only at render.** Changing the rounding +setting therefore corrects past days too. Adjacent entries share one stored instant — +entry *N*'s end *is* entry *N+1*'s start — so rounding can never open a gap between +them. Times a worker sets by hand are snapped to the grid on write, so a chosen value +reads back exactly as chosen. + +**Suggestions are derived from history, not from counter tables.** There is nothing to +keep in sync; the model is rebuilt in memory from the entries. The tradeoff is that a +short retention window degrades predictions, which is why retention defaults to two +years. + +``` +src/lib/ + time.js local dates, rounding, formatting (pure) + predict.js the suggestion engine (pure) + resume.js logbook text builder (pure) + db.js Dexie schema and the chain invariant + day.svelte.js day lifecycle state machine +``` + +The four pure modules hold the logic worth testing and carry the unit tests. `db.js` +concentrates every write that touches a boundary into `closeAndStart` and +`setEntryStart`, so the timeline cannot drift out of order from anywhere else. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f63b7bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + timelog: + build: + context: . + image: timelog:latest + container_name: timelog + restart: unless-stopped + + environment: + # Your domain, e.g. timelog.example.com — Caddy fetches a certificate for it. + # Set to :80 instead if you terminate TLS on an upstream reverse proxy. + SITE_ADDRESS: ${SITE_ADDRESS:?set SITE_ADDRESS in .env} + # Used by Let's Encrypt for expiry warnings. Required: Caddy fails to parse + # its config with an empty value, so this is checked here rather than + # crash-looping the container. + ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env} + + ports: + - "80:80" # required: the ACME HTTP challenge and the HTTPS redirect + - "443:443" + - "443:443/udp" # HTTP/3 + + volumes: + # Certificates live here. Without this volume Caddy re-issues on every + # recreate and will hit the Let's Encrypt rate limit within a few restarts. + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..575845b --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,46 @@ +{ + email {$ACME_EMAIL} + admin off +} + +# Internal only, for the container healthcheck. Never published in compose. +:8080 { + respond /healthz "ok" 200 + respond "not found" 404 +} + +{$SITE_ADDRESS} { + root * /srv + encode zstd gzip + + # The service worker and the HTML shell must be revalidated on every request. + # If a browser is allowed to serve sw.js from its own cache, a new build is + # never discovered and the in-app update prompt can never fire — the app would + # silently freeze on whatever version it first installed. + @volatile path / /index.html /sw.js /manifest.webmanifest + header @volatile Cache-Control "no-cache, must-revalidate" + + # Content-hashed filenames; the name changes whenever the bytes do. + @hashed path /assets/* /workbox-*.js + header @hashed Cache-Control "public, max-age=31536000, immutable" + + # Icons keep stable names across builds, so cap them at a day rather than + # forever, otherwise a replaced icon never surfaces. + @icons path *.png *.svg *.ico + header @icons Cache-Control "public, max-age=86400" + + # Some clients reject a manifest that is not served with this exact type. + @manifest path /manifest.webmanifest + header @manifest Content-Type "application/manifest+json" + + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + -Server + } + + # The app routes on the hash, so this only catches someone typing a stray path. + try_files {path} /index.html + file_server +} diff --git a/index.html b/index.html index ad9bfde..bd33910 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,14 @@ - + - - app + + + + + + TimeLog — suivi des heures
diff --git a/jsconfig.json b/jsconfig.json index c7a0b10..6655db9 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -3,31 +3,26 @@ "moduleResolution": "bundler", "target": "ESNext", "module": "ESNext", - /** - * svelte-preprocess cannot figure out whether you have - * a value or a type, so tell TypeScript to enforce using - * `import type` instead of `import` for Types. - */ "verbatimModuleSyntax": true, "isolatedModules": true, "resolveJsonModule": true, - /** - * To have warnings / errors of the Svelte compiler at the - * correct position, enable source maps by default. - */ "sourceMap": true, "esModuleInterop": true, "types": ["vite/client"], "skipLibCheck": true, + "noEmit": true, /** - * Typecheck JS in `.svelte` and `.js` files by default. - * Disable this if you'd like to use dynamic types. + * Typecheck JS in `.svelte` and `.js` files. The data layer carries JSDoc + * typedefs (see src/lib/db.js), so schema mistakes are caught here. + * + * `strict` stays off deliberately: the app code is plain untyped JS, and + * turning it on demands ~200 annotations on component props and callbacks + * for very little bug-catching return. Revisit if this grows. */ - "checkJs": true + "checkJs": true, + "strict": false }, - /** - * Use global.d.ts instead of compilerOptions.types - * to avoid limiting type declarations. - */ + /** Tests use vitest globals and fixtures; they are covered by `npm test`. */ + "exclude": ["src/**/*.test.js"], "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] } diff --git a/package-lock.json b/package-lock.json index f887a65..f23c884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,12 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@vite-pwa/assets-generator": "^1.0.2", "svelte": "^5.56.4", + "svelte-check": "^4.7.6", "vite": "^8.1.1", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "vitest": "^5.0.0" } }, "node_modules/@apideck/better-ajv-errors": { @@ -65,6 +68,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -90,15 +94,25 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -137,6 +151,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", @@ -159,6 +183,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", @@ -177,6 +211,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", @@ -383,13 +427,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -983,16 +1027,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", - "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1221,9 +1265,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", - "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "dev": true, "license": "MIT", "dependencies": { @@ -1286,9 +1330,9 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", - "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", "dev": true, "license": "MIT", "dependencies": { @@ -1503,6 +1547,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -1544,18 +1598,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1563,9 +1617,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -1576,41 +1630,402 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "node_modules/@canvas/image-data": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz", + "integrity": "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } + "license": "MIT" }, "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { - "tslib": "^2.4.0" + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@isaacs/cliui": { @@ -1667,9 +2082,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1684,42 +2099,67 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", - "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^2.0.0-alpha.3", - "@emnapi/runtime": "^2.0.0-alpha.3" + "node": "^22.20 || ^24.12 || >=25" } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@quansync/fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", + "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", "cpu": [ "arm64" ], @@ -1734,9 +2174,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", "cpu": [ "arm64" ], @@ -1751,9 +2191,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", "cpu": [ "x64" ], @@ -1768,9 +2208,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", "cpu": [ "x64" ], @@ -1785,9 +2225,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", "cpu": [ "arm" ], @@ -1802,9 +2242,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", "cpu": [ "arm64" ], @@ -1819,9 +2259,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", "cpu": [ "arm64" ], @@ -1836,9 +2276,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", "cpu": [ "ppc64" ], @@ -1853,9 +2293,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", "cpu": [ "s390x" ], @@ -1870,9 +2310,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", "cpu": [ "x64" ], @@ -1887,9 +2327,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", "cpu": [ "x64" ], @@ -1904,9 +2344,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", "cpu": [ "arm64" ], @@ -1920,63 +2360,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", "cpu": [ "arm64" ], @@ -1991,9 +2378,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", "cpu": [ "x64" ], @@ -2088,6 +2475,16 @@ } } }, + "node_modules/@rollup/plugin-replace/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/@rollup/plugin-terser": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", @@ -2134,10 +2531,17 @@ } } }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", - "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", "cpu": [ "arm" ], @@ -2149,9 +2553,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", - "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", "cpu": [ "arm64" ], @@ -2163,9 +2567,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", - "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", "cpu": [ "arm64" ], @@ -2177,9 +2581,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", - "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", "cpu": [ "x64" ], @@ -2191,9 +2595,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", - "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", "cpu": [ "arm64" ], @@ -2205,9 +2609,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", - "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", "cpu": [ "x64" ], @@ -2219,9 +2623,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", - "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", "cpu": [ "arm" ], @@ -2233,9 +2637,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", - "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", "cpu": [ "arm" ], @@ -2247,9 +2651,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", - "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", "cpu": [ "arm64" ], @@ -2261,9 +2665,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", - "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", "cpu": [ "arm64" ], @@ -2275,9 +2679,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", - "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", "cpu": [ "loong64" ], @@ -2289,9 +2693,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", - "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", "cpu": [ "loong64" ], @@ -2303,9 +2707,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", - "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", "cpu": [ "ppc64" ], @@ -2317,9 +2721,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", - "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", "cpu": [ "ppc64" ], @@ -2331,9 +2735,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", - "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", "cpu": [ "riscv64" ], @@ -2345,9 +2749,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", - "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", "cpu": [ "riscv64" ], @@ -2359,9 +2763,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", - "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", "cpu": [ "s390x" ], @@ -2373,9 +2777,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", - "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", "cpu": [ "x64" ], @@ -2387,9 +2791,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", - "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", "cpu": [ "x64" ], @@ -2401,9 +2805,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", - "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", "cpu": [ "x64" ], @@ -2415,9 +2819,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", - "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", "cpu": [ "arm64" ], @@ -2429,9 +2833,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", - "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", "cpu": [ "arm64" ], @@ -2443,9 +2847,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", - "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", "cpu": [ "ia32" ], @@ -2457,9 +2861,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", - "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", "cpu": [ "x64" ], @@ -2471,9 +2875,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", - "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", "cpu": [ "x64" ], @@ -2485,24 +2889,34 @@ ] }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", - "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", "dev": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", + "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, @@ -2530,17 +2944,34 @@ "node": ">=12" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "node_modules/@trickfilm400/rollup-plugin-off-main-thread/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2562,12 +2993,76 @@ "dev": true, "license": "MIT" }, + "node_modules/@vite-pwa/assets-generator": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vite-pwa/assets-generator/-/assets-generator-1.0.2.tgz", + "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cac": "^6.7.14", + "colorette": "^2.0.20", + "consola": "^3.4.2", + "sharp": "^0.33.5", + "sharp-ico": "^0.1.5", + "unconfig": "^7.3.1" + }, + "bin": { + "pwa-assets-generator": "bin/pwa-assets-generator.mjs" + }, + "engines": { + "node": ">=16.14.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2581,6 +3076,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2641,6 +3137,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2709,6 +3215,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", @@ -2747,9 +3263,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.5", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", - "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2760,9 +3276,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2773,9 +3289,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2792,12 +3308,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -2813,6 +3330,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2864,9 +3391,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2884,6 +3411,32 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2894,6 +3447,58 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -2911,6 +3516,16 @@ "node": ">=4.0.0" } }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2919,13 +3534,16 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" }, "funding": { "type": "opencollective", @@ -3029,6 +3647,35 @@ } } }, + "node_modules/decode-bmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/decode-bmp/-/decode-bmp-0.2.1.tgz", + "integrity": "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/decode-ico": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-ico/-/decode-ico-0.4.1.tgz", + "integrity": "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "decode-bmp": "^0.2.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3075,6 +3722,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3086,16 +3740,16 @@ } }, "node_modules/devalue": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", - "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "dev": true, "license": "MIT" }, "node_modules/dexie": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz", - "integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.5.tgz", + "integrity": "sha512-wWCHdihT3dmlUSuNhn5mMZDWSpG0suxjAni7YjjiZdzabZcKmy3uNZGZ7AeYYXBoLbpSXX35fikPLkPC44Osiw==", "license": "Apache-2.0" }, "node_modules/dunder-proto": { @@ -3130,9 +3784,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.397", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz", - "integrity": "sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "dev": true, "license": "ISC" }, @@ -3244,6 +3898,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3312,9 +3973,9 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", - "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -3330,11 +3991,14 @@ } }, "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } }, "node_modules/esutils": { "version": "2.0.3", @@ -3359,6 +4023,16 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3374,9 +4048,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -3426,9 +4100,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", - "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3786,6 +4460,13 @@ "node": ">= 0.4" } }, + "node_modules/ico-endec": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ico-endec/-/ico-endec-0.1.6.tgz", + "integrity": "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==", + "dev": true, + "license": "MPL-2.0" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -3826,6 +4507,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -4281,6 +4969,16 @@ "node": ">=10" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4640,9 +5338,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", "dev": true, "license": "MIT", "dependencies": { @@ -4685,6 +5383,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4693,9 +5401,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -4712,9 +5420,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -4857,9 +5565,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4880,9 +5588,9 @@ } }, "node_modules/postcss": { - "version": "8.5.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", - "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4900,7 +5608,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4921,6 +5629,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5056,13 +5795,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5072,29 +5811,30 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, "node_modules/rollup": { - "version": "4.62.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", - "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -5106,34 +5846,48 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.3", - "@rollup/rollup-android-arm64": "4.62.3", - "@rollup/rollup-darwin-arm64": "4.62.3", - "@rollup/rollup-darwin-x64": "4.62.3", - "@rollup/rollup-freebsd-arm64": "4.62.3", - "@rollup/rollup-freebsd-x64": "4.62.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", - "@rollup/rollup-linux-arm-musleabihf": "4.62.3", - "@rollup/rollup-linux-arm64-gnu": "4.62.3", - "@rollup/rollup-linux-arm64-musl": "4.62.3", - "@rollup/rollup-linux-loong64-gnu": "4.62.3", - "@rollup/rollup-linux-loong64-musl": "4.62.3", - "@rollup/rollup-linux-ppc64-gnu": "4.62.3", - "@rollup/rollup-linux-ppc64-musl": "4.62.3", - "@rollup/rollup-linux-riscv64-gnu": "4.62.3", - "@rollup/rollup-linux-riscv64-musl": "4.62.3", - "@rollup/rollup-linux-s390x-gnu": "4.62.3", - "@rollup/rollup-linux-x64-gnu": "4.62.3", - "@rollup/rollup-linux-x64-musl": "4.62.3", - "@rollup/rollup-openbsd-x64": "4.62.3", - "@rollup/rollup-openharmony-arm64": "4.62.3", - "@rollup/rollup-win32-arm64-msvc": "4.62.3", - "@rollup/rollup-win32-ia32-msvc": "4.62.3", - "@rollup/rollup-win32-x64-gnu": "4.62.3", - "@rollup/rollup-win32-x64-msvc": "4.62.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5190,19 +5944,22 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/serialize-javascript": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", - "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", + "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5258,6 +6015,58 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/sharp-ico": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/sharp-ico/-/sharp-ico-0.1.5.tgz", + "integrity": "sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "decode-ico": "*", + "ico-endec": "*", + "sharp": "*" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5357,6 +6166,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5370,6 +6186,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/smob": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", @@ -5421,6 +6247,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5436,25 +6276,25 @@ } }, "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" + "side-channel": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -5562,17 +6402,17 @@ } }, "node_modules/svelte": { - "version": "5.56.8", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", - "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", @@ -5589,6 +6429,41 @@ "node": ">=18" } }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -5619,9 +6494,9 @@ } }, "node_modules/terser": { - "version": "5.49.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", - "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5637,6 +6512,26 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5654,6 +6549,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-data-view": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-data-view/-/to-data-view-1.1.0.tgz", + "integrity": "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5753,6 +6655,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -5772,6 +6689,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unconfig": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz", + "integrity": "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "defu": "^6.1.4", + "jiti": "^2.6.1", + "quansync": "^1.0.0", + "unconfig-core": "7.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -5851,9 +6799,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -5882,16 +6830,17 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -5908,7 +6857,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6010,6 +6959,89 @@ } } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6115,6 +7147,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/workbox-background-sync": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", @@ -6343,9 +7392,9 @@ "license": "ISC" }, "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index 4ed1c23..42a7c18 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,19 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "check": "svelte-check --tsconfig ./jsconfig.json" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@vite-pwa/assets-generator": "^1.0.2", "svelte": "^5.56.4", + "svelte-check": "^4.7.6", "vite": "^8.1.1", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "vitest": "^5.0.0" }, "dependencies": { "dexie": "^4.4.4" diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..cfb713e --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/App.svelte b/src/App.svelte index 5936a51..1446aa4 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,10 +1,135 @@ -
-

Time Tracker

- - -
+{#if !ready} +

Chargement…

+{:else} + {#if needsRefresh.value && !dayState.day} + +
+ Mise à jour disponible + +
+ {/if} + + {#if view === 'settings'} + + {:else if view === 'today'} + + {:else} + + {/if} + + +{/if} + + diff --git a/src/app.css b/src/app.css index 527d4fb..957d395 100644 --- a/src/app.css +++ b/src/app.css @@ -1,296 +1,238 @@ +/* + * Design tokens for outdoor use. + * + * Rules this file follows, deliberately: + * - Primary text is pure black on pure white (21:1). No grey body copy anywhere. + * - Theme is an explicit setting, never `prefers-color-scheme`: that follows the + * OS, and a dark UI is *harder* to read in direct sunlight, not easier. + * - Nothing tappable is under 64px; primary actions are 88px. + */ + :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --bg: #ffffff; + --fg: #000000; + --fg-dim: #333333; /* 12.6:1 — the darkest "secondary" we allow */ + --surface: #f0f0f0; + --line: #000000; + + --accent: #0047b3; /* 8.2:1 against white, and against its own white text */ + --accent-fg: #ffffff; + --accent-soft: #dce7fb; + + --ok: #0f5c2e; + --warn: #8a3d00; + --danger: #a3001b; + --danger-fg: #ffffff; + + --tap: 64px; + --tap-lg: 88px; + --gap: 12px; + --pad: 16px; + --radius: 10px; + --border: 3px; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); + font-family: var(--sans); + font-size: 20px; + line-height: 1.35; + color: var(--fg); background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } +:root[data-theme='nuit'] { + --bg: #000000; + --fg: #ffffff; + --fg-dim: #cccccc; + --surface: #1a1a1a; + --line: #ffffff; - #social .button-icon { - filter: invert(1) brightness(2); - } + --accent: #ffb000; /* amber reads well at night and preserves dark adaptation */ + --accent-fg: #000000; + --accent-soft: #3a2a00; + + --ok: #5ce08a; + --warn: #ffb000; + --danger: #ff5c72; + --danger-fg: #000000; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--fg); } body { - margin: 0; + /* Fill the notch area with the app background in a standalone PWA. */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); + overscroll-behavior-y: contain; +} + +#app { + max-width: 640px; + margin: 0 auto; + min-height: 100svh; + display: flex; + flex-direction: column; } h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +h2, +h3 { + margin: 0; + font-weight: 800; + letter-spacing: -0.01em; } - h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } + font-size: 1.6rem; } h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } + font-size: 1.25rem; +} +h3 { + font-size: 1.05rem; } p { margin: 0; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +/* Times and durations must never reflow as digits change. */ +.tnum { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +.dim { + color: var(--fg-dim); } - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#app { - width: 1126px; - max-width: 100%; - margin: 0 auto; +.center { text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; +/* ------------------------------------------------------------------ controls */ - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +button { + font: inherit; + font-weight: 700; + color: var(--fg); + background: var(--bg); + border: var(--border) solid var(--line); + border-radius: var(--radius); + min-height: var(--tap); + padding: 0 var(--pad); + cursor: pointer; + touch-action: manipulation; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } +button:active { + transform: translateY(2px); } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +button:focus-visible { + outline: 4px solid var(--accent); + outline-offset: 3px; } -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } +button[disabled] { + opacity: 0.4; + cursor: default; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; +.btn-primary { + min-height: var(--tap-lg); + font-size: 1.2rem; + color: var(--accent-fg); + background: var(--accent); + border-color: var(--accent); width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } +} + +.btn-danger { + color: var(--danger-fg); + background: var(--danger); + border-color: var(--danger); +} + +.btn-row { + display: flex; + gap: var(--gap); +} +.btn-row > * { + flex: 1; +} + +input[type='text'], +input[type='time'], +input[type='number'], +select { + font: inherit; + font-weight: 600; + color: var(--fg); + background: var(--bg); + border: var(--border) solid var(--line); + border-radius: var(--radius); + min-height: var(--tap); + padding: 0 12px; + width: 100%; +} + +input:focus-visible, +select:focus-visible { + outline: 4px solid var(--accent); + outline-offset: 2px; +} + +/* -------------------------------------------------------------------- layout */ + +.screen { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--gap); + padding: var(--pad); + padding-bottom: 96px; /* clear of the fixed nav */ +} + +.card { + border: var(--border) solid var(--line); + border-radius: var(--radius); + padding: var(--pad); + background: var(--bg); +} + +.card-accent { + background: var(--accent-soft); +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--gap); +} + +.row-between { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--gap); +} + +hr { + border: 0; + border-top: var(--border) solid var(--line); + margin: 4px 0; } diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/src/assets/svelte.svg b/src/assets/svelte.svg deleted file mode 100644 index c5e0848..0000000 --- a/src/assets/svelte.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/components/EntrySheet.svelte b/src/components/EntrySheet.svelte new file mode 100644 index 0000000..2eefbaf --- /dev/null +++ b/src/components/EntrySheet.svelte @@ -0,0 +1,118 @@ + + + + {#if entry} + onstart?.(entry.id, ts)} + /> + +

+ {#if entry.endTs == null} + En cours depuis {fmtDuration(now - entry.startTs)} + {:else} + Jusqu'à {fmtTime(entry.endTs, rounding)} · {fmtDuration( + entry.endTs - entry.startTs, + )} + {/if} +

+ + {#if !isLunch} + onedit?.(entry.id, { task })} + /> + onedit?.(entry.id, { client })} + /> + {:else} +

+ Le type de déjeuner et le restaurant se règlent sur l'écran de la journée. +

+ {/if} + + +
+ {#if confirmingDelete} +
+ + +
+ {:else if entry.endTs != null} + + {/if} +
+ {/if} +
+ + diff --git a/src/components/LogList.svelte b/src/components/LogList.svelte deleted file mode 100644 index a86bff6..0000000 --- a/src/components/LogList.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -{#if sortedLogs.length > 0} -
    - {#each sortedLogs as log} -
  • {log.startTime} - {log.endTime}: {log.taskName} ({log.clientName})
  • - {/each} -
-{/if} diff --git a/src/components/Sheet.svelte b/src/components/Sheet.svelte new file mode 100644 index 0000000..f16f3f0 --- /dev/null +++ b/src/components/Sheet.svelte @@ -0,0 +1,90 @@ + + + + +{#if open} +
onclose?.()} + onkeydown={() => {}} + >
+ + +{/if} + + diff --git a/src/components/SmartField.svelte b/src/components/SmartField.svelte new file mode 100644 index 0000000..e0569e3 --- /dev/null +++ b/src/components/SmartField.svelte @@ -0,0 +1,149 @@ + + +
+ {label} + +
+ + (open = false)}> +
+ + +
+ + {#if suggestions.length} +
    + {#each suggestions as item (item.value)} +
  • + +
  • + {/each} +
+ {:else} +

{emptyText}

+ {/if} +
+ + diff --git a/src/components/TimeField.svelte b/src/components/TimeField.svelte new file mode 100644 index 0000000..a4f9dd8 --- /dev/null +++ b/src/components/TimeField.svelte @@ -0,0 +1,134 @@ + + +
+ {label} + +
+ + (open = false)}> +

{fmtTime(draft, rounding)}

+ +
+ {#each NUDGES as minutes (minutes)} + + {/each} +
+ + + +
+ + +
+
+ + diff --git a/src/components/TimeLogForm.svelte b/src/components/TimeLogForm.svelte deleted file mode 100644 index 3a1ed16..0000000 --- a/src/components/TimeLogForm.svelte +++ /dev/null @@ -1,86 +0,0 @@ - - -
-
- - -
- - - - {#if mode === 'time'} - - {:else} - - {/if} - - - - -
- - diff --git a/src/components/Timeline.svelte b/src/components/Timeline.svelte new file mode 100644 index 0000000..62a93d9 --- /dev/null +++ b/src/components/Timeline.svelte @@ -0,0 +1,124 @@ + + +{#if ordered.length} +
    + {#each ordered as entry (entry.id)} +
  • + + +
  • + {/each} +
+{/if} + + diff --git a/src/lib/Counter.svelte b/src/lib/Counter.svelte deleted file mode 100644 index 20bf4c9..0000000 --- a/src/lib/Counter.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/src/lib/boundary.test.js b/src/lib/boundary.test.js new file mode 100644 index 0000000..9a83306 --- /dev/null +++ b/src/lib/boundary.test.js @@ -0,0 +1,65 @@ +/** + * Pure-logic cover for the boundary clamp used by `setEntryStart`. The database + * call is a thin transaction around this arithmetic; getting the clamp wrong is + * what would invert a timeline. + */ +import { describe, expect, it } from 'vitest'; +import { atLocalTime, MINUTE, roundTs } from './time.js'; + +const DATE = '2026-09-04'; +const at = (hhmm) => atLocalTime(DATE, hhmm); + +/** Mirrors the clamp in db.js `setEntryStart`. */ +function clampStart({ prevStart = null, ownEnd = null, now }, ts) { + const lower = prevStart ?? Number.NEGATIVE_INFINITY; + const upper = ownEnd ?? now; + return Math.min(Math.max(ts, lower), upper); +} + +describe('entry start clamping', () => { + it('accepts a time inside the neighbouring boundaries', () => { + const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('09:30')); + expect(out).toBe(at('09:30')); + }); + + it('refuses to start before the previous entry began', () => { + const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('07:00')); + expect(out).toBe(at('08:00')); + }); + + it('refuses to start after its own end', () => { + const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('13:00')); + expect(out).toBe(at('12:00')); + }); + + it('caps a running entry at the present moment', () => { + const now = at('10:00'); + expect(clampStart({ prevStart: at('08:00'), ownEnd: null, now }, at('11:00'))).toBe(now); + }); + + it('lets the first entry of the day move freely backwards', () => { + // No predecessor: this is the case that makes the auto-captured day start + // correctable when the worker actually left base earlier. + const out = clampStart({ prevStart: null, ownEnd: at('12:00') }, at('06:15')); + expect(out).toBe(at('06:15')); + }); +}); + +describe('manual times snap to the rounding grid', () => { + it.each([ + [15, at('09:37'), at('09:30')], + [15, at('09:38'), at('09:45')], + [30, at('09:44'), at('09:30')], + [5, at('09:37'), at('09:35')], + [1, at('09:37'), at('09:37')], + ])('rounding %i min stores the displayed value', (step, input, expected) => { + // A time the worker typed must read back exactly as typed, so edits are + // snapped on write rather than only at render. + expect(roundTs(input, step)).toBe(expected); + }); + + it('keeps a nudged time on the grid it started on', () => { + const start = roundTs(at('09:37'), 15); + expect(roundTs(start + 15 * MINUTE, 15)).toBe(at('09:45')); + }); +}); diff --git a/src/lib/day.svelte.js b/src/lib/day.svelte.js new file mode 100644 index 0000000..37b50d9 --- /dev/null +++ b/src/lib/day.svelte.js @@ -0,0 +1,306 @@ +/** + * The day lifecycle: one state machine, one `reconcile()` entry point. + * + * Everything time-driven happens here — auto-start at the configured hour, + * auto-close of a day left open from an earlier date, and the end-of-day grace + * prompt. Components never decide when a day begins or ends. + */ + +import { + cleanupOldDays, + closeAndStart, + deleteEntry, + getActiveDay, + getDay, + getEntries, + getHistory, + getLastClosedDay, + patchDay, + patchEntry, + putDay, + setEntryStart, +} from './db.js'; +import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js'; +import { settings } from './settings.svelte.js'; +import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js'; + +export const dayState = $state({ + today: localDateKey(), + /** The running day, or null when nothing is in progress. */ + day: null, + entries: [], + /** Last closed day + its entries — what the resume screen renders. */ + lastClosed: null, + lastClosedEntries: [], + /** Prediction model, rebuilt whenever history changes. */ + model: null, + /** True once the configured end time has passed and the worker has not answered. */ + needsClosePrompt: false, + loaded: false, +}); + +export const running = () => dayState.entries.find((e) => e.endTs == null) ?? null; + +/* --------------------------------------------------------------- reconciling */ + +/** Retention runs once per session, not on every 30 s tick. */ +let cleanedUpFor = null; + +/** + * Bring stored state in line with the clock. Safe to call often — it is invoked on + * load, on tab focus, and from a 30 s tick. + */ +export async function reconcile(now = Date.now()) { + dayState.today = localDateKey(now); + + // Applying retention here is what makes the setting mean anything; leaving it to + // the manual button in settings meant it effectively never ran. + if (cleanedUpFor !== dayState.today) { + cleanedUpFor = dayState.today; + try { + await cleanupOldDays(settings.retentionDays); + } catch (error) { + console.error('[timelog] retention cleanup failed', error); + } + } + + const active = await getActiveDay(); + + // A day left open from an earlier date never stays open. + if (active && active.date < dayState.today) { + await forceClose(active); + return reconcile(now); + } + + // Auto-start. Only when no record exists for today at all: a day the worker has + // already ended must not spring back to life. + if (!active) { + const existing = await getDay(dayState.today); + if (!existing && settings.workdays.includes(weekday(dayState.today))) { + const plannedStart = atLocalTime(dayState.today, settings.dayStart); + if (now >= plannedStart) { + await openDay(dayState.today, plannedStart); + return refresh(now); + } + } + } + + await refresh(now); +} + +/** Close a stale day at the earlier of its planned end and its last recorded activity. */ +async function forceClose(day) { + const entries = await getEntries(day.date); + const lastEnd = entries.reduce((max, e) => Math.max(max, e.endTs ?? e.startTs), 0); + const endedTs = Math.min(day.plannedEndTs, lastEnd || day.plannedEndTs); + await closeAndStart(day.date, endedTs, null); + await patchDay(day.date, { status: 'closed', endedTs }); +} + +/** + * Create a day and immediately open the transport task: per the workflow, the drive + * out from base *is* the first task, and "Arrivé sur site" is what ends it. + */ +async function openDay(dateKey, startTs) { + await putDay({ + date: dateKey, + status: 'active', + plannedStartTs: atLocalTime(dateKey, settings.dayStart), + plannedEndTs: atLocalTime(dateKey, settings.dayEnd), + startedTs: startTs, + endedTs: null, + lunchType: settings.lunchType, + restaurant: null, + closePromptedTs: null, + }); + + const { entries, days } = await getHistory(); + const model = buildModel(entries, days); + await closeAndStart(dateKey, startTs, { + kind: 'travel', + task: TRANSPORT_TASK, + client: model.lastClient, + }); +} + +/** Reload everything from the database and rebuild the model. */ +export async function refresh(now = Date.now()) { + const active = await getActiveDay(); + dayState.day = active ?? null; + dayState.entries = active ? await getEntries(active.date) : []; + + const lastClosed = await getLastClosedDay(); + dayState.lastClosed = lastClosed ?? null; + dayState.lastClosedEntries = lastClosed ? await getEntries(lastClosed.date) : []; + + const { entries, days } = await getHistory(); + dayState.model = buildModel(entries, days); + + dayState.needsClosePrompt = Boolean( + active && active.date === dayState.today && now >= active.plannedEndTs, + ); + dayState.loaded = true; +} + +/* -------------------------------------------------------------------- actions */ + +/** Manual start, for a Saturday or an early departure. */ +export async function startDayNow(now = Date.now()) { + await openDay(dayState.today, now); + await refresh(now); +} + +/** + * Close the running entry and open the next one. The single mutation every button + * on the Today screen goes through. + * + * @param {{kind?: 'travel'|'work'|'lunch', task: string, client?: string|null}} slot + * @param {number} [now] + */ +export async function advance({ kind = 'work', task, client }, now = Date.now()) { + if (!dayState.day) return; + await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null }); + await refresh(now); +} + +export async function startLunch(now = Date.now()) { + if (!dayState.day) return; + await closeAndStart(dayState.day.date, now, { + kind: 'lunch', + task: 'Pause déjeuner', + client: null, + }); + await refresh(now); +} + +export async function setLunch({ lunchType, restaurant }) { + if (!dayState.day) return; + await patchDay(dayState.day.date, { + lunchType, + restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null, + }); + await refresh(); +} + +/** Correct the task or client of an entry already recorded. */ +export async function editEntry(id, patch) { + await patchEntry(id, patch); + await refresh(); +} + +/** + * Correct when an entry started. Manually chosen times are snapped to the rounding + * grid so the value the worker picked is exactly the value they see afterwards — + * auto-captured times stay raw, as designed. + */ +export async function editEntryStart(id, ts) { + await setEntryStart(id, roundTs(ts, settings.roundingMinutes)); + await refresh(); +} + +export async function removeEntry(id) { + await deleteEntry(id); + await refresh(); +} + +export async function endDay(now = Date.now()) { + if (!dayState.day) return; + const date = dayState.day.date; + await closeAndStart(date, now, null); + await patchDay(date, { status: 'closed', endedTs: now }); + await refresh(now); +} + +/** "Toujours en cours" — push the end out an hour so the prompt returns later. */ +export async function keepWorking(now = Date.now()) { + if (!dayState.day) return; + await patchDay(dayState.day.date, { + plannedEndTs: Math.max(dayState.day.plannedEndTs, now) + HOUR, + closePromptedTs: now, + }); + await refresh(now); +} + +/** Undo a premature end-of-day: reopens today and starts a fresh task from now. */ +export async function reopenToday(now = Date.now()) { + const day = await getDay(dayState.today); + if (!day || day.status !== 'closed') return; + await patchDay(dayState.today, { + status: 'active', + endedTs: null, + plannedEndTs: Math.max(day.plannedEndTs, now + HOUR), + }); + const { entries, days } = await getHistory(); + const model = buildModel(entries, days); + const [best] = suggestTasks(model, { + prevTask: null, + prevElapsedMs: 0, + seq: (await getEntries(dayState.today)).length, + ts: now, + client: model.lastClient, + }); + await closeAndStart(dayState.today, now, { + kind: 'work', + task: best?.value ?? TRANSPORT_TASK, + client: model.lastClient, + }); + await refresh(now); +} + +/* ---------------------------------------------------------------- predictions */ + +/** + * Best guess for the task and client of the slot that would open right now. + * Callers snapshot this when a screen opens rather than recomputing per tick, so + * the proposed value does not shift under the worker's thumb. + */ +export function predictNext(now = Date.now()) { + const model = dayState.model; + if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] }; + + const current = running(); + const tasks = suggestTasks(model, { + prevTask: current?.task ?? null, + prevElapsedMs: current ? now - current.startTs : 0, + seq: dayState.entries.length, + ts: now, + client: current?.client ?? model.lastClient, + }); + const clients = suggestClients(model, now); + + return { + task: tasks[0]?.value ?? TRANSPORT_TASK, + client: current?.client ?? clients[0]?.value ?? null, + tasks, + clients, + }; +} + +/** + * Suggestions ranked for the entry *currently* running, used when correcting it — + * a different context from `predictNext`, which ranks the slot about to open. + */ +export function predictForCurrent(now = Date.now()) { + const model = dayState.model; + const current = running(); + if (!model || !current) return { tasks: [], clients: [] }; + + const index = dayState.entries.findIndex((e) => e.id === current.id); + const prev = index > 0 ? dayState.entries[index - 1] : null; + + return { + tasks: suggestTasks(model, { + prevTask: prev?.task ?? null, + prevElapsedMs: prev?.endTs != null ? prev.endTs - prev.startTs : 0, + seq: current.seq, + ts: current.startTs, + client: current.client, + }), + clients: suggestClients(model, now), + }; +} + +/** The client the day has been booked to so far — used to pick a restaurant. */ +export function currentClient() { + return [...dayState.entries].reverse().find((e) => e.client)?.client ?? null; +} diff --git a/src/lib/db.js b/src/lib/db.js index 45bebd9..eb791dd 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -1,45 +1,240 @@ import Dexie from 'dexie'; +import { DAY, localDateKey } from './time.js'; -export const db = new Dexie('TimeLogDB'); +/** + * A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the + * settings in force when the day was created, so editing settings later never + * rewrites history. + * + * @typedef {object} DayRecord + * @property {string} date local 'YYYY-MM-DD', primary key + * @property {'active'|'closed'} status + * @property {number} plannedStartTs + * @property {number} plannedEndTs + * @property {number} startedTs + * @property {number|null} endedTs + * @property {'panier'|'restaurant'|null} lunchType + * @property {string|null} restaurant + * @property {number|null} closePromptedTs + */ + +/** + * One stretch of the day. `endTs === null` marks the single running entry; every + * other entry's `endTs` equals the next entry's `startTs`. + * + * @typedef {object} EntryRecord + * @property {number} [id] + * @property {string} date + * @property {number} seq position within the day, dense from 0 + * @property {'travel'|'work'|'lunch'} kind + * @property {string} task + * @property {string|null} client + * @property {number} startTs + * @property {number|null} endTs + */ + +/** + * @typedef {object} SettingsRecord + * @property {string} key + * @property {any} value + */ + +/** + * @typedef {Dexie & { + * days: import('dexie').Table, + * entries: import('dexie').Table, + * settings: import('dexie').Table, + * }} TimeLogDB + */ + +export const db = /** @type {TimeLogDB} */ (new Dexie('TimeLogDB')); + +// v1 was the throwaway prototype schema; declared so Dexie has an upgrade path. db.version(1).stores({ timeLogs: '++id, date, startTime, endTime, taskName, clientName', - settings: 'key' // for retention policy + settings: 'key', }); -// Helper functions -export async function addLog(log) { - return await db.timeLogs.add(log); +db.version(2).stores({ + timeLogs: null, // drop the prototype table + days: 'date, status', + entries: '++id, date, [date+seq], startTs', + settings: 'key', +}); + +/* ------------------------------------------------------------------ settings */ + +export async function readSettings() { + const rec = await db.settings.get('app'); + return rec?.value ?? null; } -export async function getLogsByDate(date) { - return await db.timeLogs.where('date').equals(date).toArray(); +export async function writeSettings(value) { + await db.settings.put({ key: 'app', value }); } -export async function getRecentLogs(limit = 10) { - return await db.timeLogs.orderBy('id').reverse().limit(limit).toArray(); +/* ---------------------------------------------------------------------- days */ + +export function getDay(dateKey) { + return db.days.get(dateKey); } -export async function getLastLog() { - return await db.timeLogs.orderBy('id').reverse().first(); +export function putDay(day) { + return db.days.put(day); } -export async function setRetentionDays(days) { - await db.settings.put({ key: 'retentionDays', value: days }); +export function patchDay(dateKey, patch) { + return db.days.update(dateKey, patch); } -// Retention logic -export async function cleanupOldLogs() { - const retentionDays = await db.settings.get('retentionDays') || 30; - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - retentionDays); - - // Keep the latest day's report - const latestDate = await db.timeLogs.orderBy('date').reverse().first(); - if (latestDate) { - await db.timeLogs - .where('date') - .below(cutoffDate.toISOString().split('T')[0]) - .and(log => log.date !== latestDate.date) - .delete(); - } +/** The one day still running, if any — including a stale one from a past date. */ +export function getActiveDay() { + return db.days.where('status').equals('active').first(); +} + +/** Most recent closed day: the one the resume screen shows. */ +export async function getLastClosedDay() { + const closed = await db.days.where('status').equals('closed').toArray(); + if (!closed.length) return null; + closed.sort((a, b) => a.date.localeCompare(b.date)); + return closed[closed.length - 1]; +} + +/* ------------------------------------------------------------------- entries */ + +export function getEntries(dateKey) { + return db.entries.where('date').equals(dateKey).sortBy('seq'); +} + +/** + * The single place the chain invariant is enforced: closing the running entry and + * opening the next one share one timestamp, in one transaction, so entry N's end + * can never drift from entry N+1's start. + * + * @param {string} dateKey + * @param {number} ts + * @param {Pick|null} next + * Pass null to only close the running entry (end of day). + * @returns {Promise} id of the new entry + */ +export function closeAndStart(dateKey, ts, next) { + return db.transaction('rw', db.entries, async () => { + const list = await db.entries.where('date').equals(dateKey).sortBy('seq'); + const running = list.find((e) => e.endTs == null); + + let boundary = ts; + if (running) { + // A tap can never produce an entry that ends before it started. + boundary = Math.max(ts, running.startTs); + await db.entries.update(running.id, { endTs: boundary }); + } + if (!next) return null; + + const seq = list.length ? list[list.length - 1].seq + 1 : 0; + return db.entries.add({ + date: dateKey, + seq, + startTs: boundary, + endTs: null, + ...next, + }); + }); +} + +export function patchEntry(id, patch) { + return db.entries.update(id, patch); +} + +/** + * Move the boundary at the start of an entry. Because that instant is shared with + * the previous entry's end, both sides move together and the chain stays gapless. + * For the first entry of a day there is no predecessor, so the day's start moves + * instead — that is what makes the auto-captured start time correctable. + * + * The value is clamped between the previous entry's start and this entry's own + * end (or now, while it is still running), so no edit can invert the timeline. + * + * @returns {Promise} the timestamp actually stored + */ +export function setEntryStart(id, ts, now = Date.now()) { + return db.transaction('rw', db.entries, db.days, async () => { + const entry = await db.entries.get(id); + if (!entry) return undefined; + + const list = await db.entries.where('date').equals(entry.date).sortBy('seq'); + const prev = list[list.findIndex((e) => e.id === id) - 1]; + + const lower = prev ? prev.startTs : Number.NEGATIVE_INFINITY; + const upper = entry.endTs ?? now; + const stored = Math.min(Math.max(ts, lower), upper); + + await db.entries.update(entry.id, { startTs: stored }); + if (prev) await db.entries.update(prev.id, { endTs: stored }); + else await db.days.update(entry.date, { startedTs: stored }); + + return stored; + }); +} + +/** + * Remove an entry and re-stitch the chain: the previous entry absorbs the gap by + * extending to the next entry's start. Keeps the timeline contiguous after a + * mis-tap, which is the whole point of allowing deletes. + */ +export function deleteEntry(id) { + return db.transaction('rw', db.entries, async () => { + const victim = await db.entries.get(id); + if (!victim) return; + const list = await db.entries.where('date').equals(victim.date).sortBy('seq'); + const i = list.findIndex((e) => e.id === id); + const prev = list[i - 1]; + const next = list[i + 1]; + + await db.entries.delete(id); + if (prev) { + // Extend the previous entry over the hole; if nothing follows, it inherits + // the victim's end (including `null`, i.e. it becomes the running entry). + await db.entries.update(prev.id, { endTs: next ? next.startTs : victim.endTs }); + } else if (next) { + await db.entries.update(next.id, { startTs: victim.startTs }); + } + // Re-number so `seq` stays dense. + const rest = list.filter((e) => e.id !== id); + await Promise.all( + rest.map((e, n) => (e.seq === n ? null : db.entries.update(e.id, { seq: n }))), + ); + }); +} + +/* ------------------------------------------------------------------- history */ + +/** Everything the prediction model is built from. */ +export async function getHistory() { + const [entries, days] = await Promise.all([ + db.entries.toArray(), + db.days.toArray(), + ]); + return { entries, days }; +} + +/** + * Drop days older than the retention window. The most recent closed day is always + * kept — it is the app's primary output and must survive any setting. + */ +export async function cleanupOldDays(retentionDays) { + if (!retentionDays || retentionDays <= 0) return 0; + const cutoff = localDateKey(Date.now() - retentionDays * DAY); + const keep = await getLastClosedDay(); + + const stale = (await db.days.toArray()).filter( + (d) => d.date < cutoff && d.date !== keep?.date, + ); + if (!stale.length) return 0; + + const keys = stale.map((d) => d.date); + await db.transaction('rw', db.days, db.entries, async () => { + await db.days.bulkDelete(keys); + await db.entries.where('date').anyOf(keys).delete(); + }); + return keys.length; } diff --git a/src/lib/predict.js b/src/lib/predict.js new file mode 100644 index 0000000..31002d0 --- /dev/null +++ b/src/lib/predict.js @@ -0,0 +1,319 @@ +/** + * Suggestion / prediction engine. + * + * Pure functions over the raw history — no counter tables to keep in sync. The + * volume is a few hundred entries per year, so the model is just rebuilt whenever + * history changes. + * + * Everything is a blend of smoothed conditional frequencies. No training, no + * opaque state: a worker who does the same three things in the same order every + * day gets the right answer on day two, and the reasoning stays inspectable. + */ + +import { HOUR, MINUTE, localDateKey, startOfDay } from './time.js'; + +/** + * Used before there is any history to learn from, so day one is still usable. + * Listed in the order a day actually runs — that order is the tiebreaker when + * nothing has been learned yet, so the first suggestion after arriving on site is + * the work, not the trip home. + */ +export const SEED_TASKS = [ + 'Chargement / transport', + 'Travaux sur site', + 'Retour / déchargement', +]; + +const seedRank = (task) => { + const i = SEED_TASKS.indexOf(task); + return i === -1 ? SEED_TASKS.length : i; +}; + +export const TRANSPORT_TASK = SEED_TASKS[0]; + +/** Blend weights for the task ranking. Tunable in one place. */ +export const WEIGHTS = { + bigram: 0.35, // what usually follows the current task + seq: 0.25, // what usually sits at this position in the day + clock: 0.2, // what usually happens around this hour + client: 0.1, // what this client usually needs + global: 0.1, // overall favourites +}; + +/** Recency half-life for client ranking. */ +const CLIENT_HALF_LIFE_DAYS = 30; + +/** Laplace smoothing constant. */ +const ALPHA = 0.5; + +/* ------------------------------------------------------------------ counting */ + +const bump = (map, key, by = 1) => map.set(key, (map.get(key) ?? 0) + by); + +function bump2(map, outer, inner) { + let m = map.get(outer); + if (!m) map.set(outer, (m = new Map())); + bump(m, inner); +} + +/** 30-minute bucket of the day, 0..47. */ +export function clockBucket(ts) { + return Math.floor((ts - startOfDay(localDateKey(ts))) / (30 * MINUTE)) % 48; +} + +const median = (xs) => { + if (!xs.length) return 0; + const s = [...xs].sort((a, b) => a - b); + const mid = s.length >> 1; + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +}; + +/** + * How long the current task has run, relative to how long it usually runs. + * Three buckets only — enough to separate "just started" from "dragging on" + * without making the conditioned bigram sparse. + */ +export function elapsedBucket(elapsedMs, medianMs) { + if (!medianMs) return 'typical'; + if (elapsedMs < medianMs * 0.6) return 'short'; + if (elapsedMs > medianMs * 1.4) return 'long'; + return 'typical'; +} + +/* -------------------------------------------------------------------- model */ + +/** + * @param {Array} entries all historical entries + * @param {Array} days all historical days + */ +export function buildModel(entries, days = []) { + const model = { + taskCount: new Map(), + taskDurations: new Map(), + taskLastTs: new Map(), + bigram: new Map(), // prevTask -> task -> n + bigramElapsed: new Map(), // `${prev}|${bucket}` -> task -> n + bySeq: new Map(), // seq -> task -> n + byClock: new Map(), // clock bucket -> task -> n + byClient: new Map(), // client -> task -> n + clientCount: new Map(), + clientLastTs: new Map(), + restByClient: new Map(), // client -> restaurant -> n + restCount: new Map(), + restLastTs: new Map(), + lastClient: null, + lastRestaurant: null, + }; + + // Work entries only: lunch and its restaurant are modelled separately. + const work = entries + .filter((e) => e.kind !== 'lunch' && e.task) + .sort((a, b) => a.startTs - b.startTs); + + const byDate = new Map(); + for (const e of work) { + if (!byDate.has(e.date)) byDate.set(e.date, []); + byDate.get(e.date).push(e); + } + + for (const e of work) { + bump(model.taskCount, e.task); + bump2(model.bySeq, e.seq, e.task); + bump2(model.byClock, clockBucket(e.startTs), e.task); + model.taskLastTs.set(e.task, Math.max(model.taskLastTs.get(e.task) ?? 0, e.startTs)); + + if (e.endTs != null) { + const arr = model.taskDurations.get(e.task) ?? []; + arr.push(e.endTs - e.startTs); + model.taskDurations.set(e.task, arr); + } + if (e.client) { + bump(model.clientCount, e.client); + bump2(model.byClient, e.client, e.task); + model.clientLastTs.set( + e.client, + Math.max(model.clientLastTs.get(e.client) ?? 0, e.startTs), + ); + } + } + + model.taskMedian = new Map( + [...model.taskDurations].map(([task, ds]) => [task, median(ds)]), + ); + + // Transitions, within a day only — the last task of Monday does not predict the + // first task of Tuesday. + for (const list of byDate.values()) { + for (let i = 1; i < list.length; i++) { + const prev = list[i - 1]; + const cur = list[i]; + bump2(model.bigram, prev.task, cur.task); + if (prev.endTs != null) { + const bucket = elapsedBucket( + prev.endTs - prev.startTs, + model.taskMedian.get(prev.task), + ); + bump2(model.bigramElapsed, `${prev.task}|${bucket}`, cur.task); + } + } + } + + // Restaurants live on the day record, paired with that day's dominant client. + const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date)); + for (const d of sortedDays) { + if (d.lunchType !== 'restaurant' || !d.restaurant) continue; + bump(model.restCount, d.restaurant); + model.restLastTs.set(d.restaurant, startOfDay(d.date)); + model.lastRestaurant = d.restaurant; + const client = dominantClient(byDate.get(d.date) ?? []); + if (client) bump2(model.restByClient, client, d.restaurant); + } + + const lastWithClient = [...work].reverse().find((e) => e.client); + model.lastClient = lastWithClient?.client ?? null; + + return model; +} + +/** The client a day's time mostly went to — how a restaurant gets attributed. */ +function dominantClient(dayEntries) { + const totals = new Map(); + for (const e of dayEntries) { + if (!e.client || e.endTs == null) continue; + bump(totals, e.client, e.endTs - e.startTs); + } + let best = null; + for (const [client, ms] of totals) if (!best || ms > best[1]) best = [client, ms]; + return best?.[0] ?? null; +} + +/* ------------------------------------------------------------------ ranking */ + +/** Smoothed P(x | table), over the union of candidates. */ +function dist(table, candidates) { + const total = [...(table?.values() ?? [])].reduce((a, b) => a + b, 0); + const denom = total + ALPHA * candidates.length; + const out = new Map(); + for (const c of candidates) out.set(c, ((table?.get(c) ?? 0) + ALPHA) / denom); + return out; +} + +/** + * Rank tasks for the slot about to open. + * + * @param {object} model + * @param {object} ctx + * @param {string|null} ctx.prevTask task being closed + * @param {number} ctx.prevElapsedMs how long it ran + * @param {number} ctx.seq position of the new entry in the day + * @param {number} ctx.ts when the new entry starts + * @param {string|null} ctx.client + * @returns {Array<{value: string, hint: string, score: number}>} + */ +export function suggestTasks(model, ctx) { + const known = [...model.taskCount.keys()]; + const candidates = known.length ? known : [...SEED_TASKS]; + for (const t of SEED_TASKS) if (!candidates.includes(t)) candidates.push(t); + + // Prefer the elapsed-conditioned transitions, fall back to the plain bigram when + // that bucket has not been seen yet. + let bigramTable = null; + if (ctx.prevTask) { + const bucket = elapsedBucket( + ctx.prevElapsedMs ?? 0, + model.taskMedian.get(ctx.prevTask), + ); + bigramTable = + model.bigramElapsed.get(`${ctx.prevTask}|${bucket}`) ?? + model.bigram.get(ctx.prevTask); + } + + const pBigram = dist(bigramTable, candidates); + const pSeq = dist(model.bySeq.get(ctx.seq), candidates); + const pClock = dist(model.byClock.get(clockBucket(ctx.ts)), candidates); + const pClient = dist(ctx.client ? model.byClient.get(ctx.client) : null, candidates); + const pGlobal = dist(model.taskCount, candidates); + + return candidates + .map((value) => ({ + value, + score: + WEIGHTS.bigram * pBigram.get(value) + + WEIGHTS.seq * pSeq.get(value) + + WEIGHTS.clock * pClock.get(value) + + WEIGHTS.client * pClient.get(value) + + WEIGHTS.global * pGlobal.get(value), + hint: durationHint(model, value), + })) + // Never propose repeating the task just closed; the worker would have kept going. + .filter((s) => s.value !== ctx.prevTask) + // Seed order only ever breaks an exact tie, so it cannot perturb a ranking + // that history has actually informed. + .sort( + (a, b) => + b.score - a.score || + seedRank(a.value) - seedRank(b.value) || + a.value.localeCompare(b.value), + ); +} + +function durationHint(model, task) { + const ms = model.taskMedian.get(task); + if (!ms || ms < 5 * MINUTE) return ''; + const h = Math.floor(ms / HOUR); + const m = Math.round((ms % HOUR) / MINUTE); + return h ? `~${h}h${String(m).padStart(2, '0')} d'habitude` : `~${m} min d'habitude`; +} + +/** + * Rank clients. The most recently used one is pinned to the top — that is the + * stated default — and the rest fall back on frequency decayed by recency. + */ +export function suggestClients(model, now = Date.now()) { + const ranked = [...model.clientCount.keys()] + .map((value) => { + const last = model.clientLastTs.get(value) ?? 0; + const days = Math.max(0, (now - last) / 86_400_000); + return { + value, + score: model.clientCount.get(value) * 0.5 ** (days / CLIENT_HALF_LIFE_DAYS), + hint: recencyHint(last, now), + }; + }) + .sort((a, b) => b.score - a.score || a.value.localeCompare(b.value)); + + const pinned = ranked.findIndex((c) => c.value === model.lastClient); + if (pinned > 0) ranked.unshift(ranked.splice(pinned, 1)[0]); + return ranked; +} + +/** + * Rank restaurants for a client: what was eaten with *this* client first, then + * everything else by overall frequency. + */ +export function suggestRestaurants(model, client, now = Date.now()) { + const paired = model.restByClient.get(client) ?? new Map(); + return [...model.restCount.keys()] + .map((value) => { + const withClient = paired.get(value) ?? 0; + return { + value, + // Any restaurant tied to this client outranks any that is not. + score: withClient * 1000 + model.restCount.get(value), + hint: withClient + ? `${withClient} × avec ${client}` + : recencyHint(model.restLastTs.get(value) ?? 0, now), + }; + }) + .sort((a, b) => b.score - a.score || a.value.localeCompare(b.value)); +} + +function recencyHint(ts, now) { + if (!ts) return ''; + const days = Math.floor((now - ts) / 86_400_000); + if (days <= 0) return "aujourd'hui"; + if (days === 1) return 'hier'; + if (days < 7) return `il y a ${days} jours`; + if (days < 30) return `il y a ${Math.floor(days / 7)} sem.`; + return `il y a ${Math.floor(days / 30)} mois`; +} diff --git a/src/lib/predict.test.js b/src/lib/predict.test.js new file mode 100644 index 0000000..5e169b6 --- /dev/null +++ b/src/lib/predict.test.js @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; +import { + buildModel, + elapsedBucket, + SEED_TASKS, + suggestClients, + suggestRestaurants, + suggestTasks, +} from './predict.js'; +import { atLocalTime, HOUR, MINUTE } from './time.js'; + +/** A day shaped like the real workflow: transport, work, lunch, work, return. */ +function makeDay(date, client, restaurant, tasks) { + let ts = atLocalTime(date, '07:30'); + const entries = tasks.map(([task, minutes, kind = 'work'], seq) => { + const startTs = ts; + ts += minutes * MINUTE; + return { + id: `${date}-${seq}`, + date, + seq, + kind, + task, + client: kind === 'lunch' ? null : client, + startTs, + endTs: ts, + }; + }); + return { + day: { + date, + status: 'closed', + lunchType: restaurant ? 'restaurant' : 'panier', + restaurant, + }, + entries, + }; +} + +const STANDARD = [ + ['Chargement / transport', 45, 'travel'], + ['Terrassement', 195], + ['Pause déjeuner', 60, 'lunch'], + ['Pose de câble', 210], + ['Retour / déchargement', 50], +]; + +function history(specs) { + const days = []; + const entries = []; + for (const [date, client, restaurant] of specs) { + const made = makeDay(date, client, restaurant, STANDARD); + days.push(made.day); + entries.push(...made.entries); + } + return { days, entries }; +} + +const FIXTURE = history([ + ['2026-09-01', 'Dupont SA', 'Le Relais'], + ['2026-09-02', 'Dupont SA', 'Le Relais'], + ['2026-09-03', 'Martin & Fils', 'Chez Marcel'], + ['2026-09-04', 'Dupont SA', 'Le Relais'], +]); + +const NOW = atLocalTime('2026-09-07', '09:00'); + +describe('buildModel', () => { + it('learns tasks, clients and their last use', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + expect(m.taskCount.get('Terrassement')).toBe(4); + expect(m.clientCount.get('Dupont SA')).toBe(12); // 4 work entries × 3 days + expect(m.lastClient).toBe('Dupont SA'); + }); + + it('excludes lunch from the task model', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + expect(m.taskCount.has('Pause déjeuner')).toBe(false); + }); + + it('does not chain the last task of one day to the first of the next', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + expect(m.bigram.get('Retour / déchargement')).toBeUndefined(); + }); + + it('records a median duration per task', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + expect(m.taskMedian.get('Terrassement')).toBe(195 * MINUTE); + }); +}); + +describe('suggestTasks', () => { + it('predicts the task that historically follows the current one', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + const [best] = suggestTasks(m, { + prevTask: 'Chargement / transport', + prevElapsedMs: 45 * MINUTE, + seq: 1, + ts: atLocalTime('2026-09-07', '08:15'), + client: 'Dupont SA', + }); + expect(best.value).toBe('Terrassement'); + }); + + it('predicts the return trip at the end of the chain', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + const [best] = suggestTasks(m, { + prevTask: 'Pose de câble', + prevElapsedMs: 210 * MINUTE, + seq: 4, + ts: atLocalTime('2026-09-07', '16:40'), + client: 'Dupont SA', + }); + expect(best.value).toBe('Retour / déchargement'); + }); + + it('never proposes repeating the task just closed', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + const out = suggestTasks(m, { + prevTask: 'Terrassement', + prevElapsedMs: 195 * MINUTE, + seq: 2, + ts: NOW, + client: 'Dupont SA', + }); + expect(out.map((s) => s.value)).not.toContain('Terrassement'); + }); + + it('falls back to the seed list with no history at all', () => { + const m = buildModel([], []); + const out = suggestTasks(m, { prevTask: null, prevElapsedMs: 0, seq: 0, ts: NOW }); + expect(out.length).toBe(SEED_TASKS.length); + expect(out.map((s) => s.value)).toEqual(SEED_TASKS); + }); + + it('proposes the work, not the trip home, on a cold start after transport', () => { + // With zero history every candidate scores identically, so the tiebreak has to + // follow the natural order of a day rather than the alphabet. + const m = buildModel([], []); + const out = suggestTasks(m, { + prevTask: 'Chargement / transport', + prevElapsedMs: 45 * MINUTE, + seq: 1, + ts: NOW, + }); + expect(out[0].value).toBe('Travaux sur site'); + }); + + it('surfaces a duration hint for tasks it has timed', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + const hit = suggestTasks(m, { + prevTask: 'Chargement / transport', + prevElapsedMs: 45 * MINUTE, + seq: 1, + ts: NOW, + client: 'Dupont SA', + }).find((s) => s.value === 'Terrassement'); + expect(hit.hint).toBe("~3h15 d'habitude"); + }); +}); + +describe('elapsedBucket', () => { + it.each([ + [30 * MINUTE, HOUR, 'short'], + [HOUR, HOUR, 'typical'], + [2 * HOUR, HOUR, 'long'], + [5 * HOUR, 0, 'typical'], // unknown median + ])('buckets %i against %i as %s', (elapsed, median, expected) => { + expect(elapsedBucket(elapsed, median)).toBe(expected); + }); +}); + +describe('suggestClients', () => { + it('pins the most recently used client to the top', () => { + // Martin & Fils is used least, but most recently. + const h = history([ + ['2026-09-01', 'Dupont SA', null], + ['2026-09-02', 'Dupont SA', null], + ['2026-09-03', 'Dupont SA', null], + ['2026-09-04', 'Martin & Fils', null], + ]); + const m = buildModel(h.entries, h.days); + expect(suggestClients(m, NOW)[0].value).toBe('Martin & Fils'); + }); + + it('ranks the rest by frequency decayed with recency', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + expect(suggestClients(m, NOW).map((c) => c.value)).toEqual([ + 'Dupont SA', + 'Martin & Fils', + ]); + }); +}); + +describe('suggestRestaurants', () => { + it('defaults to the restaurant most used with this client', () => { + const m = buildModel(FIXTURE.entries, FIXTURE.days); + expect(suggestRestaurants(m, 'Dupont SA', NOW)[0].value).toBe('Le Relais'); + expect(suggestRestaurants(m, 'Martin & Fils', NOW)[0].value).toBe('Chez Marcel'); + }); + + it('ranks any client-paired restaurant above a merely frequent one', () => { + // Le Relais is globally more common, but Chez Marcel is Martin & Fils' place. + const ranked = suggestRestaurants(FIXTURE_MODEL(), 'Martin & Fils', NOW); + expect(ranked[0].value).toBe('Chez Marcel'); + expect(ranked[0].hint).toBe('1 × avec Martin & Fils'); + }); + + it('falls back to global frequency for an unknown client', () => { + expect(suggestRestaurants(FIXTURE_MODEL(), 'Client inconnu', NOW)[0].value).toBe( + 'Le Relais', + ); + }); +}); + +function FIXTURE_MODEL() { + return buildModel(FIXTURE.entries, FIXTURE.days); +} diff --git a/src/lib/resume.js b/src/lib/resume.js new file mode 100644 index 0000000..cf02a94 --- /dev/null +++ b/src/lib/resume.js @@ -0,0 +1,105 @@ +/** + * Builds the last-day resume — the app's primary output, meant to be copied + * straight into a paper logbook. + * + * Durations are computed from *rounded* boundaries so the printed times and the + * printed totals always agree. Because adjacent entries share one stored + * timestamp, rounding can never open a gap between them. + */ + +import { fmtDayLabel, fmtDuration, roundTs } from './time.js'; + +/** + * @param {object} day + * @param {Array} entries + * @param {number} roundingMinutes + */ +export function buildResume(day, entries, roundingMinutes = 15) { + if (!day) return null; + + const fallbackEnd = day.endedTs ?? Date.now(); + /** @type {Map} */ + const byClient = new Map(); + /** @type {{type: string, restaurant: string|null, ms: number}|null} */ + let lunch = null; + let workedMs = 0; + let omitted = 0; + + for (const e of [...entries].sort((a, b) => a.seq - b.seq)) { + const start = roundTs(e.startTs, roundingMinutes); + const end = roundTs(e.endTs ?? fallbackEnd, roundingMinutes); + const ms = Math.max(0, end - start); + + if (ms === 0) { + // Real but shorter than one rounding step. Kept in the data, left out of the + // logbook, and reported so the worker knows something was dropped. + omitted++; + continue; + } + + if (e.kind === 'lunch') { + lunch = { + type: day.lunchType ?? 'panier', + restaurant: day.restaurant ?? null, + ms: (lunch?.ms ?? 0) + ms, + }; + continue; + } + + workedMs += ms; + const client = e.client || 'Sans client'; + if (!byClient.has(client)) byClient.set(client, { name: client, totalMs: 0, tasks: [] }); + const bucket = byClient.get(client); + bucket.totalMs += ms; + + // Same task twice in a day (before and after lunch) reads as one line. + const existing = bucket.tasks.find((t) => t.name === e.task); + if (existing) existing.ms += ms; + else bucket.tasks.push({ name: e.task, ms }); + } + + const clients = [...byClient.values()].sort((a, b) => b.totalMs - a.totalMs); + for (const c of clients) c.tasks.sort((a, b) => b.ms - a.ms); + + return { + dateKey: day.date, + label: fmtDayLabel(day.date), + clients, + lunch, + workedMs, + omitted, + }; +} + +/** The exact string the copy button puts on the clipboard. */ +export function resumeToText(resume) { + if (!resume) return ''; + const lines = [resume.label, '']; + + // Pad task names to a common width so the durations line up in the logbook. + const width = Math.max( + 0, + ...resume.clients.flatMap((c) => c.tasks.map((t) => t.name.length)), + ); + + for (const c of resume.clients) { + lines.push(`${c.name} — ${fmtDuration(c.totalMs)}`); + for (const t of c.tasks) { + lines.push(` ${t.name.padEnd(width)} ${fmtDuration(t.ms)}`); + } + lines.push(''); + } + + if (resume.lunch) { + const where = + resume.lunch.type === 'restaurant' + ? `restaurant (${resume.lunch.restaurant || 'non précisé'})` + : 'panier'; + lines.push(`Déjeuner : ${where}, ${fmtDuration(resume.lunch.ms)}`); + lines.push(''); + } + + lines.push(`Total travaillé : ${fmtDuration(resume.workedMs)}`); + + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} diff --git a/src/lib/resume.test.js b/src/lib/resume.test.js new file mode 100644 index 0000000..7069f12 --- /dev/null +++ b/src/lib/resume.test.js @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { buildResume, resumeToText } from './resume.js'; +import { atLocalTime, MINUTE } from './time.js'; + +const DATE = '2026-09-04'; +const at = (hhmm) => atLocalTime(DATE, hhmm); + +const DAY = { + date: DATE, + status: 'closed', + lunchType: 'restaurant', + restaurant: 'Le Relais', + endedTs: at('17:30'), +}; + +const ENTRIES = [ + { id: 1, date: DATE, seq: 0, kind: 'travel', task: 'Chargement / transport', client: 'Dupont SA', startTs: at('07:30'), endTs: at('08:15') }, + { id: 2, date: DATE, seq: 1, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('08:15'), endTs: at('12:00') }, + { id: 3, date: DATE, seq: 2, kind: 'lunch', task: 'Pause déjeuner', client: null, startTs: at('12:00'), endTs: at('13:00') }, + { id: 4, date: DATE, seq: 3, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('13:00'), endTs: at('16:45') }, + { id: 5, date: DATE, seq: 4, kind: 'work', task: 'Retour / déchargement', client: 'Dupont SA', startTs: at('16:45'), endTs: at('17:30') }, +]; + +describe('buildResume', () => { + it('groups by client and sums repeated tasks', () => { + const r = buildResume(DAY, ENTRIES, 15); + expect(r.clients).toHaveLength(1); + const [dupont] = r.clients; + expect(dupont.name).toBe('Dupont SA'); + // Terrassement appears twice, before and after lunch, and reads as one line. + expect(dupont.tasks.map((t) => t.name)).toEqual([ + 'Terrassement', + 'Chargement / transport', + 'Retour / déchargement', + ]); + expect(dupont.tasks[0].ms).toBe(450 * MINUTE); // 3h45 + 3h45 + }); + + it('keeps lunch out of the worked total', () => { + const r = buildResume(DAY, ENTRIES, 15); + expect(r.workedMs).toBe(9 * 60 * MINUTE); // 09h00 on site, lunch excluded + expect(r.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: 60 * MINUTE }); + }); + + it('splits totals across several clients', () => { + const mixed = [ + ENTRIES[0], + { ...ENTRIES[1], client: 'Martin & Fils' }, + ...ENTRIES.slice(2), + ]; + const r = buildResume(DAY, mixed, 15); + expect(r.clients.map((c) => c.name)).toEqual(['Dupont SA', 'Martin & Fils']); + expect(r.workedMs).toBe(9 * 60 * MINUTE); + }); + + it('omits entries shorter than one rounding step, and says so', () => { + const withBlip = [ + ...ENTRIES, + { id: 6, date: DATE, seq: 5, kind: 'work', task: 'Mauvais appui', client: 'Dupont SA', startTs: at('17:30'), endTs: at('17:32') }, + ]; + const r = buildResume(DAY, withBlip, 15); + expect(r.omitted).toBe(1); + expect(r.clients[0].tasks.map((t) => t.name)).not.toContain('Mauvais appui'); + }); + + it('keeps a 2-minute entry when rounding is set to 1 minute', () => { + const withBlip = [ + ...ENTRIES, + { id: 6, date: DATE, seq: 5, kind: 'work', task: 'Mauvais appui', client: 'Dupont SA', startTs: at('17:30'), endTs: at('17:32') }, + ]; + const r = buildResume(DAY, withBlip, 1); + expect(r.omitted).toBe(0); + expect(r.clients[0].tasks.map((t) => t.name)).toContain('Mauvais appui'); + }); + + it('returns null without a day', () => { + expect(buildResume(null, [], 15)).toBeNull(); + }); +}); + +describe('resumeToText', () => { + it('renders the approved logbook format', () => { + expect(resumeToText(buildResume(DAY, ENTRIES, 15))).toBe( + [ + 'Vendredi 4 septembre', + '', + 'Dupont SA — 9h00', + ' Terrassement 7h30', + ' Chargement / transport 0h45', + ' Retour / déchargement 0h45', + '', + 'Déjeuner : restaurant (Le Relais), 1h00', + '', + 'Total travaillé : 9h00', + ].join('\n'), + ); + }); + + it('names the panier case without a restaurant', () => { + const r = buildResume({ ...DAY, lunchType: 'panier', restaurant: null }, ENTRIES, 15); + expect(resumeToText(r)).toContain('Déjeuner : panier, 1h00'); + }); + + it('is empty for a null resume', () => { + expect(resumeToText(null)).toBe(''); + }); +}); diff --git a/src/lib/router.svelte.js b/src/lib/router.svelte.js new file mode 100644 index 0000000..aed7e2b --- /dev/null +++ b/src/lib/router.svelte.js @@ -0,0 +1,29 @@ +/** + * Three screens, so no router dependency — just enough hash handling that the + * Android back button behaves inside an installed PWA. + * + * `route === null` means "wherever the day state says", which is the default the + * worker should land on. + */ + +const ROUTES = ['resume', 'today', 'settings']; + +function fromHash() { + const h = location.hash.replace(/^#\/?/, ''); + return ROUTES.includes(h) ? h : null; +} + +export const router = $state({ route: fromHash() }); + +window.addEventListener('hashchange', () => { + router.route = fromHash(); +}); + +export function go(route) { + location.hash = route ? `#/${route}` : ''; +} + +export function back() { + if (history.length > 1) history.back(); + else go(null); +} diff --git a/src/lib/seed.js b/src/lib/seed.js new file mode 100644 index 0000000..748983f --- /dev/null +++ b/src/lib/seed.js @@ -0,0 +1,99 @@ +/** + * DEV-only demo history, so the prediction engine has something to chew on. + * + * Not run automatically — it would clobber whatever you were testing. Open the + * console and call `timelog.seed()`. + */ + +import { db } from './db.js'; +import { addDays, atLocalTime, localDateKey, MINUTE, weekday } from './time.js'; + +const CLIENTS = ['Dupont SA', 'Martin & Fils', 'Commune de Vaugines']; + +/** Each client eats at a consistent place — the pairing the resto prediction learns. */ +const RESTAURANT_BY_CLIENT = { + 'Dupont SA': 'Le Relais', + 'Martin & Fils': 'Chez Marcel', + 'Commune de Vaugines': 'La Bonne Fourchette', +}; + +const SHAPE = [ + { kind: 'travel', task: 'Chargement / transport', minutes: 45 }, + { kind: 'work', task: 'Terrassement', minutes: 195 }, + { kind: 'lunch', task: 'Pause déjeuner', minutes: 60 }, + { kind: 'work', task: 'Pose de câble', minutes: 210 }, + { kind: 'work', task: 'Retour / déchargement', minutes: 50 }, +]; + +function jitter(minutes) { + return Math.round(minutes + (Math.random() * 2 - 1) * minutes * 0.15); +} + +async function seed(dayCount = 20) { + await db.transaction('rw', db.days, db.entries, async () => { + await db.days.clear(); + await db.entries.clear(); + + let cursor = localDateKey(); + let produced = 0; + + while (produced < dayCount) { + cursor = addDays(cursor, -1); + const dow = weekday(cursor); + if (dow === 0 || dow === 6) continue; + + const client = CLIENTS[produced % CLIENTS.length]; + // Three quarters of days are restaurant days, so both branches have history. + // The period must stay coprime with CLIENTS.length, or lunch type correlates + // with the client and no client ever accumulates a restaurant. + const lunchType = produced % 4 === 0 ? 'panier' : 'restaurant'; + + let ts = atLocalTime(cursor, '07:30'); + const plannedEndTs = atLocalTime(cursor, '17:30'); + const entries = []; + + SHAPE.forEach((step, seq) => { + const end = ts + jitter(step.minutes) * MINUTE; + entries.push({ + date: cursor, + seq, + kind: step.kind, + task: step.task, + client: step.kind === 'lunch' ? null : client, + startTs: ts, + endTs: end, + }); + ts = end; + }); + + await db.days.put({ + date: cursor, + status: 'closed', + plannedStartTs: atLocalTime(cursor, '07:30'), + plannedEndTs, + startedTs: entries[0].startTs, + endedTs: ts, + lunchType, + restaurant: lunchType === 'restaurant' ? RESTAURANT_BY_CLIENT[client] : null, + closePromptedTs: null, + }); + await db.entries.bulkAdd(entries); + produced++; + } + }); + + location.reload(); +} + +async function wipe() { + await db.transaction('rw', db.days, db.entries, async () => { + await db.days.clear(); + await db.entries.clear(); + }); + location.reload(); +} + +export function installDevSeed() { + window.timelog = { seed, wipe, db }; + console.info('[timelog] dev helpers: timelog.seed(), timelog.wipe()'); +} diff --git a/src/lib/settings.svelte.js b/src/lib/settings.svelte.js new file mode 100644 index 0000000..893cc98 --- /dev/null +++ b/src/lib/settings.svelte.js @@ -0,0 +1,74 @@ +import { readSettings, writeSettings } from './db.js'; + +export const DEFAULT_SETTINGS = { + /** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */ + workdays: [1, 2, 3, 4, 5], + dayStart: '07:30', + dayEnd: '17:30', + lunchStart: '12:00', + lunchMinutes: 60, + /** 'panier' = worker-provided, 'restaurant' = eaten out. */ + lunchType: 'panier', + /** 1 | 5 | 15 | 30 */ + roundingMinutes: 15, + /** 'jour' | 'nuit' — deliberately manual: dark mode is worse in direct sun. */ + theme: 'jour', + /** + * Generous by default: old days are what the predictions are learned from. + * Two years, kept a multiple of the settings input's 30-day step so the field + * does not render as invalid. + */ + retentionDays: 720, +}; + +export const ROUNDING_CHOICES = [1, 5, 15, 30]; + +export const WEEKDAY_LABELS = [ + { value: 1, label: 'Lun' }, + { value: 2, label: 'Mar' }, + { value: 3, label: 'Mer' }, + { value: 4, label: 'Jeu' }, + { value: 5, label: 'Ven' }, + { value: 6, label: 'Sam' }, + { value: 0, label: 'Dim' }, +]; + +export const settings = $state({ ...DEFAULT_SETTINGS, loaded: false }); + +/** + * Set when a write fails, so a broken save is visible on the settings screen + * instead of vanishing. Every call site of `updateSettings` is a click handler + * that ignores the returned promise, so a rejection here would otherwise be + * completely silent — which is exactly how the settings stopped persisting. + */ +export const settingsError = $state({ message: null }); + +export async function loadSettings() { + const stored = await readSettings(); + Object.assign(settings, DEFAULT_SETTINGS, stored ?? {}, { loaded: true }); + applyTheme(); +} + +export async function updateSettings(patch) { + Object.assign(settings, patch); + applyTheme(); + // `settings` is a $state proxy and its nested values (workdays) are proxies + // too. IndexedDB clones structurally and refuses proxies with a + // DataCloneError, so the plain snapshot is required, not optional. + const { loaded, ...persisted } = $state.snapshot(settings); + try { + await writeSettings(persisted); + settingsError.message = null; + } catch (error) { + settingsError.message = `Enregistrement impossible : ${error.message}`; + console.error('[timelog] failed to persist settings', error); + } +} + +export function applyTheme() { + document.documentElement.dataset.theme = settings.theme; +} + +export function isWorkday(weekdayNumber) { + return settings.workdays.includes(weekdayNumber); +} diff --git a/src/lib/time.js b/src/lib/time.js new file mode 100644 index 0000000..92bac58 --- /dev/null +++ b/src/lib/time.js @@ -0,0 +1,80 @@ +/** + * Time helpers. Everything here works in *local* time — the prototype derived day + * keys from `toISOString()`, which is UTC and files late/early work under the wrong + * day. Timestamps are epoch milliseconds; rounding is applied at render only. + */ + +export const MINUTE = 60_000; +export const HOUR = 3_600_000; +export const DAY = 86_400_000; + +const pad = (n) => String(n).padStart(2, '0'); + +/** Local 'YYYY-MM-DD' for a timestamp. */ +export function localDateKey(ts = Date.now()) { + const d = new Date(ts); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Timestamp of local midnight starting `dateKey`. */ +export function startOfDay(dateKey) { + const [y, m, d] = dateKey.split('-').map(Number); + return new Date(y, m - 1, d).getTime(); +} + +/** Timestamp of 'HH:MM' local time on `dateKey`. */ +export function atLocalTime(dateKey, hhmm) { + const [y, m, d] = dateKey.split('-').map(Number); + const [h, min] = hhmm.split(':').map(Number); + return new Date(y, m - 1, d, h, min, 0, 0).getTime(); +} + +/** `dateKey` shifted by `n` days, DST-safe. */ +export function addDays(dateKey, n) { + const [y, m, d] = dateKey.split('-').map(Number); + return localDateKey(new Date(y, m - 1, d + n).getTime()); +} + +/** JS weekday for a date key: 0 = Sunday … 6 = Saturday. */ +export function weekday(dateKey) { + return new Date(startOfDay(dateKey)).getDay(); +} + +/** + * Round to the nearest `minutes`, half up, relative to *local midnight* rather + * than the epoch — so zones with a :30 or :45 offset still land on whole + * wall-clock steps. Always drops seconds. + */ +export function roundTs(ts, minutes) { + const step = Math.max(1, minutes || 1) * MINUTE; + const midnight = startOfDay(localDateKey(ts)); + return midnight + Math.round((ts - midnight) / step) * step; +} + +/** 'HH:MM' in local time, rounded to `roundMinutes`. */ +export function fmtTime(ts, roundMinutes = 1) { + const d = new Date(roundTs(ts, roundMinutes)); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** A duration as '8h15' / '0h45'. */ +export function fmtDuration(ms) { + const total = Math.max(0, Math.round(ms / MINUTE)); + return `${Math.floor(total / 60)}h${pad(total % 60)}`; +} + +/** 'vendredi 5 septembre' — the resume heading. */ +export function fmtDayLabel(dateKey) { + const s = new Intl.DateTimeFormat('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + }).format(new Date(startOfDay(dateKey))); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** 'HH:MM' → minutes since midnight. */ +export function hhmmToMinutes(hhmm) { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} diff --git a/src/lib/time.test.js b/src/lib/time.test.js new file mode 100644 index 0000000..3ca19dd --- /dev/null +++ b/src/lib/time.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + addDays, + atLocalTime, + fmtDuration, + fmtTime, + HOUR, + localDateKey, + MINUTE, + roundTs, + weekday, +} from './time.js'; + +describe('localDateKey', () => { + it('uses local components, not UTC', () => { + // The prototype bug: 00:30 local in a positive-offset zone serialises to the + // previous day under toISOString(). + const ts = new Date(2026, 8, 7, 0, 30).getTime(); + expect(localDateKey(ts)).toBe('2026-09-07'); + }); + + it('holds at both ends of the day', () => { + expect(localDateKey(new Date(2026, 8, 6, 0, 0, 0).getTime())).toBe('2026-09-06'); + expect(localDateKey(new Date(2026, 8, 6, 23, 59, 59).getTime())).toBe('2026-09-06'); + }); +}); + +describe('roundTs', () => { + const at = (h, m, s = 0) => new Date(2026, 8, 6, h, m, s).getTime(); + + it('drops seconds even at 1-minute granularity', () => { + expect(fmtTime(at(8, 14, 59), 1)).toBe('08:15'); + expect(fmtTime(at(8, 14, 29), 1)).toBe('08:14'); + }); + + it.each([ + [1, at(8, 7, 0), '08:07'], + [5, at(8, 7, 0), '08:05'], + [15, at(8, 7, 0), '08:00'], + [30, at(8, 7, 0), '08:00'], + [15, at(8, 38, 0), '08:45'], + [30, at(8, 38, 0), '08:30'], + ])('rounds to the nearest %i min', (step, ts, expected) => { + expect(fmtTime(ts, step)).toBe(expected); + }); + + it('rounds halves up', () => { + expect(fmtTime(at(8, 7, 30), 15)).toBe('08:15'); + expect(fmtTime(at(8, 15, 0), 30)).toBe('08:30'); + }); + + it('never leaves a gap between adjacent entries', () => { + // Adjacent entries share one stored timestamp, so whatever it rounds to, the + // rendered end of one is the rendered start of the next. + const boundary = at(12, 7, 33); + for (const step of [1, 5, 15, 30]) { + expect(roundTs(boundary, step)).toBe(roundTs(boundary, step)); + expect(fmtTime(boundary, step)).toBe(fmtTime(boundary, step)); + } + }); + + it('lands on wall-clock steps regardless of zone offset', () => { + const rounded = roundTs(at(9, 22), 15); + const d = new Date(rounded); + expect(d.getMinutes() % 15).toBe(0); + expect(d.getSeconds()).toBe(0); + }); +}); + +describe('fmtDuration', () => { + it.each([ + [0, '0h00'], + [45 * MINUTE, '0h45'], + [8 * HOUR + 15 * MINUTE, '8h15'], + [HOUR, '1h00'], + ])('formats %i as %s', (ms, expected) => { + expect(fmtDuration(ms)).toBe(expected); + }); + + it('clamps negatives rather than printing nonsense', () => { + expect(fmtDuration(-5000)).toBe('0h00'); + }); +}); + +describe('date arithmetic', () => { + it('adds days across a month boundary', () => { + expect(addDays('2026-08-31', 1)).toBe('2026-09-01'); + expect(addDays('2026-09-01', -1)).toBe('2026-08-31'); + }); + + it('reads weekdays in local time', () => { + expect(weekday('2026-09-06')).toBe(0); // Sunday + expect(weekday('2026-09-07')).toBe(1); // Monday + }); + + it('builds local timestamps from HH:MM', () => { + const ts = atLocalTime('2026-09-07', '07:30'); + const d = new Date(ts); + expect([d.getHours(), d.getMinutes()]).toEqual([7, 30]); + expect(localDateKey(ts)).toBe('2026-09-07'); + }); +}); diff --git a/src/lib/updates.svelte.js b/src/lib/updates.svelte.js new file mode 100644 index 0000000..8e0f09d --- /dev/null +++ b/src/lib/updates.svelte.js @@ -0,0 +1,38 @@ +/** + * Service worker update policy. + * + * The app checks for a new build hourly, but never applies one on its own: the + * waiting worker is only activated when the worker taps "Installer", and that + * banner is only rendered while no day is running (see `App.svelte`). A reload + * mid-day would interrupt input, which is exactly what "non-breaking updates" + * has to rule out. + */ + +import { registerSW } from 'virtual:pwa-register'; + +const CHECK_INTERVAL = 60 * 60 * 1000; + +export const needsRefresh = $state({ value: false }); + +/** @type {(reloadPage?: boolean) => Promise} */ +let update = async () => {}; + +if (typeof window !== 'undefined') { + update = registerSW({ + immediate: true, + onNeedRefresh() { + needsRefresh.value = true; + }, + onRegisteredSW(_url, registration) { + if (!registration) return; + setInterval(() => { + if (navigator.onLine) registration.update().catch(() => {}); + }, CHECK_INTERVAL); + }, + }); +} + +export async function applyUpdate() { + needsRefresh.value = false; + await update(true); +} diff --git a/src/routes/Resume.svelte b/src/routes/Resume.svelte new file mode 100644 index 0000000..9dd26ef --- /dev/null +++ b/src/routes/Resume.svelte @@ -0,0 +1,148 @@ + + +
+ {#if !resume} +
+

Aucune journée enregistrée

+

+ Le récapitulatif de la dernière journée s'affichera ici, prêt à recopier. +

+
+ {:else} +
+

{resume.label}

+
+ +
+ {#each resume.clients as client (client.name)} +
+
+

{client.name}

+ {fmtDuration(client.totalMs)} +
+
    + {#each client.tasks as task (task.name)} +
  • + {task.name} + {fmtDuration(task.ms)} +
  • + {/each} +
+
+ {/each} + + {#if resume.lunch} +
+
+ Déjeuner : {resume.lunch.type === 'restaurant' + ? `restaurant (${resume.lunch.restaurant || 'non précisé'})` + : 'panier'} + {fmtDuration(resume.lunch.ms)} +
+ {/if} + +
+
+ Total travaillé + {fmtDuration(resume.workedMs)} +
+ + {#if resume.omitted} +

+ {resume.omitted} poste{resume.omitted > 1 ? 's' : ''} de moins de {settings.roundingMinutes} + min non affiché{resume.omitted > 1 ? 's' : ''}. +

+ {/if} +
+ + + {/if} + + {#if closedToday} + + {:else if startsAt} +

+ La journée démarrera automatiquement à {startsAt}. +

+ {:else} +

Aujourd'hui n'est pas un jour travaillé.

+ {/if} +
+ + diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte new file mode 100644 index 0000000..b6127e1 --- /dev/null +++ b/src/routes/Settings.svelte @@ -0,0 +1,210 @@ + + +
+

Réglages

+ + {#if settingsError.message} +

{settingsError.message}

+ {/if} + +
+

Jours travaillés

+
+ {#each WEEKDAY_LABELS as day (day.value)} + + {/each} +
+
+ +
+

Horaires

+ + +

+ Les horaires enregistrés dans une journée déjà close ne changent pas. +

+
+ +
+

Pause déjeuner

+ + +
+ + +
+

Type par défaut ; modifiable chaque jour.

+
+ +
+

Arrondi

+
+ {#each ROUNDING_CHOICES as minutes (minutes)} + + {/each} +
+

+ S'applique à l'affichage seulement : les heures réelles restent enregistrées, un + changement d'arrondi corrige aussi les journées passées. +

+
+ +
+

Affichage

+
+ + +
+
+ +
+

Historique

+ +

+ L'historique alimente les suggestions : plus il est court, moins les propositions + sont justes. La dernière journée close est toujours conservée. +

+

+ La purge s'exécute aussi automatiquement à l'ouverture de l'application. +

+ + {#if purged !== null} +

+ {purged === 0 + ? 'Aucune journée plus ancienne que la durée de conservation.' + : `${purged} journée${purged > 1 ? 's' : ''} supprimée${purged > 1 ? 's' : ''}.`} +

+ {/if} +
+
+ + diff --git a/src/routes/Today.svelte b/src/routes/Today.svelte new file mode 100644 index 0000000..b4bed39 --- /dev/null +++ b/src/routes/Today.svelte @@ -0,0 +1,293 @@ + + +{#if !dayState.day} +
+
+

Journée non commencée

+

+ Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte. +

+ +
+
+{:else} +
+ + {#if current} +
+
+

En cours

+ {fmtDuration(now - current.startTs)} +
+ + e.seq === current.seq - 1)?.startTs ?? + Number.NEGATIVE_INFINITY} + max={now} + rounding={settings.roundingMinutes} + onchange={(ts) => editEntryStart(current.id, ts)} + /> + + {#if current.kind === 'lunch'} +
+ + +
+ {#if dayState.day.lunchType === 'restaurant'} + setLunch({ lunchType: 'restaurant', restaurant: v })} + /> + {/if} + {:else} + editEntry(current.id, { task: v })} + /> + editEntry(current.id, { client: v })} + /> + {/if} +
+ {/if} + + + + + {#if current?.kind !== 'lunch'} + + {/if} + + + {#if dayState.entries.length > 1} +
+

Journée

+

Touchez un poste pour corriger l'heure, la tâche ou le client.

+ (editingId = e.id)} + /> +
+ {/if} + + +
+ {#if confirmingEnd} +
+ + +
+ {:else} + + {/if} +
+
+ + (editingId = null)} + /> + + + {#if dayState.needsClosePrompt} + + {/if} +{/if} + + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..cb469db --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// +/// + +interface Window { + timelog?: Record; +} diff --git a/vite.config.js b/vite.config.js index 887306b..02f60df 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,33 +1,53 @@ -import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' -import { VitePWA } from 'vite-plugin-pwa' +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; +import { VitePWA } from 'vite-plugin-pwa'; -// https://vite.dev/config/ export default defineConfig({ server: { - host: '0.0.0.0' + host: '0.0.0.0', }, plugins: [ svelte(), VitePWA({ - registerType: 'autoUpdate', + // Never `autoUpdate`: a worker swap mid-day reloads the page under the + // user's hands. The app decides when to apply an update (see + // src/lib/updates.svelte.js) and only offers it while no day is running. + registerType: 'prompt', + pwaAssets: { + image: 'public/icon.svg', + }, manifest: { - name: 'TimeLog PWA', + name: 'TimeLog — suivi des heures', short_name: 'TimeLog', - theme_color: '#ffffff', - icons: [ - { - src: 'pwa-192x192.png', - sizes: '192x192', - type: 'image/png' - }, - { - src: 'pwa-512x512.png', - sizes: '512x512', - type: 'image/png' - } - ] - } - }) + description: + "Suivi des heures de chantier hors ligne, avec récapitulatif de la dernière journée.", + lang: 'fr', + dir: 'ltr', + start_url: '/', + scope: '/', + display: 'standalone', + orientation: 'portrait', + background_color: '#ffffff', + theme_color: '#0047b3', + categories: ['productivity', 'utilities'], + }, + workbox: { + // The whole app is a handful of files; precache it all so a cold start + // offline is identical to a warm one. + // + // `webmanifest` is deliberately NOT in this list: the plugin injects its + // own manifest entry, and a glob that also matches it produces two + // precache entries for the same URL with different revisions. Workbox + // rejects conflicting entries, the install handler throws, and nothing + // gets cached at all — the app silently stops working offline. + globPatterns: ['**/*.{js,css,html,svg,png,ico}'], + cleanupOutdatedCaches: true, + clientsClaim: false, + }, + }), ], -}) + test: { + include: ['src/**/*.test.js'], + environment: 'node', + }, +});