From de3301995853564de8f507c6748e7539f36b9c9e Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 15:29:23 +0900 Subject: [PATCH 01/21] fix: Persist custom audio previews --- .../settings/fields/AlarmSelector.tsx | 274 +++++++++--------- src/store/settingsStore.test.ts | 36 +++ src/store/settingsStore.ts | 23 +- src/utils/audioPreviewController.test.ts | 29 ++ src/utils/audioPreviewController.ts | 29 ++ 5 files changed, 258 insertions(+), 133 deletions(-) create mode 100644 src/store/settingsStore.test.ts create mode 100644 src/utils/audioPreviewController.test.ts create mode 100644 src/utils/audioPreviewController.ts 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/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 }; +}; From 70585b3caef8c52ef8ed095af7b091bd72ffb99b Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 16:22:36 +0900 Subject: [PATCH 02/21] docs: Plan reliable timer notifications --- ...reliable-background-timer-notifications.md | 280 ++++++++++++++++++ ...e-background-timer-notifications-design.md | 64 ++++ 2 files changed, 344 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-22-reliable-background-timer-notifications.md create mode 100644 docs/superpowers/specs/2026-08-22-reliable-background-timer-notifications-design.md 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. From 7e5c5124e9e453d8d3d2f84739df126f41a164a9 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 16:34:58 +0900 Subject: [PATCH 03/21] feat: Add durable timer push scheduler --- jest.config.cjs | 4 +- package-lock.json | 2384 ++++++++++++++--- package.json | 10 +- workers/timer-notifications/.dev.vars.example | 3 + workers/timer-notifications/src/index.ts | 51 + .../src/schedule-state.test.ts | 47 + .../timer-notifications/src/schedule-state.ts | 42 + .../timer-notifications/src/timer-schedule.ts | 106 + workers/timer-notifications/src/types.ts | 13 + workers/timer-notifications/tsconfig.json | 13 + workers/timer-notifications/wrangler.jsonc | 20 + 11 files changed, 2309 insertions(+), 384 deletions(-) create mode 100644 workers/timer-notifications/.dev.vars.example create mode 100644 workers/timer-notifications/src/index.ts create mode 100644 workers/timer-notifications/src/schedule-state.test.ts create mode 100644 workers/timer-notifications/src/schedule-state.ts create mode 100644 workers/timer-notifications/src/timer-schedule.ts create mode 100644 workers/timer-notifications/src/types.ts create mode 100644 workers/timer-notifications/tsconfig.json create mode 100644 workers/timer-notifications/wrangler.jsonc 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..d3d9d5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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..aede35e 100644 --- a/package.json +++ b/package.json @@ -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/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..e38da62 --- /dev/null +++ b/workers/timer-notifications/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "visual-timer-notifications", + "main": "src/index.ts", + "compatibility_date": "2026-08-22", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { + "name": "TIMER_SCHEDULE", + "class_name": "TimerSchedule" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TimerSchedule"] + } + ] +} From 7e0981812c3ec5cd3b6db8a26e5af88bca58fcb6 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 16:35:18 +0900 Subject: [PATCH 04/21] feat: Add browser push subscription client --- src/services/timerNotificationService.test.ts | 11 +++++++++ src/services/timerNotificationService.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/services/timerNotificationService.test.ts create mode 100644 src/services/timerNotificationService.ts diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts new file mode 100644 index 0000000..00d8efd --- /dev/null +++ b/src/services/timerNotificationService.test.ts @@ -0,0 +1,11 @@ +import { base64UrlToUint8Array, getNotificationSupport } from './timerNotificationService'; + +describe('timer notification service', () => { + 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); + }); +}); diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts new file mode 100644 index 0000000..ab7d8e6 --- /dev/null +++ b/src/services/timerNotificationService.ts @@ -0,0 +1,24 @@ +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; +}; + +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, + }); +}; From 375c16403b3f4c02014d87e331abeb0b9fdbc740 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 16:37:39 +0900 Subject: [PATCH 05/21] fix: Derive timer state from end time --- src/hooks/useTimer.ts | 22 ++++++++++++++++------ src/utils/timerDeadline.test.ts | 11 +++++++++++ src/utils/timerDeadline.ts | 3 +++ 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 src/utils/timerDeadline.test.ts create mode 100644 src/utils/timerDeadline.ts diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index 8a5cf5a..1d35b44 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -3,6 +3,7 @@ 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 { useWakeLock } from './useWakeLock'; type TimerOptions = { @@ -87,6 +88,7 @@ export function useTimer({ // State to handle tab visibility change event const lastUpdateTimeRef = useRef(Date.now()); const wasRunningRef = useRef(false); + const endAtRef = useRef(null); // Calculate maximum possible count value if maxTime is given const maxCountStart = maxTime ? maxTime * currentUnit.multiple : undefined; @@ -102,29 +104,34 @@ export function useTimer({ stopCountdown(); finishTriggeredRef.current = false; wasRunningRef.current = false; + endAtRef.current = null; setCount(countStart); setIsInitialized(true); }, [stopCountdown, setCount, countStart]); // 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; onFinish(resetCountdown); } - - decrement(); lastUpdateTimeRef.current = Date.now(); - }, [count, decrement, resetCountdown, onFinish]); + }, [intervalMs, onFinish, resetCountdown, setCount]); // 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(() => { + endAtRef.current = Date.now() + count * intervalMs; startCountdown(); setIsInitialized(false); - }, [startCountdown]); + }, [count, intervalMs, startCountdown]); // Sets a new time for the countdown and resets it const handleSetTime = useCallback( @@ -133,6 +140,7 @@ export function useTimer({ const newCountStart = validatedTime * currentUnit.multiple; setTime(validatedTime); setCount(newCountStart); + endAtRef.current = null; setIsInitialized(true); stopCountdown(); }, @@ -144,6 +152,7 @@ export function useTimer({ const newCountStart = time * (isMinutes ? timerUnits.seconds.multiple : timerUnits.minutes.multiple); toggleIsMinutes(); setCount(newCountStart); + endAtRef.current = null; setIsInitialized(true); stopCountdown(); }, [setCount, stopCountdown, time, isMinutes]); @@ -157,8 +166,9 @@ export function useTimer({ } newCountStart = Math.max(newCountStart, 0); setCount(newCountStart); + if (endAtRef.current) endAtRef.current = Date.now() + newCountStart * intervalMs; }, - [count, maxCountStart, currentUnit.multiple, setCount] + [count, maxCountStart, currentUnit.multiple, intervalMs, setCount] ); const currentTime = useMemo(() => { diff --git a/src/utils/timerDeadline.test.ts b/src/utils/timerDeadline.test.ts new file mode 100644 index 0000000..2f081d9 --- /dev/null +++ b/src/utils/timerDeadline.test.ts @@ -0,0 +1,11 @@ +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('clamps expired timers to zero', () => { + expect(getRemainingCount(10_000, 1_000, 10_000)).toBe(0); + }); +}); diff --git a/src/utils/timerDeadline.ts b/src/utils/timerDeadline.ts new file mode 100644 index 0000000..1f274c7 --- /dev/null +++ b/src/utils/timerDeadline.ts @@ -0,0 +1,3 @@ +export const getRemainingCount = (endAt: number, intervalMs: number, now = Date.now()) => { + return Math.max(0, Math.ceil((endAt - now) / intervalMs)); +}; From 99989c6b9eecd59c4c9773d38ebd3d93d4f3ac9b Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 16:39:53 +0900 Subject: [PATCH 06/21] feat: Render timer completion push notifications --- src/service-worker.ts | 226 +++++++++++---------- src/utils/timerNotificationPayload.test.ts | 14 ++ src/utils/timerNotificationPayload.ts | 18 ++ 3 files changed, 149 insertions(+), 109 deletions(-) create mode 100644 src/utils/timerNotificationPayload.test.ts create mode 100644 src/utils/timerNotificationPayload.ts diff --git a/src/service-worker.ts b/src/service-worker.ts index 97d5f50..297574e 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -6,6 +6,7 @@ import { ExpirationPlugin } from 'workbox-expiration'; import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'; import { registerRoute } from 'workbox-routing'; import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'; +import { createFinishedNotification, type TimerFinishedPayload } from './utils/timerNotificationPayload'; declare const self: ServiceWorkerGlobalScope; @@ -16,52 +17,51 @@ 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')}`; + 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; + timeoutId?: number | ReturnType; + intervalId?: number | ReturnType; }; const activeTimers: Record = {}; @@ -70,109 +70,117 @@ 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]; - } + 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; + if (!event.source) return; - const clientId = (event.source as Client).id; - const { command, timer, endTime } = event.data || {}; + const clientId = (event.source as Client).id; + const { command, timer, endTime } = event.data || {}; - if (!timer || !timer.id) return; + if (!timer || !timer.id) return; - if (command === 'start-timer') { - const endTimeMs = Number(endTime); - clearTimerHandles(timer.id); + if (command === 'start-timer') { + const endTimeMs = Number(endTime); + clearTimerHandles(timer.id); - const remainingTime = Math.max(0, endTimeMs - Date.now()); + 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 }); + 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); } - }, remainingTime); - } - 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; + 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); } - 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); + 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); + } + })(); } - - 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 hadClientOpen = clientList.some((client) => { - if (client.url.includes('/visual-timer') && 'focus' in client) { - return (client as WindowClient).focus(); - } - return false; - }); + 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(); + } + return false; + }); - if (!hadClientOpen && self.clients.openWindow) { - await self.clients.openWindow('/visual-timer/'); - } + if (!hadClientOpen && self.clients.openWindow) { + await self.clients.openWindow('/visual-timer/'); + } }; self.addEventListener('notificationclick', (event) => { - event.notification.close(); - event.waitUntil(navigateToApp()); + event.notification.close(); + event.waitUntil(navigateToApp()); +}); + +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/utils/timerNotificationPayload.test.ts b/src/utils/timerNotificationPayload.test.ts new file mode 100644 index 0000000..01ba12b --- /dev/null +++ b/src/utils/timerNotificationPayload.test.ts @@ -0,0 +1,14 @@ +import { createFinishedNotification } 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/' } } }); + }); +}); diff --git a/src/utils/timerNotificationPayload.ts b/src/utils/timerNotificationPayload.ts new file mode 100644 index 0000000..a450cf5 --- /dev/null +++ b/src/utils/timerNotificationPayload.ts @@ -0,0 +1,18 @@ +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 }, + }, +}); From cf956abeb3691b4b67364ad0ef35e52076dbfe16 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:08:06 +0900 Subject: [PATCH 07/21] fix: Replace background timer polling with status alerts --- src/service-worker.ts | 113 +++------------------ src/utils/timerNotificationPayload.test.ts | 9 +- src/utils/timerNotificationPayload.ts | 11 ++ 3 files changed, 35 insertions(+), 98 deletions(-) diff --git a/src/service-worker.ts b/src/service-worker.ts index 297574e..84058d3 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -6,7 +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, type TimerFinishedPayload } from './utils/timerNotificationPayload'; +import { + createFinishedNotification, + createRunningStatusNotification, + type TimerFinishedPayload, +} from './utils/timerNotificationPayload'; declare const self: ServiceWorkerGlobalScope; @@ -51,106 +55,21 @@ self.addEventListener('message', (event) => { } }); -// 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; + const { command, timerId, title, endAt } = event.data || {}; + if (!timerId) return; - if (command === 'start-timer') { - const endTimeMs = Number(endTime); - clearTimerHandles(timer.id); - - 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); - } - - 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); - } - - activeTimers[timer.id] = { timeoutId, intervalId }; - } else if (command === 'clear-timer') { - clearTimerHandles(timer.id); + 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)); + } - void (async () => { - try { - const notifications = await self.registration.getNotifications({ tag: timer.id }); + if (command === 'clear-running-status') { + event.waitUntil( + self.registration.getNotifications({ tag: `running-${timerId}` }).then((notifications) => { notifications.forEach((notification) => notification.close()); - } catch (err) { - console.error('Error closing notifications:', err); - } - })(); + }) + ); } }); diff --git a/src/utils/timerNotificationPayload.test.ts b/src/utils/timerNotificationPayload.test.ts index 01ba12b..da67146 100644 --- a/src/utils/timerNotificationPayload.test.ts +++ b/src/utils/timerNotificationPayload.test.ts @@ -1,4 +1,4 @@ -import { createFinishedNotification } from './timerNotificationPayload'; +import { createFinishedNotification, createRunningStatusNotification } from './timerNotificationPayload'; describe('timer notification payload', () => { it('creates a tagged completion notification', () => { @@ -11,4 +11,11 @@ describe('timer notification payload', () => { }) ).toMatchObject({ title: 'Focus complete', options: { tag: 'timer-1', data: { deepLink: '/visual-timer/' } } }); }); + + it('creates one silent 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', silent: true }, + }); + }); }); diff --git a/src/utils/timerNotificationPayload.ts b/src/utils/timerNotificationPayload.ts index a450cf5..3f13bd3 100644 --- a/src/utils/timerNotificationPayload.ts +++ b/src/utils/timerNotificationPayload.ts @@ -16,3 +16,14 @@ export const createFinishedNotification = (payload: TimerFinishedPayload) => ({ 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}`, + silent: true, + timestamp: endAt, + }, +}); From cecb90f32485b831424e0ad892e923c354ad6678 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:10:47 +0900 Subject: [PATCH 08/21] feat: Schedule background timer notifications --- src/hooks/useTimer.ts | 154 +++++++++--------- src/services/timerNotificationService.test.ts | 8 +- src/services/timerNotificationService.ts | 75 +++++++++ vite.config.ts | 101 ++++++------ 4 files changed, 211 insertions(+), 127 deletions(-) diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index 1d35b44..e67c9cd 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -4,6 +4,7 @@ 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, scheduleTimerNotification } from '../services/timerNotificationService'; import { useWakeLock } from './useWakeLock'; type TimerOptions = { @@ -85,29 +86,47 @@ 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 postServiceWorkerMessage = useCallback((message: Record) => { + navigator.serviceWorker.controller?.postMessage(message); + }, []); + + 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); + postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); setCount(countStart); setIsInitialized(true); - }, [stopCountdown, setCount, countStart]); + }, [countStart, postServiceWorkerMessage, setCount, stopCountdown, timer.id]); // The callback for the countdown logic const countdownCallback = useCallback(() => { @@ -118,10 +137,12 @@ export function useTimer({ if (remainingCount === 0 && onFinish && !finishTriggeredRef.current) { finishTriggeredRef.current = true; + stopCountdown(); + void cancelTimerNotification(timer.id); + postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); onFinish(resetCountdown); } - lastUpdateTimeRef.current = Date.now(); - }, [intervalMs, onFinish, resetCountdown, setCount]); + }, [intervalMs, onFinish, postServiceWorkerMessage, resetCountdown, setCount, stopCountdown, timer.id]); // useInterval hook triggers the countdown logic when the timer is running useInterval(countdownCallback, isRunning ? intervalMs : null); @@ -131,7 +152,8 @@ export function useTimer({ endAtRef.current = Date.now() + count * intervalMs; startCountdown(); setIsInitialized(false); - }, [count, intervalMs, startCountdown]); + scheduleBackgroundNotification(isDocumentVisible ? Date.now() + 15_000 : null); + }, [count, intervalMs, isDocumentVisible, scheduleBackgroundNotification, startCountdown]); // Sets a new time for the countdown and resets it const handleSetTime = useCallback( @@ -141,10 +163,12 @@ export function useTimer({ setTime(validatedTime); setCount(newCountStart); endAtRef.current = null; + void cancelTimerNotification(timer.id); + postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); setIsInitialized(true); stopCountdown(); }, - [setCount, stopCountdown, currentUnit.multiple, maxTime] + [currentUnit.multiple, maxTime, postServiceWorkerMessage, setCount, stopCountdown, timer.id] ); // Toggles between minutes and seconds mode @@ -153,9 +177,11 @@ export function useTimer({ toggleIsMinutes(); setCount(newCountStart); endAtRef.current = null; + void cancelTimerNotification(timer.id); + postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); setIsInitialized(true); stopCountdown(); - }, [setCount, stopCountdown, time, isMinutes]); + }, [isMinutes, postServiceWorkerMessage, setCount, stopCountdown, time, timer.id]); // Function to add a specific time to the current count const add = useCallback( @@ -166,9 +192,20 @@ export function useTimer({ } newCountStart = Math.max(newCountStart, 0); setCount(newCountStart); - if (endAtRef.current) endAtRef.current = Date.now() + newCountStart * intervalMs; + if (endAtRef.current) { + endAtRef.current = Date.now() + newCountStart * intervalMs; + scheduleBackgroundNotification(isDocumentVisible ? Date.now() + 15_000 : null); + } }, - [count, maxCountStart, currentUnit.multiple, intervalMs, setCount] + [ + count, + currentUnit.multiple, + intervalMs, + isDocumentVisible, + maxCountStart, + scheduleBackgroundNotification, + setCount, + ] ); const currentTime = useMemo(() => { @@ -182,75 +219,24 @@ export function useTimer({ 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; + setIsDocumentVisible(false); 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, - }); - } - } - } 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, + scheduleBackgroundNotification(null); + postServiceWorkerMessage({ + command: 'show-running-status', + timerId: timer.id, + title: timer.title || 'Timer', + endAt: endAtRef.current, }); } + } else if (document.visibilityState === 'visible') { + setIsDocumentVisible(true); + postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + countdownCallback(); } }; @@ -259,15 +245,29 @@ export function useTimer({ return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [count, isRunning, intervalMs, startCountdown, stopCountdown, setCount]); + }, [countdownCallback, isRunning, postServiceWorkerMessage, scheduleBackgroundNotification, timer.id, timer.title]); + + useInterval( + () => scheduleBackgroundNotification(Date.now() + 15_000), + isRunning && isDocumentVisible ? 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); + postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + }, [postServiceWorkerMessage, stopCountdown, timer.id]); + return { totalTime: time, count, @@ -278,7 +278,7 @@ export function useTimer({ isMinutes, isInitialized, start, - stop: stopCountdown, + stop, reset: resetCountdown, toggleUnit, setTime: handleSetTime, diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts index 00d8efd..6e55d32 100644 --- a/src/services/timerNotificationService.test.ts +++ b/src/services/timerNotificationService.test.ts @@ -1,4 +1,4 @@ -import { base64UrlToUint8Array, getNotificationSupport } from './timerNotificationService'; +import { base64UrlToUint8Array, createScheduleCredentials, getNotificationSupport } from './timerNotificationService'; describe('timer notification service', () => { it('converts a URL-safe VAPID key into subscription bytes', () => { @@ -8,4 +8,10 @@ describe('timer notification service', () => { 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' }); + }); }); diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts index ab7d8e6..c4c95ab 100644 --- a/src/services/timerNotificationService.ts +++ b/src/services/timerNotificationService.ts @@ -8,6 +8,80 @@ export const getNotificationSupport = (navigatorValue: Navigator = navigator) => return 'serviceWorker' in navigatorValue && typeof PushManager !== 'undefined' && 'Notification' in window; }; +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 = sessionStorage.getItem(scheduleCredentialsKey(timerId)); + if (existing) return JSON.parse(existing) as ScheduleCredentials; + + const credentials = { scheduleId: timerId, capability: createToken() }; + sessionStorage.setItem(scheduleCredentialsKey(timerId), JSON.stringify(credentials)); + return credentials; +}; + +const getApiBaseUrl = () => { + return typeof __TIMER_NOTIFICATION_API_URL__ === 'string' && __TIMER_NOTIFICATION_API_URL__ + ? __TIMER_NOTIFICATION_API_URL__ + : undefined; +}; + +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 = getApiBaseUrl(); + 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 = getApiBaseUrl(); + const storedCredentials = sessionStorage.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 }), + }); + sessionStorage.removeItem(scheduleCredentialsKey(timerId)); +}; + export const requestPushSubscription = async (apiBaseUrl: string) => { if (!getNotificationSupport()) return null; @@ -22,3 +96,4 @@ export const requestPushSubscription = async (apiBaseUrl: string) => { applicationServerKey: applicationServerKey.buffer as ArrayBuffer, }); }; +declare const __TIMER_NOTIFICATION_API_URL__: string | undefined; diff --git a/vite.config.ts b/vite.config.ts index c5871ac..3464374 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,53 +4,56 @@ 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/', + define: { + __TIMER_NOTIFICATION_API_URL__: JSON.stringify(process.env.VITE_TIMER_NOTIFICATION_API_URL || ''), + }, + 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, + }, }); From 36ac8d354bd84e0895219b1d0314720e56f3dd97 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:13:20 +0900 Subject: [PATCH 09/21] feat: Add background alert controls --- .../settings/sections/AlarmSettings.tsx | 64 +++++++++- src/index.tsx | 111 ++++++++---------- src/services/timerNotificationService.test.ts | 13 +- src/services/timerNotificationService.ts | 18 +++ 4 files changed, 137 insertions(+), 69 deletions(-) diff --git a/src/components/settings/sections/AlarmSettings.tsx b/src/components/settings/sections/AlarmSettings.tsx index a1faf72..7f55504 100644 --- a/src/components/settings/sections/AlarmSettings.tsx +++ b/src/components/settings/sections/AlarmSettings.tsx @@ -1,11 +1,63 @@ +import { useState } from 'react'; import AlarmSelector from '../fields/AlarmSelector'; import VolumeSelector from '../fields/VolumeSelector'; +import { + enableBackgroundAlerts, + getBackgroundAlertStatus, + type BackgroundAlertStatus, +} from '../../../services/timerNotificationService'; -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 handleEnableAlerts = async () => { + setIsEnabling(true); + try { + await enableBackgroundAlerts(); + } catch (error) { + console.debug('Unable to enable background alerts:', error); + } finally { + setAlertStatus(getBackgroundAlertStatus()); + setIsEnabling(false); + } + }; + + return ( +
+ + +
+
+
+

Background alerts

+

{alertStatusCopy[alertStatus]}

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

+ 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/index.tsx b/src/index.tsx index 485781a..d20fd27 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,72 +9,59 @@ import MainPage from './pages/MainPage'; import NotFoundPage from './pages/NotFoundPage'; 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 - ); + 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 +69,11 @@ 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, + onNeedRefresh() { + console.debug('New content available, updating service worker.'); + }, + onOfflineReady() { + console.debug('App ready to work offline.'); + }, }); diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts index 6e55d32..f73a80e 100644 --- a/src/services/timerNotificationService.test.ts +++ b/src/services/timerNotificationService.test.ts @@ -1,4 +1,9 @@ -import { base64UrlToUint8Array, createScheduleCredentials, getNotificationSupport } from './timerNotificationService'; +import { + base64UrlToUint8Array, + createScheduleCredentials, + getBackgroundAlertStatus, + getNotificationSupport, +} from './timerNotificationService'; describe('timer notification service', () => { it('converts a URL-safe VAPID key into subscription bytes', () => { @@ -14,4 +19,10 @@ describe('timer notification service', () => { expect(credentials).toEqual({ scheduleId: 'timer-1', capability: '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'); + }); }); diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts index c4c95ab..d588122 100644 --- a/src/services/timerNotificationService.ts +++ b/src/services/timerNotificationService.ts @@ -8,6 +8,18 @@ export const getNotificationSupport = (navigatorValue: Navigator = navigator) => return 'serviceWorker' in navigatorValue && typeof PushManager !== 'undefined' && 'Notification' in window; }; +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; @@ -96,4 +108,10 @@ export const requestPushSubscription = async (apiBaseUrl: string) => { applicationServerKey: applicationServerKey.buffer as ArrayBuffer, }); }; + +export const enableBackgroundAlerts = async () => { + const apiBaseUrl = getApiBaseUrl(); + if (!apiBaseUrl) return null; + return requestPushSubscription(apiBaseUrl); +}; declare const __TIMER_NOTIFICATION_API_URL__: string | undefined; From c6aeecfab49d73505b769d8050f07c4dd8faa06d Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:17:35 +0900 Subject: [PATCH 10/21] fix: Preserve background alert cancellation --- src/hooks/useTimer.ts | 3 +++ src/service-worker.ts | 16 ++++++++-------- src/services/timerNotificationService.test.ts | 5 +++++ src/services/timerNotificationService.ts | 8 ++++---- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index e67c9cd..0a45a16 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -237,6 +237,9 @@ export function useTimer({ setIsDocumentVisible(true); postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); countdownCallback(); + if (isRunning) { + scheduleBackgroundNotification(Date.now() + 15_000); + } } }; diff --git a/src/service-worker.ts b/src/service-worker.ts index 84058d3..b9efb01 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -73,27 +73,27 @@ self.addEventListener('message', (event) => { } }); -const navigateToApp = async () => { +const navigateToApp = async (deepLink = '/visual-timer/') => { const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true, }); - const hadClientOpen = clientList.some((client) => { + for (const client of clientList) { if (client.url.includes('/visual-timer') && 'focus' in client) { - return (client as WindowClient).focus(); + 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.waitUntil(navigateToApp(event.notification.data?.deepLink)); }); self.addEventListener('push', (event) => { diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts index f73a80e..1459b98 100644 --- a/src/services/timerNotificationService.test.ts +++ b/src/services/timerNotificationService.test.ts @@ -6,6 +6,10 @@ import { } from './timerNotificationService'; describe('timer notification service', () => { + afterEach(() => { + localStorage.clear(); + }); + it('converts a URL-safe VAPID key into subscription bytes', () => { expect(Array.from(base64UrlToUint8Array('AQI'))).toEqual([1, 2]); }); @@ -18,6 +22,7 @@ describe('timer notification service', () => { 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', () => { diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts index d588122..8900043 100644 --- a/src/services/timerNotificationService.ts +++ b/src/services/timerNotificationService.ts @@ -39,11 +39,11 @@ export const createScheduleCredentials = ( timerId: string, createToken = () => crypto.randomUUID() ): ScheduleCredentials => { - const existing = sessionStorage.getItem(scheduleCredentialsKey(timerId)); + const existing = localStorage.getItem(scheduleCredentialsKey(timerId)); if (existing) return JSON.parse(existing) as ScheduleCredentials; const credentials = { scheduleId: timerId, capability: createToken() }; - sessionStorage.setItem(scheduleCredentialsKey(timerId), JSON.stringify(credentials)); + localStorage.setItem(scheduleCredentialsKey(timerId), JSON.stringify(credentials)); return credentials; }; @@ -82,7 +82,7 @@ export const scheduleTimerNotification = async (request: ScheduleRequest) => { export const cancelTimerNotification = async (timerId: string) => { const apiBaseUrl = getApiBaseUrl(); - const storedCredentials = sessionStorage.getItem(scheduleCredentialsKey(timerId)); + const storedCredentials = localStorage.getItem(scheduleCredentialsKey(timerId)); if (!apiBaseUrl || !storedCredentials) return; const credentials = JSON.parse(storedCredentials) as ScheduleCredentials; @@ -91,7 +91,7 @@ export const cancelTimerNotification = async (timerId: string) => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ capability: credentials.capability }), }); - sessionStorage.removeItem(scheduleCredentialsKey(timerId)); + localStorage.removeItem(scheduleCredentialsKey(timerId)); }; export const requestPushSubscription = async (apiBaseUrl: string) => { From ea63022d616e34f5110eae692fb43ee6291eaec2 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:18:52 +0900 Subject: [PATCH 11/21] chore: Configure timer notification deployment --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) 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' From 7f864d52374e45b0fe203375b6d5bc89780a5f3a Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:33:51 +0900 Subject: [PATCH 12/21] chore: Release v0.5.0 --- CHANGELOG.md | 14 ++++++++++++++ README.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- src/versionMetadata.test.ts | 4 ++-- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5048d7b..cef040a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 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. + +### 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. + ## [0.4.1] - 2026-08-20 ### Fixed diff --git a/README.md b/README.md index 41b693d..9111042 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,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/package-lock.json b/package-lock.json index d3d9d5e..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", diff --git a/package.json b/package.json index aede35e..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", 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'); }); }); From cc758973fbabc5278bc597692200a389c4dedaed Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:50:52 +0900 Subject: [PATCH 13/21] fix: Request timer alerts on start --- .env.example | 2 ++ CHANGELOG.md | 1 + README.md | 6 ++-- .../settings/sections/AlarmSettings.tsx | 8 +++++ src/hooks/useTimer.ts | 35 +++++++++++++------ src/index.tsx | 3 ++ src/services/serviceWorkerMessages.test.ts | 26 ++++++++++++++ src/services/serviceWorkerMessages.ts | 17 +++++++++ src/services/timerNotificationService.test.ts | 24 +++++++++++++ src/services/timerNotificationService.ts | 31 ++++++++++------ vite.config.ts | 3 -- 11 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 .env.example create mode 100644 src/services/serviceWorkerMessages.test.ts create mode 100644 src/services/serviceWorkerMessages.ts 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/CHANGELOG.md b/CHANGELOG.md index cef040a..1ebaad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to Mellow Visual Timer are documented in this file. - 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. ## [0.4.1] - 2026-08-20 diff --git a/README.md b/README.md index 9111042..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 diff --git a/src/components/settings/sections/AlarmSettings.tsx b/src/components/settings/sections/AlarmSettings.tsx index 7f55504..d08eba9 100644 --- a/src/components/settings/sections/AlarmSettings.tsx +++ b/src/components/settings/sections/AlarmSettings.tsx @@ -4,6 +4,7 @@ import VolumeSelector from '../fields/VolumeSelector'; import { enableBackgroundAlerts, getBackgroundAlertStatus, + getTimerNotificationApiUrl, type BackgroundAlertStatus, } from '../../../services/timerNotificationService'; @@ -17,6 +18,7 @@ const alertStatusCopy: Record = { const AlarmSettings: React.FC = () => { const [alertStatus, setAlertStatus] = useState(() => getBackgroundAlertStatus()); const [isEnabling, setIsEnabling] = useState(false); + const isTimerNotificationApiConfigured = Boolean(getTimerNotificationApiUrl()); const handleEnableAlerts = async () => { setIsEnabling(true); @@ -51,6 +53,12 @@ const AlarmSettings: React.FC = () => { )}
+ {!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. diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index 0a45a16..81620bd 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -4,7 +4,12 @@ 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, scheduleTimerNotification } from '../services/timerNotificationService'; +import { + cancelTimerNotification, + requestBackgroundAlertsIfNeeded, + scheduleTimerNotification, +} from '../services/timerNotificationService'; +import { postServiceWorkerMessage as postToServiceWorker } from '../services/serviceWorkerMessages'; import { useWakeLock } from './useWakeLock'; type TimerOptions = { @@ -42,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. */ @@ -99,7 +104,9 @@ export function useTimer({ const { value: isRunning, setTrue: startCountdown, setFalse: stopCountdown } = useBoolean(false); const postServiceWorkerMessage = useCallback((message: Record) => { - navigator.serviceWorker.controller?.postMessage(message); + void postToServiceWorker(message).catch((error) => + console.debug('Unable to send timer status to the service worker:', error) + ); }, []); const scheduleBackgroundNotification = useCallback( @@ -148,12 +155,20 @@ export function useTimer({ useInterval(countdownCallback, isRunning ? intervalMs : null); // Function to start the countdown and mark as initialized - const start = useCallback(() => { - endAtRef.current = Date.now() + count * intervalMs; - startCountdown(); - setIsInitialized(false); - scheduleBackgroundNotification(isDocumentVisible ? Date.now() + 15_000 : null); - }, [count, intervalMs, isDocumentVisible, scheduleBackgroundNotification, 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( @@ -215,7 +230,7 @@ export function useTimer({ const progress = Math.max(0, count / currentUnit.denominator); - if (autoStart && isInitialized) start(); + if (autoStart && isInitialized) start(false); useWakeLock(isRunning); diff --git a/src/index.tsx b/src/index.tsx index d20fd27..3fc577d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,9 @@ import './index.css'; import ErrorPage from './pages/ErrorPage'; import MainPage from './pages/MainPage'; import NotFoundPage from './pages/NotFoundPage'; +import { configureTimerNotificationApiUrl } from './services/timerNotificationService'; + +configureTimerNotificationApiUrl(import.meta.env.VITE_TIMER_NOTIFICATION_API_URL); const parseLocalStorage = () => { return Object.keys(localStorage).reduce( 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 index 1459b98..22cdbf4 100644 --- a/src/services/timerNotificationService.test.ts +++ b/src/services/timerNotificationService.test.ts @@ -1,13 +1,17 @@ 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', () => { @@ -30,4 +34,24 @@ describe('timer notification service', () => { 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 only 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); + }); }); diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts index 8900043..1b00cd1 100644 --- a/src/services/timerNotificationService.ts +++ b/src/services/timerNotificationService.ts @@ -8,6 +8,16 @@ 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 = ( @@ -47,12 +57,6 @@ export const createScheduleCredentials = ( return credentials; }; -const getApiBaseUrl = () => { - return typeof __TIMER_NOTIFICATION_API_URL__ === 'string' && __TIMER_NOTIFICATION_API_URL__ - ? __TIMER_NOTIFICATION_API_URL__ - : undefined; -}; - const getActiveSubscription = async () => { if (!getNotificationSupport() || Notification.permission !== 'granted') return null; const registration = await navigator.serviceWorker.ready; @@ -60,7 +64,7 @@ const getActiveSubscription = async () => { }; export const scheduleTimerNotification = async (request: ScheduleRequest) => { - const apiBaseUrl = getApiBaseUrl(); + const apiBaseUrl = getTimerNotificationApiUrl(); const subscription = await getActiveSubscription(); if (!apiBaseUrl || !subscription) return false; @@ -81,7 +85,7 @@ export const scheduleTimerNotification = async (request: ScheduleRequest) => { }; export const cancelTimerNotification = async (timerId: string) => { - const apiBaseUrl = getApiBaseUrl(); + const apiBaseUrl = getTimerNotificationApiUrl(); const storedCredentials = localStorage.getItem(scheduleCredentialsKey(timerId)); if (!apiBaseUrl || !storedCredentials) return; @@ -110,8 +114,15 @@ export const requestPushSubscription = async (apiBaseUrl: string) => { }; export const enableBackgroundAlerts = async () => { - const apiBaseUrl = getApiBaseUrl(); + const apiBaseUrl = getTimerNotificationApiUrl(); if (!apiBaseUrl) return null; return requestPushSubscription(apiBaseUrl); }; -declare const __TIMER_NOTIFICATION_API_URL__: string | undefined; + +export const requestBackgroundAlertsIfNeeded = async ( + alertStatus = getBackgroundAlertStatus(), + requestAlerts: () => Promise = enableBackgroundAlerts +) => { + if (alertStatus !== 'needs-permission') return null; + return requestAlerts(); +}; diff --git a/vite.config.ts b/vite.config.ts index 3464374..d21b622 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,9 +5,6 @@ import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ export default defineConfig({ base: '/visual-timer/', - define: { - __TIMER_NOTIFICATION_API_URL__: JSON.stringify(process.env.VITE_TIMER_NOTIFICATION_API_URL || ''), - }, plugins: [ react(), VitePWA({ From 4ed3d01eb7e79630a9ccf2be1fe4a4900bcc3071 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 17:57:14 +0900 Subject: [PATCH 14/21] fix: Restore elapsed timer display --- CHANGELOG.md | 1 + src/hooks/useTimer.ts | 50 ++++++++++---------- src/services/timerStatusNotification.test.ts | 35 ++++++++++++++ src/services/timerStatusNotification.ts | 29 ++++++++++++ src/utils/timerDeadline.test.ts | 3 +- src/utils/timerDeadline.ts | 2 +- 6 files changed, 92 insertions(+), 28 deletions(-) create mode 100644 src/services/timerStatusNotification.test.ts create mode 100644 src/services/timerStatusNotification.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebaad4..f714be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to Mellow Visual Timer are documented in this file. - 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. ## [0.4.1] - 2026-08-20 diff --git a/src/hooks/useTimer.ts b/src/hooks/useTimer.ts index 81620bd..4b32d6a 100644 --- a/src/hooks/useTimer.ts +++ b/src/hooks/useTimer.ts @@ -9,7 +9,7 @@ import { requestBackgroundAlertsIfNeeded, scheduleTimerNotification, } from '../services/timerNotificationService'; -import { postServiceWorkerMessage as postToServiceWorker } from '../services/serviceWorkerMessages'; +import { clearRunningTimerStatus, showRunningTimerStatus } from '../services/timerStatusNotification'; import { useWakeLock } from './useWakeLock'; type TimerOptions = { @@ -103,9 +103,9 @@ export function useTimer({ // Manage the running state of the timer const { value: isRunning, setTrue: startCountdown, setFalse: stopCountdown } = useBoolean(false); - const postServiceWorkerMessage = useCallback((message: Record) => { - void postToServiceWorker(message).catch((error) => - console.debug('Unable to send timer status to the service worker:', error) + const clearRunningStatus = useCallback((timerId: string) => { + void clearRunningTimerStatus(timerId).catch((error) => + console.debug('Unable to clear running timer status:', error) ); }, []); @@ -130,10 +130,10 @@ export function useTimer({ finishTriggeredRef.current = false; endAtRef.current = null; void cancelTimerNotification(timer.id); - postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + clearRunningStatus(timer.id); setCount(countStart); setIsInitialized(true); - }, [countStart, postServiceWorkerMessage, setCount, stopCountdown, timer.id]); + }, [clearRunningStatus, countStart, setCount, stopCountdown, timer.id]); // The callback for the countdown logic const countdownCallback = useCallback(() => { @@ -142,14 +142,13 @@ export function useTimer({ const remainingCount = getRemainingCount(endAtRef.current, intervalMs); setCount(remainingCount); - if (remainingCount === 0 && onFinish && !finishTriggeredRef.current) { + if (remainingCount <= 0 && onFinish && !finishTriggeredRef.current) { finishTriggeredRef.current = true; - stopCountdown(); void cancelTimerNotification(timer.id); - postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + clearRunningStatus(timer.id); onFinish(resetCountdown); } - }, [intervalMs, onFinish, postServiceWorkerMessage, resetCountdown, setCount, stopCountdown, timer.id]); + }, [clearRunningStatus, intervalMs, onFinish, resetCountdown, setCount, timer.id]); // useInterval hook triggers the countdown logic when the timer is running useInterval(countdownCallback, isRunning ? intervalMs : null); @@ -179,11 +178,11 @@ export function useTimer({ setCount(newCountStart); endAtRef.current = null; void cancelTimerNotification(timer.id); - postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + clearRunningStatus(timer.id); setIsInitialized(true); stopCountdown(); }, - [currentUnit.multiple, maxTime, postServiceWorkerMessage, setCount, stopCountdown, timer.id] + [clearRunningStatus, currentUnit.multiple, maxTime, setCount, stopCountdown, timer.id] ); // Toggles between minutes and seconds mode @@ -193,10 +192,10 @@ export function useTimer({ setCount(newCountStart); endAtRef.current = null; void cancelTimerNotification(timer.id); - postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + clearRunningStatus(timer.id); setIsInitialized(true); stopCountdown(); - }, [isMinutes, postServiceWorkerMessage, setCount, stopCountdown, time, timer.id]); + }, [clearRunningStatus, isMinutes, setCount, stopCountdown, time, timer.id]); // Function to add a specific time to the current count const add = useCallback( @@ -239,18 +238,17 @@ export function useTimer({ const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { setIsDocumentVisible(false); - if (isRunning) { + if (isRunning && !finishTriggeredRef.current) { scheduleBackgroundNotification(null); - postServiceWorkerMessage({ - command: 'show-running-status', - timerId: timer.id, - title: timer.title || 'Timer', - endAt: endAtRef.current, - }); + 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') { setIsDocumentVisible(true); - postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); + clearRunningStatus(timer.id); countdownCallback(); if (isRunning) { scheduleBackgroundNotification(Date.now() + 15_000); @@ -263,11 +261,11 @@ export function useTimer({ return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [countdownCallback, isRunning, postServiceWorkerMessage, scheduleBackgroundNotification, timer.id, timer.title]); + }, [clearRunningStatus, countdownCallback, isRunning, scheduleBackgroundNotification, timer.id, timer.title]); useInterval( () => scheduleBackgroundNotification(Date.now() + 15_000), - isRunning && isDocumentVisible ? 5_000 : null + isRunning && isDocumentVisible && !finishTriggeredRef.current ? 5_000 : null ); // Reset timer with new input data @@ -283,8 +281,8 @@ export function useTimer({ stopCountdown(); endAtRef.current = null; void cancelTimerNotification(timer.id); - postServiceWorkerMessage({ command: 'clear-running-status', timerId: timer.id }); - }, [postServiceWorkerMessage, stopCountdown, timer.id]); + clearRunningStatus(timer.id); + }, [clearRunningStatus, stopCountdown, timer.id]); return { totalTime: time, diff --git a/src/services/timerStatusNotification.test.ts b/src/services/timerStatusNotification.test.ts new file mode 100644 index 0000000..00e7af5 --- /dev/null +++ b/src/services/timerStatusNotification.test.ts @@ -0,0 +1,35 @@ +import { clearRunningTimerStatus, showRunningTimerStatus } from './timerStatusNotification'; + +describe('timer status notifications', () => { + it('shows the running status directly through the ready service worker registration', async () => { + const showNotification = jest.fn().mockResolvedValue(undefined); + const registration = { showNotification } as unknown as ServiceWorkerRegistration; + + await expect(showRunningTimerStatus('timer-1', 'Focus', 10_000, registration, 'granted')).resolves.toBe(true); + + expect(showNotification).toHaveBeenCalledWith( + 'Focus running', + expect.objectContaining({ body: expect.stringContaining('Ends at'), tag: 'running-timer-1' }) + ); + }); + + it('does not show a status notification before permission is granted', async () => { + const showNotification = jest.fn(); + const registration = { showNotification } as unknown as ServiceWorkerRegistration; + + await expect(showRunningTimerStatus('timer-1', 'Focus', 10_000, registration, 'default')).resolves.toBe(false); + expect(showNotification).not.toHaveBeenCalled(); + }); + + it('clears the matching running status notification', async () => { + const close = jest.fn(); + const registration = { + getNotifications: jest.fn().mockResolvedValue([{ close }]), + } as unknown as ServiceWorkerRegistration; + + await clearRunningTimerStatus('timer-1', registration); + + expect(registration.getNotifications).toHaveBeenCalledWith({ tag: 'running-timer-1' }); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/src/services/timerStatusNotification.ts b/src/services/timerStatusNotification.ts new file mode 100644 index 0000000..a482646 --- /dev/null +++ b/src/services/timerStatusNotification.ts @@ -0,0 +1,29 @@ +import { createRunningStatusNotification } from '../utils/timerNotificationPayload'; + +type RegistrationSource = ServiceWorkerRegistration | Promise; + +const getReadyRegistration = () => navigator.serviceWorker.ready; + +export const showRunningTimerStatus = async ( + timerId: string, + title: string, + endAt: number, + registrationSource: RegistrationSource = getReadyRegistration(), + permission: NotificationPermission = Notification.permission +) => { + if (permission !== 'granted') return false; + + const registration = await registrationSource; + const notification = createRunningStatusNotification(timerId, title, endAt); + await registration.showNotification(notification.title, notification.options); + return true; +}; + +export const clearRunningTimerStatus = async ( + timerId: string, + registrationSource: RegistrationSource = getReadyRegistration() +) => { + const registration = await registrationSource; + const notifications = await registration.getNotifications({ tag: `running-${timerId}` }); + notifications.forEach((notification) => notification.close()); +}; diff --git a/src/utils/timerDeadline.test.ts b/src/utils/timerDeadline.test.ts index 2f081d9..253bff6 100644 --- a/src/utils/timerDeadline.test.ts +++ b/src/utils/timerDeadline.test.ts @@ -5,7 +5,8 @@ describe('timer deadline', () => { expect(getRemainingCount(10_000, 1_000, 4_200)).toBe(6); }); - it('clamps expired timers to zero', () => { + 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 index 1f274c7..259ca46 100644 --- a/src/utils/timerDeadline.ts +++ b/src/utils/timerDeadline.ts @@ -1,3 +1,3 @@ export const getRemainingCount = (endAt: number, intervalMs: number, now = Date.now()) => { - return Math.max(0, Math.ceil((endAt - now) / intervalMs)); + return Math.ceil((endAt - now) / intervalMs); }; From c33c33193122a73e7b6360cd035da2c1fc3755c5 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 18:00:24 +0900 Subject: [PATCH 15/21] fix: Surface running timer alerts --- src/utils/timerNotificationPayload.test.ts | 4 ++-- src/utils/timerNotificationPayload.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/timerNotificationPayload.test.ts b/src/utils/timerNotificationPayload.test.ts index da67146..eced236 100644 --- a/src/utils/timerNotificationPayload.test.ts +++ b/src/utils/timerNotificationPayload.test.ts @@ -12,10 +12,10 @@ describe('timer notification payload', () => { ).toMatchObject({ title: 'Focus complete', options: { tag: 'timer-1', data: { deepLink: '/visual-timer/' } } }); }); - it('creates one silent running-status notification with an end time', () => { + 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', silent: true }, + options: { tag: 'running-timer-1', renotify: true }, }); }); }); diff --git a/src/utils/timerNotificationPayload.ts b/src/utils/timerNotificationPayload.ts index 3f13bd3..49bc6fb 100644 --- a/src/utils/timerNotificationPayload.ts +++ b/src/utils/timerNotificationPayload.ts @@ -23,7 +23,7 @@ export const createRunningStatusNotification = (timerId: string, title: string, body: `Ends at ${new Date(endAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`, icon: '/visual-timer/logo512.png', tag: `running-${timerId}`, - silent: true, + renotify: true, timestamp: endAt, }, }); From 0d8285061cdd03affcc79c69759c67549fd05f8f Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 18:42:11 +0900 Subject: [PATCH 16/21] fix: Register service worker in development --- src/index.tsx | 3 +++ vite.config.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index 3fc577d..17f7f88 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -73,6 +73,9 @@ root.render(); // Register Service Worker with auto-update registerSW({ immediate: true, + onRegisterError(error) { + console.error('Service worker registration failed:', error); + }, onNeedRefresh() { console.debug('New content available, updating service worker.'); }, diff --git a/vite.config.ts b/vite.config.ts index d21b622..1989ad8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ devOptions: { enabled: true, type: 'module', + navigateFallback: 'index.html', }, }), ], From afff35306e9a7907e84e7d34b2f34c7f9161969d Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 18:42:34 +0900 Subject: [PATCH 17/21] fix: Subscribe to completion alerts after permission grant --- src/index.tsx | 12 ++++++++++ src/services/timerNotificationService.test.ts | 23 ++++++++++++++++++- src/services/timerNotificationService.ts | 7 ++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 17f7f88..7f86cf7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,18 @@ import { configureTimerNotificationApiUrl } from './services/timerNotificationSe 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) + ); + } + }, + { once: true } +); + const parseLocalStorage = () => { return Object.keys(localStorage).reduce( (acc, key) => { diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts index 22cdbf4..7a6c2d7 100644 --- a/src/services/timerNotificationService.test.ts +++ b/src/services/timerNotificationService.test.ts @@ -46,7 +46,7 @@ describe('timer notification service', () => { expect(getTimerNotificationApiUrl()).toBe('https://worker.example'); }); - it('requests alerts only when permission has not been decided', async () => { + 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'); @@ -54,4 +54,25 @@ describe('timer notification service', () => { 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 index 1b00cd1..b946662 100644 --- a/src/services/timerNotificationService.ts +++ b/src/services/timerNotificationService.ts @@ -121,8 +121,11 @@ export const enableBackgroundAlerts = async () => { export const requestBackgroundAlertsIfNeeded = async ( alertStatus = getBackgroundAlertStatus(), - requestAlerts: () => Promise = enableBackgroundAlerts + requestAlerts: () => Promise = enableBackgroundAlerts, + getSubscription: () => Promise = getActiveSubscription ) => { - if (alertStatus !== 'needs-permission') return null; + if (alertStatus === 'unsupported' || alertStatus === 'denied') return null; + if (alertStatus === 'enabled' && (await getSubscription())) return null; + return requestAlerts(); }; From 120d915717f8f460a624e4e08d4843439a10dd0b Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 18:42:56 +0900 Subject: [PATCH 18/21] feat: Add background alert test control --- .../settings/sections/AlarmSettings.tsx | 23 ++++++++++ src/services/timerStatusNotification.test.ts | 46 +++++++++++-------- src/services/timerStatusNotification.ts | 28 ++++++----- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/components/settings/sections/AlarmSettings.tsx b/src/components/settings/sections/AlarmSettings.tsx index d08eba9..0e1187a 100644 --- a/src/components/settings/sections/AlarmSettings.tsx +++ b/src/components/settings/sections/AlarmSettings.tsx @@ -7,6 +7,7 @@ import { getTimerNotificationApiUrl, type BackgroundAlertStatus, } from '../../../services/timerNotificationService'; +import { showTestRunningTimerStatus } from '../../../services/timerStatusNotification'; const alertStatusCopy: Record = { unsupported: 'Background alerts are not supported in this browser.', @@ -18,6 +19,7 @@ const alertStatusCopy: Record = { 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 () => { @@ -32,6 +34,17 @@ const AlarmSettings: React.FC = () => { } }; + const handleTestAlert = async () => { + setIsTesting(true); + try { + await showTestRunningTimerStatus(); + } catch (error) { + console.debug('Unable to show test background alert:', error); + } finally { + setIsTesting(false); + } + }; + return (

@@ -52,6 +65,16 @@ const AlarmSettings: React.FC = () => { {isEnabling ? 'Enabling…' : 'Enable alerts'} )} + {alertStatus === 'enabled' && ( + + )}
{!isTimerNotificationApiConfigured && (

diff --git a/src/services/timerStatusNotification.test.ts b/src/services/timerStatusNotification.test.ts index 00e7af5..0e55ef5 100644 --- a/src/services/timerStatusNotification.test.ts +++ b/src/services/timerStatusNotification.test.ts @@ -1,35 +1,41 @@ -import { clearRunningTimerStatus, showRunningTimerStatus } from './timerStatusNotification'; +import { clearRunningTimerStatus, showRunningTimerStatus, showTestRunningTimerStatus } from './timerStatusNotification'; describe('timer status notifications', () => { - it('shows the running status directly through the ready service worker registration', async () => { - const showNotification = jest.fn().mockResolvedValue(undefined); - const registration = { showNotification } as unknown as ServiceWorkerRegistration; + 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, registration, 'granted')).resolves.toBe(true); + await expect(showRunningTimerStatus('timer-1', 'Focus', 10_000, postMessage, 'granted')).resolves.toBe(true); - expect(showNotification).toHaveBeenCalledWith( - 'Focus running', - expect.objectContaining({ body: expect.stringContaining('Ends at'), tag: 'running-timer-1' }) - ); + 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 showNotification = jest.fn(); - const registration = { showNotification } as unknown as ServiceWorkerRegistration; + const postMessage = jest.fn(); - await expect(showRunningTimerStatus('timer-1', 'Focus', 10_000, registration, 'default')).resolves.toBe(false); - expect(showNotification).not.toHaveBeenCalled(); + 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 close = jest.fn(); - const registration = { - getNotifications: jest.fn().mockResolvedValue([{ close }]), - } as unknown as ServiceWorkerRegistration; + 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 clearRunningTimerStatus('timer-1', registration); + await expect(showTestRunningTimerStatus(postMessage, 'granted', 10_000)).resolves.toBe(true); - expect(registration.getNotifications).toHaveBeenCalledWith({ tag: 'running-timer-1' }); - expect(close).toHaveBeenCalled(); + 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 index a482646..c03f356 100644 --- a/src/services/timerStatusNotification.ts +++ b/src/services/timerStatusNotification.ts @@ -1,29 +1,27 @@ -import { createRunningStatusNotification } from '../utils/timerNotificationPayload'; +import { postServiceWorkerMessage } from './serviceWorkerMessages'; -type RegistrationSource = ServiceWorkerRegistration | Promise; +type PostMessage = (message: Record) => Promise; -const getReadyRegistration = () => navigator.serviceWorker.ready; +const TEST_TIMER_ID = 'background-alert-test'; export const showRunningTimerStatus = async ( timerId: string, title: string, endAt: number, - registrationSource: RegistrationSource = getReadyRegistration(), + postMessage: PostMessage = postServiceWorkerMessage, permission: NotificationPermission = Notification.permission ) => { if (permission !== 'granted') return false; - const registration = await registrationSource; - const notification = createRunningStatusNotification(timerId, title, endAt); - await registration.showNotification(notification.title, notification.options); - return true; + return postMessage({ command: 'show-running-status', timerId, title, endAt }); }; -export const clearRunningTimerStatus = async ( - timerId: string, - registrationSource: RegistrationSource = getReadyRegistration() -) => { - const registration = await registrationSource; - const notifications = await registration.getNotifications({ tag: `running-${timerId}` }); - notifications.forEach((notification) => notification.close()); +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); From 2961d4318b0d547ef80b58beaa5f60156a73ee1e Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 18:43:17 +0900 Subject: [PATCH 19/21] fix: Tighten About setting item spacing --- src/components/settings/fields/DonateField.tsx | 2 +- src/components/settings/fields/FeedbackField.tsx | 2 +- src/components/settings/fields/VersionField.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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" > From dcd418d64e85858d29bac59712bce9f5a65731a3 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 19:08:37 +0900 Subject: [PATCH 20/21] chore: Enable Worker observability --- workers/timer-notifications/wrangler.jsonc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/workers/timer-notifications/wrangler.jsonc b/workers/timer-notifications/wrangler.jsonc index e38da62..5ef87a1 100644 --- a/workers/timer-notifications/wrangler.jsonc +++ b/workers/timer-notifications/wrangler.jsonc @@ -3,6 +3,10 @@ "main": "src/index.ts", "compatibility_date": "2026-08-22", "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true, + "head_sampling_rate": 1 + }, "durable_objects": { "bindings": [ { From 2308b0ef48865b66c6d8788185c7f6717dae7ea2 Mon Sep 17 00:00:00 2001 From: do0ori Date: Sat, 22 Aug 2026 19:08:56 +0900 Subject: [PATCH 21/21] chore: Update v0.5.0 release notes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f714be5..d26d5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ All notable changes to Mellow Visual Timer are documented in this file. - 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 @@ -17,6 +22,9 @@ All notable changes to Mellow Visual Timer are documented in this file. - 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