diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37cbcd8 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_TIMER_NOTIFICATION_API_URL=https://visual-timer-notifications.do0ori.workers.dev +VITE_SENTRY_DSN= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fd34dc6..ab2d85b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,7 @@ jobs: run: npm run build env: VITE_SENTRY_DSN: ${{ secrets.REACT_APP_SENTRY_DSN }} + VITE_TIMER_NOTIFICATION_API_URL: ${{ secrets.VITE_TIMER_NOTIFICATION_API_URL }} - name: Deploy to GitHub Pages if: github.ref == 'refs/heads/main' diff --git a/CHANGELOG.md b/CHANGELOG.md index 5048d7b..d26d5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to Mellow Visual Timer are documented in this file. +## [0.5.0] - 2026-08-22 + +### Added + +- Reliable background completion alerts through Web Push and a Cloudflare Durable Object scheduler. +- A Background alerts control in Timer & Sound settings, including platform guidance for installed iPhone and iPad PWAs. +- A single, non-intrusive running-status notification while a timer continues in the background. +- A Test alert action for confirming that background notifications work on the current device. + +### Changed + +- Enabled Cloudflare Worker observability for timer-scheduling requests and Durable Object failures. + +### Fixed + +- Persisted uploaded alarm audio and made its preview controllable and removable from settings. +- Derived timer state from an absolute end time so timer completion remains accurate after background throttling. +- Made pause, reset, and foreground return cancel or refresh the background alert correctly. +- Request notification permission from the first manual timer start and deliver running-status messages before the page is controlled by the service worker. +- Restored the negative elapsed-time display after a timer completes without retriggering completion handling. +- Registered the development service worker with its app-shell fallback so local notification testing works. +- Created a Push subscription when notification permission was granted before background alerts were enabled. +- Tightened the spacing of links in the About & Developer settings tab. + ## [0.4.1] - 2026-08-20 ### Fixed diff --git a/README.md b/README.md index 41b693d..e564ce0 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,15 @@ npm install ``` -3. Start the development server: +3. Copy `.env.example` to `.env` and set `VITE_TIMER_NOTIFICATION_API_URL` to enable background alert testing locally. + +4. Start the development server: ```bash npm run dev ``` -4. Create a production build or preview it locally: +5. Create a production build or preview it locally: ```bash npm run build @@ -172,6 +174,7 @@ Feel free to open [issues](https://github.com/do0ori/visual-timer/issues) for bu ## Release History +- **v0.5.0:** Reliable background timer alerts through Web Push, persistent controllable custom alarm audio, and accurate end-time-based countdowns. - **v0.4.1:** Stoppable alarm previews, app-theme-driven editor surfaces, wider desktop timer cards, and an aligned Routine Timer interactive dial. - **v0.4.0:** A streamlined Routine Timer editing flow with clearer step creation, inline editing, reordering, deletion, context-aware timer creation, and compact timer cards. - **v0.3.0:** Vite migration, focus stats, custom alarm audio, unified settings, updated themes, and version information. diff --git a/docs/superpowers/plans/2026-08-22-reliable-background-timer-notifications.md b/docs/superpowers/plans/2026-08-22-reliable-background-timer-notifications.md new file mode 100644 index 0000000..140e329 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-reliable-background-timer-notifications.md @@ -0,0 +1,280 @@ +# Reliable Background Timer Notifications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reliably notify a user when a timer ends while the PWA is backgrounded or closed, without relying on browser timer execution. + +**Architecture:** The React PWA owns foreground rendering from an absolute `endAt` timestamp. A Cloudflare Worker and one SQLite-backed Durable Object per schedule own the durable deadline and send one VAPID-authenticated Web Push completion notification. The existing service worker becomes a Push notification renderer and client-navigation bridge, not a scheduler. + +**Tech Stack:** React 18, TypeScript, Zustand, Vite PWA/Workbox, Cloudflare Workers, Durable Objects, Web Push/VAPID. + +**Spec:** `docs/superpowers/specs/2026-08-22-reliable-background-timer-notifications-design.md` + +## Global Constraints + +- Keep the React app deployed on GitHub Pages at `https://do0ori.github.io/visual-timer`. +- Use Cloudflare Worker secrets for VAPID private material; never commit secrets. +- Support Web Push progressively; foreground timers remain usable without Push support or permission. +- Do not claim custom uploaded audio plays while the app is hidden or closed. +- Use `endAt` as the sole clock source outside visible rendering. + +--- + +### Task 1: Create and test the Cloudflare scheduling service + +**Files:** + +- Create: `workers/timer-notifications/src/index.ts` +- Create: `workers/timer-notifications/src/timer-schedule.ts` +- Create: `workers/timer-notifications/src/types.ts` +- Create: `workers/timer-notifications/wrangler.jsonc` +- Create: `workers/timer-notifications/package.json` +- Create: `workers/timer-notifications/test/timer-schedule.test.ts` + +**Interfaces:** + +- Produces `TimerSchedule` Durable Object with `fetch()` routes for create, lease refresh, cancellation, and `alarm()` delivery. +- Produces worker routes `GET /v1/push/public-key` and `/v1/schedules/:id`. + +- [ ] **Step 1: Write failing Durable Object tests** + +Test creation schedules an alarm at `endAt`, cancellation deletes the alarm, a valid visible lease defers completion by five seconds, and an expired lease sends one Push payload. + +- [ ] **Step 2: Run the Worker test command and verify expected failures** + +Run: `npm test --workspace workers/timer-notifications` + +Expected: failing imports because `TimerSchedule` and routes do not exist. + +- [ ] **Step 3: Implement the minimal durable schedule** + +Create `TimerSchedule` with SQLite-backed state `{ capability, endAt, title, deepLink, subscription, visibleUntil, status }`. Validate a future `endAt` no more than 24 hours away. Call `this.ctx.storage.setAlarm(endAt)`. In `alarm()`, defer five seconds while `visibleUntil > Date.now()`; otherwise atomically mark the schedule delivered, send its encrypted Push, and remove invalid subscriptions for HTTP 404/410 responses. + +- [ ] **Step 4: Add worker routing and CORS** + +Route exact production and localhost origins only. Bind `TIMER_SCHEDULE` Durable Object. Return the public VAPID key without exposing private credentials. Require matching capability tokens for `PUT`, `PATCH`, and `DELETE` operations. + +- [ ] **Step 5: Run the Worker tests and typecheck** + +Run: `npm test --workspace workers/timer-notifications && npm run typecheck --workspace workers/timer-notifications` + +Expected: all schedule state, cancellation, lease, and invalid-subscription cases pass. + +- [ ] **Step 6: Commit** + +```bash +git add workers/timer-notifications +git commit -m "feat: Add durable timer push scheduling" +``` + +### Task 2: Add browser Push subscription and schedule client + +**Files:** + +- Create: `src/services/timerNotificationService.ts` +- Create: `src/services/timerNotificationService.test.ts` +- Modify: `src/store/settingsStore.ts` +- Modify: `src/index.tsx` + +**Interfaces:** + +- Consumes `GET /v1/push/public-key` and schedule mutation routes from Task 1. +- Produces `ensurePushSubscription()`, `scheduleTimer()`, `renewVisibleLease()`, and `cancelTimerSchedule()`. + +- [ ] **Step 1: Write failing client service tests** + +Verify URL-safe VAPID public-key conversion, unsupported API handling, a user-initiated permission request, subscription serialization, and cancellation requests containing schedule ID plus capability. + +- [ ] **Step 2: Run the client test and verify expected failures** + +Run: `npm.cmd test -- --runTestsByPath src/services/timerNotificationService.test.ts` + +Expected: failing import because the notification service does not exist. + +- [ ] **Step 3: Implement the client service** + +Wait for `navigator.serviceWorker.ready`, request permission only from the explicit settings action, call `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })`, persist the opaque schedule credentials locally, and call the Worker API with `fetch` plus the production API base URL from `VITE_TIMER_NOTIFICATION_API_URL`. + +- [ ] **Step 4: Add persisted notification capability state** + +Extend settings with permission/support status only. Do not persist the Push endpoint in Zustand; retrieve current browser subscription through `pushManager.getSubscription()` when scheduling. + +- [ ] **Step 5: Run service tests and the existing suite** + +Run: `npm.cmd test -- --runTestsByPath src/services/timerNotificationService.test.ts && npm.cmd test` + +Expected: client service tests and all existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/services src/store/settingsStore.ts src/index.tsx +git commit -m "feat: Add browser push scheduling client" +``` + +### Task 3: Convert timer state to absolute deadline reconciliation + +**Files:** + +- Modify: `src/hooks/useTimer.ts` +- Create: `src/hooks/useTimer.test.ts` +- Modify: `src/utils/timerHandler.ts` + +**Interfaces:** + +- Consumes `scheduleTimer`, `renewVisibleLease`, and `cancelTimerSchedule` from Task 2. +- Produces timer controller behavior derived from `endAt` while running. + +- [ ] **Step 1: Write failing timer tests** + +Test that hidden-to-visible reconciliation derives remaining count from an `endAt` timestamp, a completed deadline calls `onFinish` once, and pause/reset cancels the associated remote schedule. + +- [ ] **Step 2: Run the hook test and verify expected failures** + +Run: `npm.cmd test -- --runTestsByPath src/hooks/useTimer.test.ts` + +Expected: failures because the hook still stores elapsed interval state and posts direct service-worker timer commands. + +- [ ] **Step 3: Implement absolute-deadline behavior** + +Set `endAt` when `start()` runs. While visible, calculate `count` from `Math.ceil((endAt - Date.now()) / intervalMs)`. On hidden, schedule the remote deadline and show a running-status notification. On visible, close the running-status notification, reconcile count from `endAt`, and renew a visible lease every five seconds. Cancel the remote schedule on pause, reset, input changes, and completion. + +- [ ] **Step 4: Preserve exactly-once completion behavior** + +Retain `finishTriggeredRef`; after reconciliation reaches zero, invoke `onFinish` once, cancel the schedule, and leave existing foreground audio/modal behavior in `timerHandler.ts` intact. + +- [ ] **Step 5: Run hook tests and the full application suite** + +Run: `npm.cmd test -- --runTestsByPath src/hooks/useTimer.test.ts && npm.cmd test` + +Expected: deadline, visibility, and cancellation regressions pass; existing tests remain green. + +- [ ] **Step 6: Commit** + +```bash +git add src/hooks/useTimer.ts src/hooks/useTimer.test.ts src/utils/timerHandler.ts +git commit -m "fix: Reconcile timers from absolute deadlines" +``` + +### Task 4: Render Push and running-status notifications in the service worker + +**Files:** + +- Modify: `src/service-worker.ts` +- Create: `src/service-worker.test.ts` + +**Interfaces:** + +- Consumes `timer-finished` Push payloads from Task 1. +- Consumes `show-running-status` and `clear-running-status` messages from Task 3. +- Produces one tagged running-status notification and one tagged completion notification per schedule. + +- [ ] **Step 1: Write failing service-worker tests** + +Test that a `timer-finished` Push event calls `showNotification()` with `tag: timerId`, completion body, timestamp, and deep-link data; test that visible-status clear messages close only their matching tag. + +- [ ] **Step 2: Run the service-worker test and verify expected failures** + +Run: `npm.cmd test -- --runTestsByPath src/service-worker.test.ts` + +Expected: failures because the worker currently depends on in-memory interval handles rather than Push payloads. + +- [ ] **Step 3: Replace in-memory scheduler logic** + +Remove `activeTimers`, `setTimeout`, and `setInterval`. Add a `push` listener that parses the completion payload and wraps `showNotification()` in `event.waitUntil()`. Keep notification click behavior, carrying `deepLink` in notification data. Add message handlers that render or close the single running-status notification. + +- [ ] **Step 4: Run service-worker and full tests, then build** + +Run: `npm.cmd test && npm.cmd run build` + +Expected: all tests pass and the PWA worker builds successfully. + +- [ ] **Step 5: Commit** + +```bash +git add src/service-worker.ts src/service-worker.test.ts +git commit -m "feat: Handle timer push notifications" +``` + +### Task 5: Add user-facing notification controls and platform guidance + +**Files:** + +- Modify: `src/components/settings/sections/AlarmSettings.tsx` +- Create: `src/components/settings/sections/AlarmSettings.test.tsx` +- Modify: `README.md` + +**Interfaces:** + +- Consumes notification support and permission state from Task 2. +- Produces an explicit “Enable background alerts” action and platform-specific status text. + +- [ ] **Step 1: Write failing settings tests** + +Test that the enable action appears when permission is `default`, shows an enabled state for `granted`, and displays iOS Home Screen installation guidance when Push APIs are unavailable. + +- [ ] **Step 2: Run the settings test and verify expected failures** + +Run: `npm.cmd test -- --runTestsByPath src/components/settings/sections/AlarmSettings.test.tsx` + +Expected: failures because no explicit Push permission control exists. + +- [ ] **Step 3: Implement the settings control and documentation** + +Add a user-initiated permission button, supported/denied states, and iOS Home Screen guidance. Document that background completion uses OS notifications and that custom uploaded audio is foreground-only. + +- [ ] **Step 4: Run final verification** + +Run: `npm.cmd test && npm.cmd run build && git diff --check` + +Expected: all tests and build pass with no whitespace errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/settings/sections/AlarmSettings.tsx src/components/settings/sections/AlarmSettings.test.tsx README.md +git commit -m "feat: Add background alert controls" +``` + +### Task 6: Provision secrets and verify production behavior + +**Files:** + +- Modify: `workers/timer-notifications/wrangler.jsonc` +- Create: `workers/timer-notifications/.dev.vars.example` + +**Interfaces:** + +- Requires a user-approved Cloudflare interactive login. +- Requires `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT` secrets. + +- [ ] **Step 1: Generate VAPID keys locally** + +Run: `npx web-push generate-vapid-keys` + +Keep the private key out of source control. Add only key names to `.dev.vars.example`. + +- [ ] **Step 2: Authenticate and create Cloudflare bindings** + +Run: `npx wrangler login`, then create the Worker and SQLite-backed Durable Object binding defined in `wrangler.jsonc`. + +- [ ] **Step 3: Upload secrets interactively** + +Run `npx wrangler secret put` separately for `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT`. + +- [ ] **Step 4: Deploy and record the API origin** + +Run: `npx wrangler deploy` + +Set `VITE_TIMER_NOTIFICATION_API_URL` to the deployed Worker URL through the GitHub Pages build configuration. + +- [ ] **Step 5: Test supported production paths** + +Verify on desktop Chromium, Android installed PWA, and iOS/iPadOS Home Screen PWA: permission grant, one hidden running-status notification, one completion notification, notification click navigation, foreground deduplication, pause/reset cancellation, and expired-subscription recovery. + +- [ ] **Step 6: Commit configuration only** + +```bash +git add workers/timer-notifications/wrangler.jsonc workers/timer-notifications/.dev.vars.example +git commit -m "chore: Configure timer notification deployment" +``` diff --git a/docs/superpowers/specs/2026-08-22-reliable-background-timer-notifications-design.md b/docs/superpowers/specs/2026-08-22-reliable-background-timer-notifications-design.md new file mode 100644 index 0000000..e378843 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-reliable-background-timer-notifications-design.md @@ -0,0 +1,64 @@ +# Reliable Background Timer Notifications Design + +## Goal + +Deliver one OS-level completion notification for a running timer even when the PWA is backgrounded or closed, while keeping the visible timer accurate from an absolute end timestamp. + +## Scope + +- Support desktop browsers, Android PWAs, and iOS/iPadOS Home Screen PWAs that support Web Push and have granted notification permission. +- Replace the service worker's in-memory `setTimeout` and one-second notification interval as the background scheduler. +- Retain the existing foreground timer UI, selected alarm sound, and completion modal. + +## Non-goals + +- Do not guarantee custom audio playback while the PWA is backgrounded or closed. +- Do not update a background notification body every second. +- Do not add accounts, cross-device timer sync, or a native mobile application. + +## Architecture + +The React client remains hosted on GitHub Pages. A Cloudflare Worker exposes a small CORS-restricted scheduling API and binds one Durable Object per timer schedule. The client stores an absolute `endAt` timestamp locally, registers its Push subscription with the API, and creates, updates, or cancels a schedule as timer state changes. + +Each schedule Durable Object stores the subscription, title, deep link, end timestamp, and state in SQLite-backed Durable Object storage. It sets one Durable Object Alarm at `endAt`. When the alarm fires, it sends an encrypted Web Push completion payload using VAPID credentials stored as Cloudflare secrets. The service worker receives that payload and displays a persistent OS notification. Invalid subscriptions are deleted after 404 or 410 responses. + +## Foreground and background behavior + +| State | Client behavior | Background notification behavior | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Visible and running | Render `endAt - Date.now()` every second and play the selected sound on completion. | No running-status notification. The server schedule remains as a fallback guarded by a short visible lease. | +| Hidden and running | Stop visual updates, preserve `endAt`, and refresh the server schedule. | Show one `Timer running — ends at HH:mm` status notification. | +| Visible before completion | Recalculate from `endAt`, close the running-status notification, and refresh a 15-second visible lease every 5 seconds. | The Durable Object defers its alarm by five seconds while a valid lease exists, avoiding a duplicate OS completion notification. | +| Complete | Persist completed state, cancel the Durable Object alarm, close status notification, and run the existing foreground completion UI if visible. | If hidden or no valid lease, send exactly one completion Push. | + +The five-second deferral is a deliberate reliability trade-off: a recently visible page gets a short opportunity to complete locally; if it disappears or freezes, the lease expires and the server sends the Push. + +## API contract + +- `GET /v1/push/public-key` returns `{ publicKey }`. +- `PUT /v1/schedules/:id` accepts `{ capability, endAt, title, deepLink, subscription, visibleUntil }` and creates or replaces that schedule. +- `PATCH /v1/schedules/:id` accepts `{ capability, visibleUntil }` to renew the visible lease. +- `DELETE /v1/schedules/:id` accepts the capability token and cancels the alarm plus stored schedule. + +The client creates an opaque UUID schedule ID and a separate opaque capability token, stores both only in local browser storage, and sends the capability with every mutation. The API permits only the production origin and local development origin through CORS. Schedules reject past end times and are capped at 24 hours. + +## Failure behavior + +- If Push is unsupported or permission is denied, the timer remains fully functional in foreground and the settings UI explains that background completion alerts are unavailable. +- If scheduling fails while hidden, retain `endAt`, retry on the next visibility change, and report a non-blocking in-app warning while visible. +- If the Push endpoint returns 404 or 410, delete the subscription and require a new user-initiated subscription. +- If a notification is clicked, focus an existing timer client or open the app deep link; the client reconciles from `endAt` and completion state. + +## Security and privacy + +- Keep VAPID private key and contact subject only in Cloudflare Worker secrets. +- Persist only timer title, end timestamp, deep link, and encrypted Push subscription metadata; do not upload custom audio or timer history. +- Use capability tokens to prevent unrelated clients from modifying a schedule. + +## Acceptance criteria + +1. A hidden or closed supported PWA receives one completion OS notification near the deadline without requiring the user to refocus it. +2. Foreground completion retains the existing sound and modal and does not show a duplicate OS completion notification. +3. Returning to the app always derives the remaining or elapsed state from `endAt`; it never resumes from a stale interval count. +4. A running-status notification shows an end time while the PWA is hidden, then closes on foreground return, reset, pause, or completion. +5. Android and desktop support standard Web Push. iOS/iPadOS instructions require Home Screen installation and a user-initiated permission grant. diff --git a/jest.config.cjs b/jest.config.cjs index 897c6a5..d582395 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,4 +1,4 @@ module.exports = { - testEnvironment: 'jsdom', - testMatch: ['/src/**/*.test.ts'], + testEnvironment: 'jsdom', + testMatch: ['/src/**/*.test.ts', '/workers/**/*.test.ts'], }; diff --git a/package-lock.json b/package-lock.json index da96d8a..3c4965e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "visual-timer", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "visual-timer", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@headlessui/react": "^2.2.0", "@hello-pangea/dnd": "^17.0.0", @@ -20,15 +20,18 @@ "react-router-dom": "^7.18.2", "sweetalert2": "^11.17.2", "usehooks-ts": "^3.1.0", + "web-push": "^3.6.7", "zustand": "^5.0.3" }, "devDependencies": { "@babel/preset-env": "^7.29.2", "@babel/preset-typescript": "^7.28.5", + "@cloudflare/workers-types": "^5.20260822.1", "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.13.4", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "babel-jest": "^27.5.1", @@ -46,7 +49,8 @@ "workbox-precaching": "^7.3.0", "workbox-routing": "^7.3.0", "workbox-strategies": "^7.3.0", - "workbox-window": "^7.3.0" + "workbox-window": "^7.3.0", + "wrangler": "^4.125.0" } }, "node_modules/@alloc/quick-lru": { @@ -1833,6 +1837,159 @@ "dev": true, "license": "MIT" }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260820.1.tgz", + "integrity": "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260820.1.tgz", + "integrity": "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260820.1.tgz", + "integrity": "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260820.1.tgz", + "integrity": "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260820.1.tgz", + "integrity": "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260822.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260822.1.tgz", + "integrity": "sha512-z922wN0pEWwYaAcYdncSf11fTPfj2j1Q2n2LCLbtBBhpkIu/aC8fotol5q4ek4isWgTKfWzUx389Dsa+lhF6yw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/runtime": { + "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, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2350,137 +2507,664 @@ "react-dom": "^18.0.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" } }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@img/sharp-wasm32": "0.35.2" }, "engines": { - "node": ">=12" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jest/console": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", - "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^27.5.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^27.5.1", - "jest-util": "^27.5.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jest/core": { - "version": "27.5.1", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "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.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "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.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "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.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "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.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "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.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", "dev": true, @@ -2848,6 +3532,58 @@ "node": ">=14" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/colors/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, "node_modules/@react-aria/focus": { "version": "3.19.0", "license": "Apache-2.0", @@ -3377,6 +4113,19 @@ "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", @@ -3397,6 +4146,13 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "license": "Apache-2.0", @@ -3596,6 +4352,16 @@ "version": "0.0.6", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "16.0.11", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", @@ -3882,6 +4648,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "dev": true, @@ -4150,6 +4928,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -4242,6 +5033,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "dev": true, @@ -4763,7 +5560,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4841,6 +5637,16 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -4913,6 +5719,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "dev": true, @@ -4973,6 +5788,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/es-abstract-get": { "version": "1.0.0", "dev": true, @@ -5803,6 +6628,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -5906,7 +6740,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -7296,6 +8129,27 @@ "node": ">=0.10.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7779,6 +8633,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/miniflare": { + "version": "5.20260820.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260820.0-alpha.tgz", + "integrity": "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260820.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7792,6 +8692,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "dev": true, @@ -7802,7 +8711,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -8108,7 +9016,21 @@ "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.4.3", "dev": true, - "license": "ISC" + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -9033,6 +9955,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "dev": true, @@ -9068,7 +10010,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -9150,6 +10091,64 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/sharp/node_modules/semver": { + "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/shebang-command": { "version": "2.0.0", "dev": true, @@ -10257,11 +11256,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "dev": true, "license": "MIT" }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, "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", @@ -10818,7 +11837,212 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vite-plugin-pwa/node_modules/rollup": { + "node_modules/vite-plugin-pwa/node_modules/rollup": { + "version": "4.62.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-pwa/node_modules/source-map": { + "version": "0.8.0", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-background-sync": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-build": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-range-requests": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-recipes": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-streams": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/vite-plugin-pwa/node_modules/workbox-sw": { + "version": "7.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/rollup": { "version": "4.62.4", "dev": true, "license": "MIT", @@ -10862,106 +12086,264 @@ "fsevents": "~2.3.2" } }, - "node_modules/vite-plugin-pwa/node_modules/source-map": { - "version": "0.8.0", + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^3.0.0" + }, "engines": { - "node": ">= 12" + "node": ">=10" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-background-sync": { - "version": "7.4.1", + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "idb": "^7.0.1", - "workbox-core": "7.4.1" + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-broadcast-update": { - "version": "7.4.1", + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10.4" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.1" + "iconv-lite": "0.4.24" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-build": { - "version": "7.4.1", + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, "license": "MIT", "dependencies": { - "@apideck/better-ajv-errors": "^0.3.1", - "@babel/core": "^7.24.4", - "@babel/preset-env": "^7.11.0", - "@babel/runtime": "^7.11.2", - "@rollup/plugin-babel": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-replace": "^6.0.3", - "@rollup/plugin-terser": "^1.0.0", - "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", - "ajv": "^8.6.0", - "common-tags": "^1.8.0", - "eta": "^4.5.1", - "fast-json-stable-stringify": "^2.1.0", - "fs-extra": "^9.0.1", - "glob": "^11.0.1", - "pretty-bytes": "^5.3.0", - "rollup": "^4.53.3", - "source-map": "^0.8.0-beta.0", - "stringify-object": "^3.3.0", - "strip-comments": "^2.0.1", - "tempy": "^0.6.0", - "upath": "^1.2.0", - "workbox-background-sync": "7.4.1", - "workbox-broadcast-update": "7.4.1", - "workbox-cacheable-response": "7.4.1", - "workbox-core": "7.4.1", - "workbox-expiration": "7.4.1", - "workbox-google-analytics": "7.4.1", - "workbox-navigation-preload": "7.4.1", - "workbox-precaching": "7.4.1", - "workbox-range-requests": "7.4.1", - "workbox-recipes": "7.4.1", - "workbox-routing": "7.4.1", - "workbox-strategies": "7.4.1", - "workbox-streams": "7.4.1", - "workbox-sw": "7.4.1", - "workbox-window": "7.4.1" + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=10" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-build/node_modules/pretty-bytes": { - "version": "5.6.0", + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", "dev": true, "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-cacheable-response": { + "node_modules/which-builtin-type": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { "version": "7.4.1", "dev": true, "license": "MIT", "dependencies": { + "idb": "^7.0.1", "workbox-core": "7.4.1" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-navigation-preload": { + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-google-analytics/node_modules/workbox-background-sync": { "version": "7.4.1", "dev": true, "license": "MIT", "dependencies": { + "idb": "^7.0.1", "workbox-core": "7.4.1" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-range-requests": { + "node_modules/workbox-precaching": { + "version": "7.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { "version": "7.4.1", "dev": true, "license": "MIT", @@ -10969,343 +12351,562 @@ "workbox-core": "7.4.1" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-recipes": { + "node_modules/workbox-strategies": { "version": "7.4.1", "dev": true, "license": "MIT", "dependencies": { - "workbox-cacheable-response": "7.4.1", - "workbox-core": "7.4.1", - "workbox-expiration": "7.4.1", - "workbox-precaching": "7.4.1", - "workbox-routing": "7.4.1", - "workbox-strategies": "7.4.1" + "workbox-core": "7.4.1" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-streams": { + "node_modules/workbox-window": { "version": "7.4.1", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.1", - "workbox-routing": "7.4.1" + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" } }, - "node_modules/vite-plugin-pwa/node_modules/workbox-sw": { - "version": "7.4.1", + "node_modules/workerd": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260820.1.tgz", + "integrity": "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w==", "dev": true, - "license": "MIT" + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260820.1", + "@cloudflare/workerd-darwin-arm64": "1.20260820.1", + "@cloudflare/workerd-linux-64": "1.20260820.1", + "@cloudflare/workerd-linux-arm64": "1.20260820.1", + "@cloudflare/workerd-windows-64": "1.20260820.1" + } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", + "node_modules/wrangler": { + "version": "4.125.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.125.0.tgz", + "integrity": "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==", "dev": true, - "license": "MIT", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260820.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260820.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, "engines": { - "node": ">=12.0.0" + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "@cloudflare/workers-types": "^5.20260820.1" }, "peerDependenciesMeta": { - "picomatch": { + "@cloudflare/workers-types": { "optional": true } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=18" } }, - "node_modules/vite/node_modules/rollup": { - "version": "4.62.4", + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.62.4", - "@rollup/rollup-android-arm64": "4.62.4", - "@rollup/rollup-darwin-arm64": "4.62.4", - "@rollup/rollup-darwin-x64": "4.62.4", - "@rollup/rollup-freebsd-arm64": "4.62.4", - "@rollup/rollup-freebsd-x64": "4.62.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", - "@rollup/rollup-linux-arm-musleabihf": "4.62.4", - "@rollup/rollup-linux-arm64-gnu": "4.62.4", - "@rollup/rollup-linux-arm64-musl": "4.62.4", - "@rollup/rollup-linux-loong64-gnu": "4.62.4", - "@rollup/rollup-linux-loong64-musl": "4.62.4", - "@rollup/rollup-linux-ppc64-gnu": "4.62.4", - "@rollup/rollup-linux-ppc64-musl": "4.62.4", - "@rollup/rollup-linux-riscv64-gnu": "4.62.4", - "@rollup/rollup-linux-riscv64-musl": "4.62.4", - "@rollup/rollup-linux-s390x-gnu": "4.62.4", - "@rollup/rollup-linux-x64-gnu": "4.62.4", - "@rollup/rollup-linux-x64-musl": "4.62.4", - "@rollup/rollup-openbsd-x64": "4.62.4", - "@rollup/rollup-openharmony-arm64": "4.62.4", - "@rollup/rollup-win32-arm64-msvc": "4.62.4", - "@rollup/rollup-win32-ia32-msvc": "4.62.4", - "@rollup/rollup-win32-x64-gnu": "4.62.4", - "@rollup/rollup-win32-x64-msvc": "4.62.4", - "fsevents": "~2.3.2" + "node": ">=18" } }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "browser-process-hrtime": "^1.0.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "xml-name-validator": "^3.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10.4" + "node": ">=18" } }, - "node_modules/whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "iconv-lite": "0.4.24" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/which": { - "version": "2.0.2", + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/which-builtin-type": { - "version": "1.2.1", + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/which-collection": { - "version": "1.0.2", + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/which-typed-array": { - "version": "1.1.22", + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/workbox-core": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/workbox-expiration": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "idb": "^7.0.1", - "workbox-core": "7.4.1" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workbox-google-analytics": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "workbox-background-sync": "7.4.1", - "workbox-core": "7.4.1", - "workbox-routing": "7.4.1", - "workbox-strategies": "7.4.1" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workbox-google-analytics/node_modules/workbox-background-sync": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "idb": "^7.0.1", - "workbox-core": "7.4.1" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workbox-precaching": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "workbox-core": "7.4.1", - "workbox-routing": "7.4.1", - "workbox-strategies": "7.4.1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workbox-routing": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "workbox-core": "7.4.1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workbox-strategies": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "workbox-core": "7.4.1" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workbox-window": { - "version": "7.4.1", + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@types/trusted-types": "^2.0.2", - "workbox-core": "7.4.1" + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/wrap-ansi": { @@ -11457,6 +13058,31 @@ "node": ">=10" } }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "node_modules/zustand": { "version": "5.0.15", "license": "MIT", diff --git a/package.json b/package.json index 5fe0dfe..1592695 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "visual-timer", - "version": "0.4.1", + "version": "0.5.0", "private": false, "type": "module", "homepage": "https://do0ori.github.io/visual-timer", @@ -27,16 +27,19 @@ "react-router-dom": "^7.18.2", "sweetalert2": "^11.17.2", "usehooks-ts": "^3.1.0", + "web-push": "^3.6.7", "zustand": "^5.0.3" }, "devDependencies": { + "@babel/preset-env": "^7.29.2", + "@babel/preset-typescript": "^7.28.5", + "@cloudflare/workers-types": "^5.20260822.1", "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.13.4", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^4.3.4", - "@babel/preset-env": "^7.29.2", - "@babel/preset-typescript": "^7.28.5", "autoprefixer": "^10.4.20", "babel-jest": "^27.5.1", "jest": "^27.5.1", @@ -53,6 +56,7 @@ "workbox-precaching": "^7.3.0", "workbox-routing": "^7.3.0", "workbox-strategies": "^7.3.0", - "workbox-window": "^7.3.0" + "workbox-window": "^7.3.0", + "wrangler": "^4.125.0" } } diff --git a/src/components/settings/fields/AlarmSelector.tsx b/src/components/settings/fields/AlarmSelector.tsx index c819f05..78907ae 100644 --- a/src/components/settings/fields/AlarmSelector.tsx +++ b/src/components/settings/fields/AlarmSelector.tsx @@ -1,145 +1,159 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { BiSolidBellRing } from 'react-icons/bi'; -import { IoMdCloudUpload, IoMdPlay, IoMdSquare } from 'react-icons/io'; +import { IoMdCloudUpload, IoMdPlay, IoMdSquare, IoMdTrash } from 'react-icons/io'; import { useSettingsStore } from '../../../store/settingsStore'; import { useThemeStore } from '../../../store/themeStore'; +import { createAudioPreviewController } from '../../../utils/audioPreviewController'; import { PRESET_SOUNDS, playSound } from '../../../utils/soundEngine'; import Dropdown from '../../common/Dropdown'; import ListItem from '../../common/ListItem'; const AlarmSelector: React.FC = () => { - const { selectedTheme } = useThemeStore(); - const { selectedAlarm, setSelectedAlarm, volume, mute } = useSettingsStore(); - const [isPlaying, setIsPlaying] = useState(false); - const stopAudioRef = useRef<(() => void) | void>(undefined); - const previewTimeoutRef = useRef | undefined>(undefined); - const fileInputRef = useRef(null); - const [customSoundName, setCustomSoundName] = useState(''); - - const alarmIcon = ; - - const options = [ - ...PRESET_SOUNDS.map((s) => ({ - label: s.label, - value: s.value, - subLabel: s.description, - })), - ...(customSoundName - ? [ - { - label: `📁 ${customSoundName}`, - value: selectedAlarm, - subLabel: 'Custom Uploaded Sound', - }, - ] - : []), - ]; - - const stopPreview = useCallback(() => { - if (stopAudioRef.current) { - stopAudioRef.current(); - stopAudioRef.current = undefined; - } - - if (previewTimeoutRef.current) { - clearTimeout(previewTimeoutRef.current); - previewTimeoutRef.current = undefined; - } - - setIsPlaying(false); - }, []); - - const startPreview = useCallback((soundValue?: string) => { - stopPreview(); - - const soundToPlay = soundValue || selectedAlarm; - const stopFn = playSound(soundToPlay, volume, mute); - stopAudioRef.current = stopFn; - setIsPlaying(Boolean(stopFn)); - - if (stopFn) { - previewTimeoutRef.current = setTimeout(() => { + const { selectedTheme } = useThemeStore(); + const { selectedAlarm, customAlarm, setSelectedAlarm, setCustomAlarm, removeCustomAlarm, volume, mute } = + useSettingsStore(); + const [isPlaying, setIsPlaying] = useState(false); + const previewControllerRef = useRef(createAudioPreviewController()); + const previewTimeoutRef = useRef | undefined>(undefined); + const fileInputRef = useRef(null); + + const alarmIcon = ; + + const options = [ + ...PRESET_SOUNDS.map((s) => ({ + label: s.label, + value: s.value, + subLabel: s.description, + })), + ...(customAlarm + ? [ + { + label: `📁 ${customAlarm.name}`, + value: customAlarm.value, + subLabel: 'Custom Uploaded Sound', + }, + ] + : []), + ]; + + const stopPreview = useCallback(() => { + if (previewTimeoutRef.current) { + clearTimeout(previewTimeoutRef.current); + previewTimeoutRef.current = undefined; + } + + previewControllerRef.current.stop(); + setIsPlaying(false); + }, []); + + const startPreview = useCallback( + (soundValue?: string) => { + stopPreview(); + + const soundToPlay = soundValue || selectedAlarm; + const isPreviewPlaying = previewControllerRef.current.start(() => playSound(soundToPlay, volume, mute)); + setIsPlaying(isPreviewPlaying); + + if (isPreviewPlaying) { + previewTimeoutRef.current = setTimeout(() => { + stopPreview(); + }, 3000); + } + }, + [mute, selectedAlarm, stopPreview, volume] + ); + + useEffect(() => stopPreview, [stopPreview]); + + const handleAlarmChange = (newAlarm: string) => { + setSelectedAlarm(newAlarm); + startPreview(newAlarm); + }; + + const handlePreviewButtonClick = () => { + if (previewControllerRef.current.isPlaying()) { + stopPreview(); + } else { + startPreview(); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result as string; + if (dataUrl) { + setCustomAlarm({ name: file.name, value: dataUrl }); + startPreview(dataUrl); + } + }; + reader.readAsDataURL(file); + }; + + const handleCustomAudioRemoval = () => { stopPreview(); - }, 3000); - } - }, [mute, selectedAlarm, stopPreview, volume]); - - useEffect(() => stopPreview, [stopPreview]); - - const handleAlarmChange = (newAlarm: string) => { - setSelectedAlarm(newAlarm); - startPreview(newAlarm); - }; - - const handlePreviewButtonClick = () => { - if (isPlaying) { - stopPreview(); - } else { - startPreview(); - } - }; - - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (event) => { - const dataUrl = event.target?.result as string; - if (dataUrl) { - setCustomSoundName(file.name); - setSelectedAlarm(dataUrl); - startPreview(dataUrl); - } + removeCustomAlarm(); }; - reader.readAsDataURL(file); - }; - - const alarmSelector = ( -
-
- Alarm Sound -
- {/* Custom Sound Upload Button */} - - - - {/* Sound Preview Play Button */} - + + const alarmSelector = ( +
+
+ Alarm Sound +
+ {/* Custom Sound Upload Button */} + + + + {customAlarm && ( + + )} + + {/* Sound Preview Play Button */} + +
+
+ +
-
- - -
- ); - - return ; + ); + + return ; }; export default AlarmSelector; diff --git a/src/components/settings/fields/DonateField.tsx b/src/components/settings/fields/DonateField.tsx index 2887a12..218deae 100644 --- a/src/components/settings/fields/DonateField.tsx +++ b/src/components/settings/fields/DonateField.tsx @@ -20,7 +20,7 @@ const DonateField: React.FC = () => { href="https://www.paypal.com/paypalme/do0ori" target="_blank" rel="noopener noreferrer" - className="block -m-3 rounded-2xl p-3 cursor-pointer transition-colors hover:bg-black/5 dark:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2" + className="-m-1 block cursor-pointer rounded-2xl p-1 transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" > diff --git a/src/components/settings/fields/FeedbackField.tsx b/src/components/settings/fields/FeedbackField.tsx index 32cbb33..9c7f043 100644 --- a/src/components/settings/fields/FeedbackField.tsx +++ b/src/components/settings/fields/FeedbackField.tsx @@ -19,7 +19,7 @@ const FeedbackField: React.FC = () => { href="https://padlet.com/fuzzydo0ori/visual-timer-feedback-ykjvyrb6887wz6zc" target="_blank" rel="noreferrer" - className="block -m-3 rounded-2xl p-3 cursor-pointer transition-colors hover:bg-black/5 dark:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2" + className="-m-1 block cursor-pointer rounded-2xl p-1 transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" > diff --git a/src/components/settings/fields/VersionField.tsx b/src/components/settings/fields/VersionField.tsx index 1d96369..842c083 100644 --- a/src/components/settings/fields/VersionField.tsx +++ b/src/components/settings/fields/VersionField.tsx @@ -21,7 +21,7 @@ const VersionField: React.FC = () => { href="https://github.com/do0ori/visual-timer/releases" target="_blank" rel="noreferrer" - className="block -m-3 rounded-2xl p-3 cursor-pointer transition-colors hover:bg-black/5 dark:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2" + className="-m-1 block cursor-pointer rounded-2xl p-1 transition-colors hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2 dark:hover:bg-white/10" > diff --git a/src/components/settings/sections/AlarmSettings.tsx b/src/components/settings/sections/AlarmSettings.tsx index a1faf72..0e1187a 100644 --- a/src/components/settings/sections/AlarmSettings.tsx +++ b/src/components/settings/sections/AlarmSettings.tsx @@ -1,11 +1,94 @@ +import { useState } from 'react'; import AlarmSelector from '../fields/AlarmSelector'; import VolumeSelector from '../fields/VolumeSelector'; +import { + enableBackgroundAlerts, + getBackgroundAlertStatus, + getTimerNotificationApiUrl, + type BackgroundAlertStatus, +} from '../../../services/timerNotificationService'; +import { showTestRunningTimerStatus } from '../../../services/timerStatusNotification'; -const AlarmSettings: React.FC = () => ( -
- - -
-); +const alertStatusCopy: Record = { + unsupported: 'Background alerts are not supported in this browser.', + 'needs-permission': 'Enable alerts to be notified when a timer ends in the background.', + enabled: 'Background completion alerts are enabled.', + denied: 'Alerts are blocked. Enable them in this browser or device settings.', +}; + +const AlarmSettings: React.FC = () => { + const [alertStatus, setAlertStatus] = useState(() => getBackgroundAlertStatus()); + const [isEnabling, setIsEnabling] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const isTimerNotificationApiConfigured = Boolean(getTimerNotificationApiUrl()); + + const handleEnableAlerts = async () => { + setIsEnabling(true); + try { + await enableBackgroundAlerts(); + } catch (error) { + console.debug('Unable to enable background alerts:', error); + } finally { + setAlertStatus(getBackgroundAlertStatus()); + setIsEnabling(false); + } + }; + + const handleTestAlert = async () => { + setIsTesting(true); + try { + await showTestRunningTimerStatus(); + } catch (error) { + console.debug('Unable to show test background alert:', error); + } finally { + setIsTesting(false); + } + }; + + return ( +
+ + +
+
+
+

Background alerts

+

{alertStatusCopy[alertStatus]}

+
+ {alertStatus === 'needs-permission' && ( + + )} + {alertStatus === 'enabled' && ( + + )} +
+ {!isTimerNotificationApiConfigured && ( +

+ Completion alerts are not configured in this development build. Running-status alerts still work + after notification permission is granted. +

+ )} +

+ On iPhone and iPad, install the app to the Home Screen before enabling alerts. Custom uploaded audio + plays only while the app is open. +

+
+
+ ); +}; export default AlarmSettings; diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index 8a5cf5a..4b32d6a 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -3,6 +3,13 @@ import { useBoolean, useCounter, useInterval } from 'usehooks-ts'; import { timerUnits, Unit } from '../config/timer/units'; import { BaseTimerData, RoutineTimerItem } from '../store/types/timer'; import { convertMsToMmSs } from '../utils/timeUtils'; +import { getRemainingCount } from '../utils/timerDeadline'; +import { + cancelTimerNotification, + requestBackgroundAlertsIfNeeded, + scheduleTimerNotification, +} from '../services/timerNotificationService'; +import { clearRunningTimerStatus, showRunningTimerStatus } from '../services/timerStatusNotification'; import { useWakeLock } from './useWakeLock'; type TimerOptions = { @@ -40,7 +47,7 @@ type TimerControllers = { /** If true, the timer is initialized. */ isInitialized: boolean; /** Starts the countdown. */ - start: () => void; + start: (requestAlerts?: boolean) => void; /** Stops the countdown. */ stop: () => void; /** Resets the countdown to the initial value. */ @@ -84,47 +91,83 @@ export function useTimer({ // State to prevent duplicated onFinish execution const finishTriggeredRef = useRef(false); - // State to handle tab visibility change event - const lastUpdateTimeRef = useRef(Date.now()); - const wasRunningRef = useRef(false); + const endAtRef = useRef(null); + const [isDocumentVisible, setIsDocumentVisible] = useState(() => document.visibilityState === 'visible'); // Calculate maximum possible count value if maxTime is given const maxCountStart = maxTime ? maxTime * currentUnit.multiple : undefined; // Manage count state with useCounter, which provides decrement and setCount functions - const { count, decrement, setCount } = useCounter(countStart); + const { count, setCount } = useCounter(countStart); // Manage the running state of the timer const { value: isRunning, setTrue: startCountdown, setFalse: stopCountdown } = useBoolean(false); + const clearRunningStatus = useCallback((timerId: string) => { + void clearRunningTimerStatus(timerId).catch((error) => + console.debug('Unable to clear running timer status:', error) + ); + }, []); + + const scheduleBackgroundNotification = useCallback( + (visibleUntil: number | null) => { + if (!endAtRef.current) return; + + void scheduleTimerNotification({ + timerId: timer.id, + endAt: endAtRef.current, + title: timer.title || 'Timer', + deepLink: '/visual-timer/', + visibleUntil, + }).catch((error) => console.debug('Unable to schedule background timer alert:', error)); + }, + [timer.id, timer.title] + ); + // Resets the countdown to the initial value and stops it const resetCountdown = useCallback(() => { stopCountdown(); finishTriggeredRef.current = false; - wasRunningRef.current = false; + endAtRef.current = null; + void cancelTimerNotification(timer.id); + clearRunningStatus(timer.id); setCount(countStart); setIsInitialized(true); - }, [stopCountdown, setCount, countStart]); + }, [clearRunningStatus, countStart, setCount, stopCountdown, timer.id]); // The callback for the countdown logic const countdownCallback = useCallback(() => { - if (count === 0 && onFinish && !finishTriggeredRef.current) { + if (!endAtRef.current) return; + + const remainingCount = getRemainingCount(endAtRef.current, intervalMs); + setCount(remainingCount); + + if (remainingCount <= 0 && onFinish && !finishTriggeredRef.current) { finishTriggeredRef.current = true; + void cancelTimerNotification(timer.id); + clearRunningStatus(timer.id); onFinish(resetCountdown); } - - decrement(); - lastUpdateTimeRef.current = Date.now(); - }, [count, decrement, resetCountdown, onFinish]); + }, [clearRunningStatus, intervalMs, onFinish, resetCountdown, setCount, timer.id]); // useInterval hook triggers the countdown logic when the timer is running useInterval(countdownCallback, isRunning ? intervalMs : null); // Function to start the countdown and mark as initialized - const start = useCallback(() => { - startCountdown(); - setIsInitialized(false); - }, [startCountdown]); + const start = useCallback( + (requestAlerts = true) => { + endAtRef.current = Date.now() + count * intervalMs; + startCountdown(); + setIsInitialized(false); + scheduleBackgroundNotification(isDocumentVisible ? Date.now() + 15_000 : null); + if (requestAlerts) { + void requestBackgroundAlertsIfNeeded() + .then(() => scheduleBackgroundNotification(isDocumentVisible ? Date.now() + 15_000 : null)) + .catch((error) => console.debug('Unable to request background alerts:', error)); + } + }, + [count, intervalMs, isDocumentVisible, scheduleBackgroundNotification, startCountdown] + ); // Sets a new time for the countdown and resets it const handleSetTime = useCallback( @@ -133,10 +176,13 @@ export function useTimer({ const newCountStart = validatedTime * currentUnit.multiple; setTime(validatedTime); setCount(newCountStart); + endAtRef.current = null; + void cancelTimerNotification(timer.id); + clearRunningStatus(timer.id); setIsInitialized(true); stopCountdown(); }, - [setCount, stopCountdown, currentUnit.multiple, maxTime] + [clearRunningStatus, currentUnit.multiple, maxTime, setCount, stopCountdown, timer.id] ); // Toggles between minutes and seconds mode @@ -144,9 +190,12 @@ export function useTimer({ const newCountStart = time * (isMinutes ? timerUnits.seconds.multiple : timerUnits.minutes.multiple); toggleIsMinutes(); setCount(newCountStart); + endAtRef.current = null; + void cancelTimerNotification(timer.id); + clearRunningStatus(timer.id); setIsInitialized(true); stopCountdown(); - }, [setCount, stopCountdown, time, isMinutes]); + }, [clearRunningStatus, isMinutes, setCount, stopCountdown, time, timer.id]); // Function to add a specific time to the current count const add = useCallback( @@ -157,8 +206,20 @@ export function useTimer({ } newCountStart = Math.max(newCountStart, 0); setCount(newCountStart); + if (endAtRef.current) { + endAtRef.current = Date.now() + newCountStart * intervalMs; + scheduleBackgroundNotification(isDocumentVisible ? Date.now() + 15_000 : null); + } }, - [count, maxCountStart, currentUnit.multiple, setCount] + [ + count, + currentUnit.multiple, + intervalMs, + isDocumentVisible, + maxCountStart, + scheduleBackgroundNotification, + setCount, + ] ); const currentTime = useMemo(() => { @@ -168,78 +229,29 @@ export function useTimer({ const progress = Math.max(0, count / currentUnit.denominator); - if (autoStart && isInitialized) start(); + if (autoStart && isInitialized) start(false); useWakeLock(isRunning); - // Handle the case where the timer is assigned in the background - useEffect(() => { - if (document.visibilityState === 'hidden' && isRunning) { - const endTime = Date.now() + count * intervalMs; - navigator.serviceWorker.controller?.postMessage({ - command: 'start-timer', - timer, - endTime, - }); - } - }, [timer.id]); - - useEffect(() => { - const handleServiceWorkerMessage = (event: MessageEvent) => { - const { command, id: messageId } = event.data; - - if (command === 'finished' && messageId === timer.id) { - console.debug('Timer finished in background.'); - setCount(0); - startCountdown(); - } - }; - - navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); - - return () => { - navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); - }; - }, [setCount, startCountdown]); - // Visibility change handling useEffect(() => { const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { - wasRunningRef.current = isRunning; - if (isRunning) { - lastUpdateTimeRef.current = Date.now(); - stopCountdown(); - - // Send remaining time to service worker - if (count > 0) { - const endTime = Date.now() + count * intervalMs; - navigator.serviceWorker.controller?.postMessage({ - command: 'start-timer', - timer, - endTime, - }); + setIsDocumentVisible(false); + if (isRunning && !finishTriggeredRef.current) { + scheduleBackgroundNotification(null); + if (endAtRef.current) { + void showRunningTimerStatus(timer.id, timer.title || 'Timer', endAtRef.current).catch((error) => + console.debug('Unable to show running timer status:', error) + ); } } } else if (document.visibilityState === 'visible') { - if (wasRunningRef.current) { - // Restore timer based on remaining time when the tab becomes active - const elapsedMs = Date.now() - lastUpdateTimeRef.current; - const elapsedCount = Math.floor(elapsedMs / intervalMs); - const newCountStart = count - elapsedCount; - - if (count > 0) { - setCount(Math.max(0, newCountStart)); - } else { - setCount(newCountStart); - } - - startCountdown(); - - navigator.serviceWorker.controller?.postMessage({ - command: 'clear-timer', - timer, - }); + setIsDocumentVisible(true); + clearRunningStatus(timer.id); + countdownCallback(); + if (isRunning) { + scheduleBackgroundNotification(Date.now() + 15_000); } } }; @@ -249,15 +261,29 @@ export function useTimer({ return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [count, isRunning, intervalMs, startCountdown, stopCountdown, setCount]); + }, [clearRunningStatus, countdownCallback, isRunning, scheduleBackgroundNotification, timer.id, timer.title]); + + useInterval( + () => scheduleBackgroundNotification(Date.now() + 15_000), + isRunning && isDocumentVisible && !finishTriggeredRef.current ? 5_000 : null + ); // Reset timer with new input data useEffect(() => { setTime(initialTime); setIsMinutes(unit === 'minutes'); setCount(initialTime * currentUnit.multiple); + endAtRef.current = null; + void cancelTimerNotification(timer.id); }, [initialTime, unit, currentUnit.multiple, setCount]); + const stop = useCallback(() => { + stopCountdown(); + endAtRef.current = null; + void cancelTimerNotification(timer.id); + clearRunningStatus(timer.id); + }, [clearRunningStatus, stopCountdown, timer.id]); + return { totalTime: time, count, @@ -268,7 +294,7 @@ export function useTimer({ isMinutes, isInitialized, start, - stop: stopCountdown, + stop, reset: resetCountdown, toggleUnit, setTime: handleSetTime, diff --git a/src/index.tsx b/src/index.tsx index 485781a..7f86cf7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,74 +7,76 @@ import './index.css'; import ErrorPage from './pages/ErrorPage'; import MainPage from './pages/MainPage'; import NotFoundPage from './pages/NotFoundPage'; +import { configureTimerNotificationApiUrl } from './services/timerNotificationService'; -const parseLocalStorage = () => { - return Object.keys(localStorage).reduce( - (acc, key) => { - try { - acc[key] = JSON.parse(localStorage.getItem(key) || ''); - } catch { - acc[key] = localStorage.getItem(key); - } - return acc; +configureTimerNotificationApiUrl(import.meta.env.VITE_TIMER_NOTIFICATION_API_URL); + +document.addEventListener( + 'click', + () => { + if ('Notification' in window && Notification.permission === 'default') { + void Notification.requestPermission().catch((error) => + console.debug('Unable to request notification permission:', error) + ); + } }, - {} as Record - ); + { once: true } +); + +const parseLocalStorage = () => { + return Object.keys(localStorage).reduce( + (acc, key) => { + try { + acc[key] = JSON.parse(localStorage.getItem(key) || ''); + } catch { + acc[key] = localStorage.getItem(key); + } + return acc; + }, + {} as Record + ); }; const sentryDsn = import.meta.env.VITE_SENTRY_DSN; if (sentryDsn) { - Sentry.init({ - dsn: sentryDsn, - beforeSend(event) { - event.extra = { - ...event.extra, - localStorage: parseLocalStorage(), - }; - return event; - }, - }); + Sentry.init({ + dsn: sentryDsn, + beforeSend(event) { + event.extra = { + ...event.extra, + localStorage: parseLocalStorage(), + }; + return event; + }, + }); } const router = createBrowserRouter( - [ - { - path: '/', - element: ( - }> - - - ), - errorElement: , - children: [ + [ { - index: true, - path: '/', - element: , + path: '/', + element: ( + }> + + + ), + errorElement: , + children: [ + { + index: true, + path: '/', + element: , + }, + ], }, - ], - }, + { + path: '*', + element: , + }, + ], { - path: '*', - element: , - }, - ], - { - basename: '/visual-timer', - } -); - -// Request notification permission on first user click -document.addEventListener( - 'click', - () => { - if ('Notification' in window && Notification.permission !== 'granted' && Notification.permission !== 'denied') { - Notification.requestPermission().catch((error) => { - console.error('Notification permission error:', error); - }); + basename: '/visual-timer', } - }, - { once: true } ); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); @@ -82,11 +84,14 @@ root.render(); // Register Service Worker with auto-update registerSW({ - immediate: true, - onNeedRefresh() { - console.debug('New content available, updating service worker.'); - }, - onOfflineReady() { - console.debug('App ready to work offline.'); - }, + immediate: true, + onRegisterError(error) { + console.error('Service worker registration failed:', error); + }, + onNeedRefresh() { + console.debug('New content available, updating service worker.'); + }, + onOfflineReady() { + console.debug('App ready to work offline.'); + }, }); diff --git a/src/service-worker.ts b/src/service-worker.ts index 97d5f50..b9efb01 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -6,6 +6,11 @@ import { ExpirationPlugin } from 'workbox-expiration'; import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'; +import { + createFinishedNotification, + createRunningStatusNotification, + type TimerFinishedPayload, +} from './utils/timerNotificationPayload'; declare const self: ServiceWorkerGlobalScope; @@ -16,163 +21,85 @@ precacheAndRoute(self.__WB_MANIFEST); // SPA App Shell routing for navigation requests const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); -registerRoute( - ({ request, url }: { request: Request; url: URL }) => { +registerRoute(({ request, url }: { request: Request; url: URL }) => { if (request.mode !== 'navigate') return false; if (url.pathname.startsWith('/_')) return false; if (url.pathname.match(fileExtensionRegexp)) return false; return true; - }, - createHandlerBoundToURL('/visual-timer/index.html') -); +}, createHandlerBoundToURL('/visual-timer/index.html')); // Cache images registerRoute( - ({ url }) => url.origin === self.location.origin && (url.pathname.endsWith('.png') || url.pathname.endsWith('.ico') || url.pathname.endsWith('.svg')), - new StaleWhileRevalidate({ - cacheName: 'images', - plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })], - }) + ({ url }) => + url.origin === self.location.origin && + (url.pathname.endsWith('.png') || url.pathname.endsWith('.ico') || url.pathname.endsWith('.svg')), + new StaleWhileRevalidate({ + cacheName: 'images', + plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 })], + }) ); // Cache audio assets registerRoute( - ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.mp3'), - new CacheFirst({ - cacheName: 'audio-cache', - plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 60 * 24 * 60 * 60 })], - }) + ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.mp3'), + new CacheFirst({ + cacheName: 'audio-cache', + plugins: [new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 60 * 24 * 60 * 60 })], + }) ); // Skip waiting on demand self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') { - self.skipWaiting(); - } + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } }); -// Helper for time formatting -function formatMsToMmSs(ms: number): string { - const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; -} - -type TimerHandles = { - timeoutId?: number | ReturnType; - intervalId?: number | ReturnType; -}; - -const activeTimers: Record = {}; -const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); -const NOTIFICATION_TICK_MS = 1000; -const OVERTIME_CAP_MS = 10 * 60 * 1000; - -const clearTimerHandles = (timerId: string) => { - const handles = activeTimers[timerId]; - if (handles) { - if (handles.timeoutId) clearTimeout(handles.timeoutId); - if (handles.intervalId) clearInterval(handles.intervalId); - delete activeTimers[timerId]; - } -}; - self.addEventListener('message', (event) => { - if (!event.source) return; - - const clientId = (event.source as Client).id; - const { command, timer, endTime } = event.data || {}; - - if (!timer || !timer.id) return; - - if (command === 'start-timer') { - const endTimeMs = Number(endTime); - clearTimerHandles(timer.id); + const { command, timerId, title, endAt } = event.data || {}; + if (!timerId) return; - const remainingTime = Math.max(0, endTimeMs - Date.now()); - - let timeoutId: ReturnType | undefined; - if (remainingTime > 0) { - timeoutId = setTimeout(async () => { - const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const targetClient = clientList.find((client) => client.id === clientId); - if (targetClient) { - targetClient.postMessage({ command: 'finished', id: timer.id }); - } - }, remainingTime); + if (command === 'show-running-status' && title && Number.isFinite(endAt)) { + const notification = createRunningStatusNotification(timerId, title, Number(endAt)); + event.waitUntil(self.registration.showNotification(notification.title, notification.options)); } - let intervalId: ReturnType | undefined; - if (isIOS) { - self.registration.showNotification(timer.title || 'Timer Running', { - body: 'Timer is currently running in the background', - icon: '/visual-timer/logo512.png', - tag: timer.id, - silent: true, - }); - } else { - const tick = async () => { - const rawMs = endTimeMs - Date.now(); - if (rawMs < -OVERTIME_CAP_MS) { - if (intervalId !== undefined) { - clearInterval(intervalId); - intervalId = undefined; - } - if (activeTimers[timer.id]) { - activeTimers[timer.id].intervalId = undefined; - } - return; - } - - await self.registration.showNotification(timer.title || 'Timer Running', { - body: formatMsToMmSs(rawMs), - icon: '/visual-timer/logo512.png', - tag: timer.id, - silent: true, - }); - }; - - void tick(); - intervalId = setInterval(() => { - void tick(); - }, NOTIFICATION_TICK_MS); + if (command === 'clear-running-status') { + event.waitUntil( + self.registration.getNotifications({ tag: `running-${timerId}` }).then((notifications) => { + notifications.forEach((notification) => notification.close()); + }) + ); } - - activeTimers[timer.id] = { timeoutId, intervalId }; - } else if (command === 'clear-timer') { - clearTimerHandles(timer.id); - - void (async () => { - try { - const notifications = await self.registration.getNotifications({ tag: timer.id }); - notifications.forEach((notification) => notification.close()); - } catch (err) { - console.error('Error closing notifications:', err); - } - })(); - } }); -const navigateToApp = async () => { - const clientList = await self.clients.matchAll({ - type: 'window', - includeUncontrolled: true, - }); +const navigateToApp = async (deepLink = '/visual-timer/') => { + const clientList = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); - const hadClientOpen = clientList.some((client) => { - if (client.url.includes('/visual-timer') && 'focus' in client) { - return (client as WindowClient).focus(); + for (const client of clientList) { + if (client.url.includes('/visual-timer') && 'focus' in client) { + await (client as WindowClient).focus(); + return; + } } - return false; - }); - if (!hadClientOpen && self.clients.openWindow) { - await self.clients.openWindow('/visual-timer/'); - } + if (self.clients.openWindow) { + await self.clients.openWindow(deepLink); + } }; self.addEventListener('notificationclick', (event) => { - event.notification.close(); - event.waitUntil(navigateToApp()); + event.notification.close(); + event.waitUntil(navigateToApp(event.notification.data?.deepLink)); +}); + +self.addEventListener('push', (event) => { + const payload = event.data?.json() as (TimerFinishedPayload & { type: 'timer-finished' }) | undefined; + if (!payload || payload.type !== 'timer-finished') return; + + const notification = createFinishedNotification(payload); + event.waitUntil(self.registration.showNotification(notification.title, notification.options)); }); diff --git a/src/services/serviceWorkerMessages.test.ts b/src/services/serviceWorkerMessages.test.ts new file mode 100644 index 0000000..d294e01 --- /dev/null +++ b/src/services/serviceWorkerMessages.test.ts @@ -0,0 +1,26 @@ +import { postServiceWorkerMessage } from './serviceWorkerMessages'; + +describe('service worker messages', () => { + it('uses the active registration when the page is not yet controlled', async () => { + const postMessage = jest.fn(); + const serviceWorker = { + controller: null, + ready: Promise.resolve({ active: { postMessage } }), + } as unknown as ServiceWorkerContainer; + + await expect(postServiceWorkerMessage({ command: 'show-running-status' }, serviceWorker)).resolves.toBe(true); + expect(postMessage).toHaveBeenCalledWith({ command: 'show-running-status' }); + }); + + it('uses the current controller without waiting for registration', async () => { + const postMessage = jest.fn(); + const serviceWorker = { + controller: { postMessage }, + ready: Promise.resolve({ active: null }), + } as unknown as ServiceWorkerContainer; + + await postServiceWorkerMessage({ command: 'clear-running-status' }, serviceWorker); + + expect(postMessage).toHaveBeenCalledWith({ command: 'clear-running-status' }); + }); +}); diff --git a/src/services/serviceWorkerMessages.ts b/src/services/serviceWorkerMessages.ts new file mode 100644 index 0000000..b6d6e7f --- /dev/null +++ b/src/services/serviceWorkerMessages.ts @@ -0,0 +1,17 @@ +type ServiceWorkerMessage = Record; + +export const postServiceWorkerMessage = async ( + message: ServiceWorkerMessage, + serviceWorker: Pick = navigator.serviceWorker +) => { + if (serviceWorker.controller) { + serviceWorker.controller.postMessage(message); + return true; + } + + const registration = await serviceWorker.ready; + if (!registration.active) return false; + + registration.active.postMessage(message); + return true; +}; diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts new file mode 100644 index 0000000..7a6c2d7 --- /dev/null +++ b/src/services/timerNotificationService.test.ts @@ -0,0 +1,78 @@ +import { + base64UrlToUint8Array, + createScheduleCredentials, + configureTimerNotificationApiUrl, + getBackgroundAlertStatus, + getNotificationSupport, + getTimerNotificationApiUrl, + requestBackgroundAlertsIfNeeded, +} from './timerNotificationService'; + +describe('timer notification service', () => { + afterEach(() => { + localStorage.clear(); + configureTimerNotificationApiUrl(undefined); + }); + + it('converts a URL-safe VAPID key into subscription bytes', () => { + expect(Array.from(base64UrlToUint8Array('AQI'))).toEqual([1, 2]); + }); + + it('reports unavailable when Push APIs are missing', () => { + expect(getNotificationSupport({} as Navigator)).toBe(false); + }); + + it('creates persistent credentials for one timer schedule', () => { + const credentials = createScheduleCredentials('timer-1', () => 'generated-token'); + + expect(credentials).toEqual({ scheduleId: 'timer-1', capability: 'generated-token' }); + expect(localStorage.getItem('timer-notification:timer-1')).toContain('generated-token'); + }); + + it('reports when a supported browser still needs notification permission', () => { + expect(getBackgroundAlertStatus(true, 'default')).toBe('needs-permission'); + expect(getBackgroundAlertStatus(true, 'granted')).toBe('enabled'); + expect(getBackgroundAlertStatus(true, 'denied')).toBe('denied'); + }); + + it('recognizes an unconfigured timer notification API', () => { + expect(getTimerNotificationApiUrl('')).toBeUndefined(); + expect(getTimerNotificationApiUrl('https://worker.example')).toBe('https://worker.example'); + }); + + it('uses the Vite-provided API URL configured during app startup', () => { + configureTimerNotificationApiUrl('https://worker.example'); + + expect(getTimerNotificationApiUrl()).toBe('https://worker.example'); + }); + + it('requests alerts when permission has not been decided', async () => { + const requestAlerts = jest.fn().mockResolvedValue('subscribed'); + + await expect(requestBackgroundAlertsIfNeeded('needs-permission', requestAlerts)).resolves.toBe('subscribed'); + await expect(requestBackgroundAlertsIfNeeded('denied', requestAlerts)).resolves.toBeNull(); + + expect(requestAlerts).toHaveBeenCalledTimes(1); + }); + + it('subscribes when notification permission was granted before Push was enabled', async () => { + const requestAlerts = jest.fn().mockResolvedValue('subscribed'); + const getSubscription = jest.fn().mockResolvedValue(null); + + await expect(requestBackgroundAlertsIfNeeded('enabled', requestAlerts, getSubscription)).resolves.toBe( + 'subscribed' + ); + + expect(getSubscription).toHaveBeenCalledTimes(1); + expect(requestAlerts).toHaveBeenCalledTimes(1); + }); + + it('does not replace an existing Push subscription', async () => { + const requestAlerts = jest.fn(); + const getSubscription = jest.fn().mockResolvedValue({} as PushSubscription); + + await expect(requestBackgroundAlertsIfNeeded('enabled', requestAlerts, getSubscription)).resolves.toBeNull(); + + expect(requestAlerts).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts new file mode 100644 index 0000000..b946662 --- /dev/null +++ b/src/services/timerNotificationService.ts @@ -0,0 +1,131 @@ +export const base64UrlToUint8Array = (value: string): Uint8Array => { + const paddedValue = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), '='); + const binary = atob(paddedValue.replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +}; + +export const getNotificationSupport = (navigatorValue: Navigator = navigator) => { + return 'serviceWorker' in navigatorValue && typeof PushManager !== 'undefined' && 'Notification' in window; +}; + +let configuredTimerNotificationApiUrl: string | undefined; + +export const configureTimerNotificationApiUrl = (apiUrl: string | undefined) => { + configuredTimerNotificationApiUrl = apiUrl || undefined; +}; + +export const getTimerNotificationApiUrl = (apiUrl: string | undefined = configuredTimerNotificationApiUrl) => { + return apiUrl || undefined; +}; + +export type BackgroundAlertStatus = 'unsupported' | 'needs-permission' | 'enabled' | 'denied'; + +export const getBackgroundAlertStatus = ( + isSupported = getNotificationSupport(), + permission: NotificationPermission = isSupported ? Notification.permission : 'default' +): BackgroundAlertStatus => { + if (!isSupported) return 'unsupported'; + if (permission === 'granted') return 'enabled'; + if (permission === 'denied') return 'denied'; + return 'needs-permission'; +}; + +type ScheduleCredentials = { + scheduleId: string; + capability: string; +}; + +type ScheduleRequest = { + timerId: string; + endAt: number; + title: string; + deepLink: string; + visibleUntil: number | null; +}; + +const scheduleCredentialsKey = (timerId: string) => `timer-notification:${timerId}`; + +export const createScheduleCredentials = ( + timerId: string, + createToken = () => crypto.randomUUID() +): ScheduleCredentials => { + const existing = localStorage.getItem(scheduleCredentialsKey(timerId)); + if (existing) return JSON.parse(existing) as ScheduleCredentials; + + const credentials = { scheduleId: timerId, capability: createToken() }; + localStorage.setItem(scheduleCredentialsKey(timerId), JSON.stringify(credentials)); + return credentials; +}; + +const getActiveSubscription = async () => { + if (!getNotificationSupport() || Notification.permission !== 'granted') return null; + const registration = await navigator.serviceWorker.ready; + return registration.pushManager.getSubscription(); +}; + +export const scheduleTimerNotification = async (request: ScheduleRequest) => { + const apiBaseUrl = getTimerNotificationApiUrl(); + const subscription = await getActiveSubscription(); + if (!apiBaseUrl || !subscription) return false; + + const credentials = createScheduleCredentials(request.timerId); + const response = await fetch(`${apiBaseUrl}/v1/schedules/${encodeURIComponent(credentials.scheduleId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + capability: credentials.capability, + endAt: request.endAt, + title: request.title, + deepLink: request.deepLink, + subscription: subscription.toJSON(), + visibleUntil: request.visibleUntil, + }), + }); + return response.ok; +}; + +export const cancelTimerNotification = async (timerId: string) => { + const apiBaseUrl = getTimerNotificationApiUrl(); + const storedCredentials = localStorage.getItem(scheduleCredentialsKey(timerId)); + if (!apiBaseUrl || !storedCredentials) return; + + const credentials = JSON.parse(storedCredentials) as ScheduleCredentials; + await fetch(`${apiBaseUrl}/v1/schedules/${encodeURIComponent(credentials.scheduleId)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: credentials.capability }), + }); + localStorage.removeItem(scheduleCredentialsKey(timerId)); +}; + +export const requestPushSubscription = async (apiBaseUrl: string) => { + if (!getNotificationSupport()) return null; + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') return null; + + const registration = await navigator.serviceWorker.ready; + const { publicKey } = await fetch(`${apiBaseUrl}/v1/push/public-key`).then((response) => response.json()); + const applicationServerKey = base64UrlToUint8Array(publicKey); + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey.buffer as ArrayBuffer, + }); +}; + +export const enableBackgroundAlerts = async () => { + const apiBaseUrl = getTimerNotificationApiUrl(); + if (!apiBaseUrl) return null; + return requestPushSubscription(apiBaseUrl); +}; + +export const requestBackgroundAlertsIfNeeded = async ( + alertStatus = getBackgroundAlertStatus(), + requestAlerts: () => Promise = enableBackgroundAlerts, + getSubscription: () => Promise = getActiveSubscription +) => { + if (alertStatus === 'unsupported' || alertStatus === 'denied') return null; + if (alertStatus === 'enabled' && (await getSubscription())) return null; + + return requestAlerts(); +}; diff --git a/src/services/timerStatusNotification.test.ts b/src/services/timerStatusNotification.test.ts new file mode 100644 index 0000000..0e55ef5 --- /dev/null +++ b/src/services/timerStatusNotification.test.ts @@ -0,0 +1,41 @@ +import { clearRunningTimerStatus, showRunningTimerStatus, showTestRunningTimerStatus } from './timerStatusNotification'; + +describe('timer status notifications', () => { + it('asks the service worker to show the running status', async () => { + const postMessage = jest.fn().mockResolvedValue(true); + + await expect(showRunningTimerStatus('timer-1', 'Focus', 10_000, postMessage, 'granted')).resolves.toBe(true); + + expect(postMessage).toHaveBeenCalledWith({ + command: 'show-running-status', + timerId: 'timer-1', + title: 'Focus', + endAt: 10_000, + }); + }); + + it('does not show a status notification before permission is granted', async () => { + const postMessage = jest.fn(); + + await expect(showRunningTimerStatus('timer-1', 'Focus', 10_000, postMessage, 'default')).resolves.toBe(false); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('clears the matching running status notification', async () => { + const postMessage = jest.fn().mockResolvedValue(true); + + await clearRunningTimerStatus('timer-1', postMessage); + + expect(postMessage).toHaveBeenCalledWith({ command: 'clear-running-status', timerId: 'timer-1' }); + }); + + it('uses the same service worker path for a test alert', async () => { + const postMessage = jest.fn().mockResolvedValue(true); + + await expect(showTestRunningTimerStatus(postMessage, 'granted', 10_000)).resolves.toBe(true); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ command: 'show-running-status', timerId: 'background-alert-test' }) + ); + }); +}); diff --git a/src/services/timerStatusNotification.ts b/src/services/timerStatusNotification.ts new file mode 100644 index 0000000..c03f356 --- /dev/null +++ b/src/services/timerStatusNotification.ts @@ -0,0 +1,27 @@ +import { postServiceWorkerMessage } from './serviceWorkerMessages'; + +type PostMessage = (message: Record) => Promise; + +const TEST_TIMER_ID = 'background-alert-test'; + +export const showRunningTimerStatus = async ( + timerId: string, + title: string, + endAt: number, + postMessage: PostMessage = postServiceWorkerMessage, + permission: NotificationPermission = Notification.permission +) => { + if (permission !== 'granted') return false; + + return postMessage({ command: 'show-running-status', timerId, title, endAt }); +}; + +export const clearRunningTimerStatus = async (timerId: string, postMessage: PostMessage = postServiceWorkerMessage) => { + return postMessage({ command: 'clear-running-status', timerId }); +}; + +export const showTestRunningTimerStatus = ( + postMessage: PostMessage = postServiceWorkerMessage, + permission: NotificationPermission = Notification.permission, + now = Date.now() +) => showRunningTimerStatus(TEST_TIMER_ID, 'Mellow Visual Timer', now + 10 * 60 * 1_000, postMessage, permission); diff --git a/src/store/settingsStore.test.ts b/src/store/settingsStore.test.ts new file mode 100644 index 0000000..ed714b6 --- /dev/null +++ b/src/store/settingsStore.test.ts @@ -0,0 +1,36 @@ +import { useSettingsStore } from './settingsStore'; + +describe('settings store custom alarm', () => { + const defaultAlarm = '/visual-timer/audios/radar.mp3'; + + afterEach(() => { + useSettingsStore.setState({ + selectedAlarm: defaultAlarm, + customAlarm: null, + }); + }); + + it('keeps custom alarm metadata with the selected sound', () => { + const customAlarm = { + name: 'focus-song.mp3', + value: 'data:audio/mpeg;base64,custom-audio', + }; + + useSettingsStore.getState().setCustomAlarm(customAlarm); + + expect(useSettingsStore.getState().customAlarm).toEqual(customAlarm); + expect(useSettingsStore.getState().selectedAlarm).toBe(customAlarm.value); + }); + + it('removes the custom alarm and restores the default sound', () => { + useSettingsStore.getState().setCustomAlarm({ + name: 'focus-song.mp3', + value: 'data:audio/mpeg;base64,custom-audio', + }); + + useSettingsStore.getState().removeCustomAlarm(); + + expect(useSettingsStore.getState().customAlarm).toBeNull(); + expect(useSettingsStore.getState().selectedAlarm).toBe(defaultAlarm); + }); +}); diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 3059fb3..d3e2fa2 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -1,14 +1,24 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +const DEFAULT_ALARM = '/visual-timer/audios/radar.mp3'; + +type CustomAlarm = { + name: string; + value: string; +}; + type SettingsState = { volume: number; // Notification sound volume (0 to 1) mute: boolean; selectedAlarm: string; // Selected alarm sound file URL + customAlarm: CustomAlarm | null; isClockwise: boolean; setVolume: (volume: number) => void; setMute: (mute: boolean) => void; setSelectedAlarm: (alarm: string) => void; + setCustomAlarm: (alarm: CustomAlarm) => void; + removeCustomAlarm: () => void; setIsClockwise: (isClockwise: boolean) => void; }; @@ -17,25 +27,32 @@ export const useSettingsStore = create()( (set) => ({ volume: 1, // Default volume (max) mute: false, - selectedAlarm: '/visual-timer/audios/radar.mp3', // Default alarm sound + selectedAlarm: DEFAULT_ALARM, // Default alarm sound + customAlarm: null, isClockwise: true, //Default direction setVolume: (volume) => set({ volume }), setMute: (mute) => set({ mute }), setSelectedAlarm: (alarm) => set({ selectedAlarm: alarm }), + setCustomAlarm: (customAlarm) => set({ customAlarm, selectedAlarm: customAlarm.value }), + removeCustomAlarm: () => set({ customAlarm: null, selectedAlarm: DEFAULT_ALARM }), setIsClockwise: (isClockwise) => set({ isClockwise }), }), { name: 'settings-store', - version: 2, // a migration will be triggered if the version in the storage mismatches this one + version: 3, // a migration will be triggered if the version in the storage mismatches this one migrate: (persistedState, version) => { const state = persistedState as SettingsState; if (version < 2) { return { ...state, - selectedAlarm: '/visual-timer/audios/radar.mp3', + selectedAlarm: DEFAULT_ALARM, isClockwise: true, + customAlarm: null, }; } + if (version < 3) { + return { ...state, customAlarm: null }; + } return state; }, } diff --git a/src/utils/audioPreviewController.test.ts b/src/utils/audioPreviewController.test.ts new file mode 100644 index 0000000..ad7d734 --- /dev/null +++ b/src/utils/audioPreviewController.test.ts @@ -0,0 +1,29 @@ +import { createAudioPreviewController } from './audioPreviewController'; + +describe('audio preview controller', () => { + it('stops the active preview when toggled again', () => { + const controller = createAudioPreviewController(); + const stop = jest.fn(); + + expect(controller.toggle(() => stop)).toBe(true); + expect(controller.isPlaying()).toBe(true); + + expect(controller.toggle(() => stop)).toBe(false); + expect(stop).toHaveBeenCalledTimes(1); + expect(controller.isPlaying()).toBe(false); + }); + + it('stops the previous preview before starting another one', () => { + const controller = createAudioPreviewController(); + const firstStop = jest.fn(); + const secondStop = jest.fn(); + + controller.start(() => firstStop); + controller.start(() => secondStop); + + expect(firstStop).toHaveBeenCalledTimes(1); + expect(controller.isPlaying()).toBe(true); + controller.stop(); + expect(secondStop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/audioPreviewController.ts b/src/utils/audioPreviewController.ts new file mode 100644 index 0000000..1fb1389 --- /dev/null +++ b/src/utils/audioPreviewController.ts @@ -0,0 +1,29 @@ +export type StopPreview = () => void; + +export const createAudioPreviewController = () => { + let stopCurrentPreview: StopPreview | undefined; + + const stop = () => { + stopCurrentPreview?.(); + stopCurrentPreview = undefined; + }; + + const start = (play: () => StopPreview | undefined) => { + stop(); + stopCurrentPreview = play(); + return Boolean(stopCurrentPreview); + }; + + const isPlaying = () => Boolean(stopCurrentPreview); + + const toggle = (play: () => StopPreview | undefined) => { + if (isPlaying()) { + stop(); + return false; + } + + return start(play); + }; + + return { isPlaying, start, stop, toggle }; +}; diff --git a/src/utils/timerDeadline.test.ts b/src/utils/timerDeadline.test.ts new file mode 100644 index 0000000..253bff6 --- /dev/null +++ b/src/utils/timerDeadline.test.ts @@ -0,0 +1,12 @@ +import { getRemainingCount } from './timerDeadline'; + +describe('timer deadline', () => { + it('derives the remaining count from an absolute end time', () => { + expect(getRemainingCount(10_000, 1_000, 4_200)).toBe(6); + }); + + it('reports elapsed time as a negative count after expiry', () => { + expect(getRemainingCount(10_000, 1_000, 10_000)).toBe(0); + expect(getRemainingCount(10_000, 1_000, 11_200)).toBe(-1); + }); +}); diff --git a/src/utils/timerDeadline.ts b/src/utils/timerDeadline.ts new file mode 100644 index 0000000..259ca46 --- /dev/null +++ b/src/utils/timerDeadline.ts @@ -0,0 +1,3 @@ +export const getRemainingCount = (endAt: number, intervalMs: number, now = Date.now()) => { + return Math.ceil((endAt - now) / intervalMs); +}; diff --git a/src/utils/timerNotificationPayload.test.ts b/src/utils/timerNotificationPayload.test.ts new file mode 100644 index 0000000..eced236 --- /dev/null +++ b/src/utils/timerNotificationPayload.test.ts @@ -0,0 +1,21 @@ +import { createFinishedNotification, createRunningStatusNotification } from './timerNotificationPayload'; + +describe('timer notification payload', () => { + it('creates a tagged completion notification', () => { + expect( + createFinishedNotification({ + timerId: 'timer-1', + title: 'Focus', + deepLink: '/visual-timer/', + endAt: 1000, + }) + ).toMatchObject({ title: 'Focus complete', options: { tag: 'timer-1', data: { deepLink: '/visual-timer/' } } }); + }); + + it('creates one visible running-status notification with an end time', () => { + expect(createRunningStatusNotification('timer-1', 'Focus', Date.UTC(2026, 7, 22, 15, 42))).toMatchObject({ + title: 'Focus running', + options: { tag: 'running-timer-1', renotify: true }, + }); + }); +}); diff --git a/src/utils/timerNotificationPayload.ts b/src/utils/timerNotificationPayload.ts new file mode 100644 index 0000000..49bc6fb --- /dev/null +++ b/src/utils/timerNotificationPayload.ts @@ -0,0 +1,29 @@ +export type TimerFinishedPayload = { + timerId: string; + title: string; + deepLink: string; + endAt: number; +}; + +export const createFinishedNotification = (payload: TimerFinishedPayload) => ({ + title: `${payload.title} complete`, + options: { + body: 'Your timer has finished.', + icon: '/visual-timer/logo512.png', + tag: payload.timerId, + renotify: true, + timestamp: payload.endAt, + data: { deepLink: payload.deepLink }, + }, +}); + +export const createRunningStatusNotification = (timerId: string, title: string, endAt: number) => ({ + title: `${title} running`, + options: { + body: `Ends at ${new Date(endAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`, + icon: '/visual-timer/logo512.png', + tag: `running-${timerId}`, + renotify: true, + timestamp: endAt, + }, +}); diff --git a/src/versionMetadata.test.ts b/src/versionMetadata.test.ts index 9652d63..5c8df08 100644 --- a/src/versionMetadata.test.ts +++ b/src/versionMetadata.test.ts @@ -1,7 +1,7 @@ import packageMetadata from '../package.json'; describe('release metadata', () => { - test('reports the current patch release version', () => { - expect(packageMetadata.version).toBe('0.4.1'); + test('reports the current minor release version', () => { + expect(packageMetadata.version).toBe('0.5.0'); }); }); diff --git a/vite.config.ts b/vite.config.ts index c5871ac..1989ad8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,53 +4,54 @@ import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ export default defineConfig({ - base: '/visual-timer/', - plugins: [ - react(), - VitePWA({ - strategies: 'injectManifest', - srcDir: 'src', - filename: 'service-worker.ts', - registerType: 'autoUpdate', - injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,mp3,json}'], - }, - manifest: { - name: 'Mellow Visual Timer', - short_name: 'Visual Timer', - description: 'A soothing and intuitive visual countdown timer for deep focus, routines, and Pomodoro.', - theme_color: '#4A7658', - background_color: '#F4E3C1', - display: 'standalone', - orientation: 'any', - start_url: '/visual-timer/', - scope: '/visual-timer/', - icons: [ - { - src: 'favicon.ico', - sizes: '64x64 32x32 24x24 16x16', - type: 'image/x-icon', - }, - { - src: 'icon-192.png', - type: 'image/png', - sizes: '192x192', - }, - { - src: 'logo512.png', - type: 'image/png', - sizes: '512x512', - }, - ], - }, - devOptions: { - enabled: true, - type: 'module', - }, - }), - ], - server: { - port: 3000, - open: false, - }, + base: '/visual-timer/', + plugins: [ + react(), + VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'service-worker.ts', + registerType: 'autoUpdate', + injectManifest: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,mp3,json}'], + }, + manifest: { + name: 'Mellow Visual Timer', + short_name: 'Visual Timer', + description: 'A soothing and intuitive visual countdown timer for deep focus, routines, and Pomodoro.', + theme_color: '#4A7658', + background_color: '#F4E3C1', + display: 'standalone', + orientation: 'any', + start_url: '/visual-timer/', + scope: '/visual-timer/', + icons: [ + { + src: 'favicon.ico', + sizes: '64x64 32x32 24x24 16x16', + type: 'image/x-icon', + }, + { + src: 'icon-192.png', + type: 'image/png', + sizes: '192x192', + }, + { + src: 'logo512.png', + type: 'image/png', + sizes: '512x512', + }, + ], + }, + devOptions: { + enabled: true, + type: 'module', + navigateFallback: 'index.html', + }, + }), + ], + server: { + port: 3000, + open: false, + }, }); diff --git a/workers/timer-notifications/.dev.vars.example b/workers/timer-notifications/.dev.vars.example new file mode 100644 index 0000000..5403031 --- /dev/null +++ b/workers/timer-notifications/.dev.vars.example @@ -0,0 +1,3 @@ +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:you@example.com diff --git a/workers/timer-notifications/src/index.ts b/workers/timer-notifications/src/index.ts new file mode 100644 index 0000000..f691705 --- /dev/null +++ b/workers/timer-notifications/src/index.ts @@ -0,0 +1,51 @@ +import { TimerSchedule } from './timer-schedule'; +import type { WorkerEnv } from './types'; + +export { TimerSchedule }; + +const allowedOrigins = new Set(['https://do0ori.github.io', 'http://localhost:3000']); + +const corsHeaders = (origin: string | null) => { + const headers = new Headers(); + if (!origin || !allowedOrigins.has(origin)) return headers; + + headers.set('Access-Control-Allow-Origin', origin); + headers.set('Access-Control-Allow-Methods', 'GET, PUT, PATCH, DELETE, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type'); + headers.set('Vary', 'Origin'); + return headers; +}; + +const withCors = (response: Response, origin: string | null) => { + const headers = new Headers(response.headers); + corsHeaders(origin).forEach((value, name) => headers.set(name, value)); + return new Response(response.body, { status: response.status, headers }); +}; + +const scheduleIdFromPath = (pathname: string) => { + const match = pathname.match(/^\/v1\/schedules\/([\w-]+)$/); + return match?.[1]; +}; + +export default { + async fetch(request, env): Promise { + const origin = request.headers.get('Origin'); + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders(origin) }); + } + + const url = new URL(request.url); + if (request.method === 'GET' && url.pathname === '/v1/push/public-key') { + return withCors(Response.json({ publicKey: env.VAPID_PUBLIC_KEY }), origin); + } + + const scheduleId = scheduleIdFromPath(url.pathname); + if (!scheduleId || !['PUT', 'PATCH', 'DELETE'].includes(request.method)) { + return withCors(Response.json({ error: 'Not found' }, { status: 404 }), origin); + } + + const id = env.TIMER_SCHEDULE.idFromName(scheduleId); + const response = await env.TIMER_SCHEDULE.get(id).fetch(request); + return withCors(response, origin); + }, +} satisfies ExportedHandler; diff --git a/workers/timer-notifications/src/schedule-state.test.ts b/workers/timer-notifications/src/schedule-state.test.ts new file mode 100644 index 0000000..dc3f1da --- /dev/null +++ b/workers/timer-notifications/src/schedule-state.test.ts @@ -0,0 +1,47 @@ +/** @jest-environment node */ + +import { createScheduleState, shouldDeferAlarm } from './schedule-state'; + +describe('timer schedule state', () => { + const now = Date.UTC(2026, 7, 22, 12, 0, 0); + + it('accepts an upcoming schedule and preserves its delivery details', () => { + const schedule = createScheduleState( + { + capability: 'capability-token', + endAt: now + 60_000, + title: 'Focus', + deepLink: '/visual-timer/', + subscription: { + endpoint: 'https://push.example.test/subscription', + keys: { auth: 'auth-key', p256dh: 'public-key' }, + }, + visibleUntil: now + 15_000, + }, + now + ); + + expect(schedule.status).toBe('scheduled'); + expect(schedule.endAt).toBe(now + 60_000); + expect(shouldDeferAlarm(schedule, now + 10_000)).toBe(true); + }); + + it('rejects schedules outside the supported time window', () => { + expect(() => + createScheduleState( + { + capability: 'capability-token', + endAt: now - 1, + title: 'Focus', + deepLink: '/visual-timer/', + subscription: { + endpoint: 'https://push.example.test/subscription', + keys: { auth: 'auth-key', p256dh: 'public-key' }, + }, + visibleUntil: null, + }, + now + ) + ).toThrow('endAt must be in the future'); + }); +}); diff --git a/workers/timer-notifications/src/schedule-state.ts b/workers/timer-notifications/src/schedule-state.ts new file mode 100644 index 0000000..80de4ef --- /dev/null +++ b/workers/timer-notifications/src/schedule-state.ts @@ -0,0 +1,42 @@ +export type PushSubscriptionData = { + endpoint: string; + keys: { + auth: string; + p256dh: string; + }; +}; + +export type ScheduleInput = { + capability: string; + endAt: number; + title: string; + deepLink: string; + subscription: PushSubscriptionData; + visibleUntil: number | null; +}; + +export type ScheduleState = ScheduleInput & { + status: 'scheduled' | 'delivered' | 'cancelled'; +}; + +const MAX_SCHEDULE_DELAY_MS = 24 * 60 * 60 * 1000; + +export const createScheduleState = (input: ScheduleInput, now = Date.now()): ScheduleState => { + if (input.endAt <= now) { + throw new Error('endAt must be in the future'); + } + + if (input.endAt - now > MAX_SCHEDULE_DELAY_MS) { + throw new Error('endAt must be within 24 hours'); + } + + if (!input.capability || !input.title || !input.deepLink || !input.subscription.endpoint) { + throw new Error('schedule is missing required fields'); + } + + return { ...input, status: 'scheduled' }; +}; + +export const shouldDeferAlarm = (schedule: ScheduleState, now = Date.now()) => { + return schedule.status === 'scheduled' && (schedule.visibleUntil ?? 0) > now; +}; diff --git a/workers/timer-notifications/src/timer-schedule.ts b/workers/timer-notifications/src/timer-schedule.ts new file mode 100644 index 0000000..95d8d79 --- /dev/null +++ b/workers/timer-notifications/src/timer-schedule.ts @@ -0,0 +1,106 @@ +import webpush from 'web-push'; +import { DurableObject } from 'cloudflare:workers'; +import { createScheduleState, shouldDeferAlarm } from './schedule-state'; +import type { ScheduleState, TimerScheduleEnv } from './types'; + +const SCHEDULE_STORAGE_KEY = 'schedule'; +const VISIBLE_GRACE_MS = 5_000; + +const json = (body: unknown, status = 200) => { + return Response.json(body, { status }); +}; + +export class TimerSchedule extends DurableObject { + async fetch(request: Request) { + if (request.method === 'PUT') { + return this.replace(request); + } + + if (request.method === 'PATCH') { + return this.refreshVisibleLease(request); + } + + if (request.method === 'DELETE') { + return this.cancel(request); + } + + return json({ error: 'Method not allowed' }, 405); + } + + async alarm() { + const schedule = await this.ctx.storage.get(SCHEDULE_STORAGE_KEY); + if (!schedule || schedule.status !== 'scheduled') return; + + if (shouldDeferAlarm(schedule)) { + await this.ctx.storage.setAlarm(Date.now() + VISIBLE_GRACE_MS); + return; + } + + await this.ctx.storage.put(SCHEDULE_STORAGE_KEY, { ...schedule, status: 'delivered' }); + + webpush.setVapidDetails(this.env.VAPID_SUBJECT, this.env.VAPID_PUBLIC_KEY, this.env.VAPID_PRIVATE_KEY); + + try { + await webpush.sendNotification( + schedule.subscription, + JSON.stringify({ + type: 'timer-finished', + timerId: this.ctx.id.toString(), + title: schedule.title, + deepLink: schedule.deepLink, + endAt: schedule.endAt, + }), + { TTL: 60, urgency: 'high' } + ); + } catch (error) { + const statusCode = error instanceof webpush.WebPushError ? error.statusCode : undefined; + if (statusCode === 404 || statusCode === 410) { + await this.ctx.storage.delete(SCHEDULE_STORAGE_KEY); + return; + } + + throw error; + } + } + + private async replace(request: Request) { + try { + const input = await request.json[0]>(); + const schedule = createScheduleState(input); + await this.ctx.storage.put(SCHEDULE_STORAGE_KEY, schedule); + await this.ctx.storage.setAlarm(schedule.endAt); + return json({ status: schedule.status, endAt: schedule.endAt }, 201); + } catch (error) { + return json({ error: error instanceof Error ? error.message : 'Invalid schedule' }, 400); + } + } + + private async refreshVisibleLease(request: Request) { + const schedule = await this.ctx.storage.get(SCHEDULE_STORAGE_KEY); + if (!schedule || schedule.status !== 'scheduled') { + return json({ error: 'Schedule not found' }, 404); + } + + const { capability, visibleUntil } = await request.json<{ capability?: string; visibleUntil?: number }>(); + if (capability !== schedule.capability || !Number.isFinite(visibleUntil)) { + return json({ error: 'Unauthorized' }, 401); + } + + await this.ctx.storage.put(SCHEDULE_STORAGE_KEY, { ...schedule, visibleUntil: Number(visibleUntil) }); + return json({ status: 'scheduled', visibleUntil }); + } + + private async cancel(request: Request) { + const schedule = await this.ctx.storage.get(SCHEDULE_STORAGE_KEY); + if (!schedule) return new Response(null, { status: 204 }); + + const { capability } = await request.json<{ capability?: string }>(); + if (capability !== schedule.capability) { + return json({ error: 'Unauthorized' }, 401); + } + + await this.ctx.storage.deleteAll(); + await this.ctx.storage.deleteAlarm(); + return new Response(null, { status: 204 }); + } +} diff --git a/workers/timer-notifications/src/types.ts b/workers/timer-notifications/src/types.ts new file mode 100644 index 0000000..76a64a8 --- /dev/null +++ b/workers/timer-notifications/src/types.ts @@ -0,0 +1,13 @@ +import type { PushSubscriptionData, ScheduleInput, ScheduleState } from './schedule-state'; + +export type { PushSubscriptionData, ScheduleInput, ScheduleState }; + +export type TimerScheduleEnv = { + VAPID_PUBLIC_KEY: string; + VAPID_PRIVATE_KEY: string; + VAPID_SUBJECT: string; +}; + +export type WorkerEnv = TimerScheduleEnv & { + TIMER_SCHEDULE: DurableObjectNamespace; +}; diff --git a/workers/timer-notifications/tsconfig.json b/workers/timer-notifications/tsconfig.json new file mode 100644 index 0000000..517f7c8 --- /dev/null +++ b/workers/timer-notifications/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "types": [] + }, + "include": ["src/**/*.ts", "../../worker-configuration.d.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/workers/timer-notifications/wrangler.jsonc b/workers/timer-notifications/wrangler.jsonc new file mode 100644 index 0000000..5ef87a1 --- /dev/null +++ b/workers/timer-notifications/wrangler.jsonc @@ -0,0 +1,24 @@ +{ + "name": "visual-timer-notifications", + "main": "src/index.ts", + "compatibility_date": "2026-08-22", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true, + "head_sampling_rate": 1 + }, + "durable_objects": { + "bindings": [ + { + "name": "TIMER_SCHEDULE", + "class_name": "TimerSchedule" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TimerSchedule"] + } + ] +}