(
+ () => defaultPreferences
+ );
+
+ const [isEnabling, setIsEnabling] = useState(false);
+
+ // Update translations when locale changes
+ useEffect(() => {
+ setPreferences((current) =>
+ current.map((pref) => {
+ const defaultPref = defaultPreferences.find(
+ (d) => d.category === pref.category
+ );
+ return defaultPref
+ ? {
+ ...pref,
+ label: defaultPref.label,
+ description: defaultPref.description,
+ }
+ : pref;
+ })
+ );
+ }, [defaultPreferences]);
+
+ // Load preferences from localStorage
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ try {
+ const parsed = JSON.parse(stored);
+ // Validate that parsed data is an array
+ if (!Array.isArray(parsed)) {
+ console.warn(
+ "Invalid notification preferences format, using defaults"
+ );
+ return;
+ }
+
+ setPreferences((current) =>
+ current.map((pref) => {
+ const storedPref = parsed.find(
+ (p: NotificationPreference) =>
+ p &&
+ typeof p === "object" &&
+ p.category === pref.category &&
+ typeof p.enabled === "boolean"
+ );
+ return storedPref
+ ? { ...pref, enabled: storedPref.enabled }
+ : pref;
+ })
+ );
+ } catch (parseError) {
+ console.error(
+ "Failed to parse notification preferences, using defaults:",
+ parseError
+ );
+ // Clear corrupted data
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Ignore removal errors (e.g., SecurityError in private mode)
+ }
+ }
+ }
+ } catch (error) {
+ // Handle QuotaExceededError or SecurityError when accessing localStorage
+ console.error(
+ "Failed to load notification preferences (storage access denied):",
+ error
+ );
+ }
+ }, []);
+
+ // Save preferences to localStorage synchronously for immediate error feedback
+ const savePreferences = (newPreferences: NotificationPreference[]) => {
+ setPreferences(newPreferences);
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(newPreferences));
+ } catch (error) {
+ // Handle QuotaExceededError or SecurityError
+ if (error instanceof Error) {
+ if (error.name === "QuotaExceededError") {
+ console.error(
+ "Failed to save notification preferences: Storage quota exceeded"
+ );
+ // Optionally notify user about storage issues
+ } else if (error.name === "SecurityError") {
+ console.error(
+ "Failed to save notification preferences: Storage access denied (private mode?)"
+ );
+ } else {
+ console.error("Failed to save notification preferences:", error);
+ }
+ }
+ }
+ };
+
+ // Handle enabling notifications
+ const handleEnableNotifications = async () => {
+ setIsEnabling(true);
+ try {
+ const result = await requestPermission();
+ if (result === "granted") {
+ await showNotification({
+ title: _(msg`Notifications Enabled`),
+ body: _(msg`You'll now receive important updates from SecPal`),
+ tag: "welcome-notification",
+ });
+ }
+ } catch (error) {
+ console.error("Failed to enable notifications:", error);
+ } finally {
+ setIsEnabling(false);
+ }
+ };
+
+ // Handle toggling a preference
+ const handleTogglePreference = (category: NotificationCategory) => {
+ const newPreferences = preferences.map((pref) =>
+ pref.category === category ? { ...pref, enabled: !pref.enabled } : pref
+ );
+ savePreferences(newPreferences);
+ };
+
+ // Handle sending a test notification
+ const handleTestNotification = async () => {
+ if (permission !== "granted") return;
+
+ try {
+ await showNotification({
+ title: _(msg`Test Notification`),
+ body: _(msg`This is a test notification from SecPal`),
+ tag: "test-notification",
+ requireInteraction: false,
+ });
+ } catch (error) {
+ console.error("Failed to send test notification:", error);
+ }
+ };
+
+ if (!isSupported) {
+ return (
+
+
+
+ Notifications are not supported in your browser. Please use a modern
+ browser like Chrome, Firefox, or Safari.
+
+
+
+ );
+ }
+
+ if (permission === "denied") {
+ return (
+
+
+
+ Notifications have been blocked. Please enable them in your browser
+ settings to receive important updates.
+
+
+
+ );
+ }
+
+ if (permission === "default") {
+ return (
+
+
+
+
+ Enable notifications to receive important updates about security
+ alerts, shift reminders, and system notifications.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Notification Preferences
+
+
+ Choose which notifications you want to receive
+
+
+
+
+
+
+
+
+
+
+ โ Notifications are enabled. You'll receive updates based on your
+ preferences.
+
+
+
+
+ );
+}
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts
new file mode 100644
index 0000000..4d9c1b8
--- /dev/null
+++ b/src/hooks/useNotifications.test.ts
@@ -0,0 +1,313 @@
+// SPDX-FileCopyrightText: 2025 SecPal
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { useNotifications } from "./useNotifications";
+
+// Mock Notification API
+const mockNotification = vi.fn();
+const mockServiceWorkerRegistration = {
+ showNotification: vi.fn().mockResolvedValue(undefined),
+};
+
+describe("useNotifications", () => {
+ beforeEach(() => {
+ // Setup Notification mock
+ globalThis.Notification = mockNotification as never;
+ Object.defineProperty(globalThis.Notification, "permission", {
+ writable: true,
+ value: "default",
+ });
+ globalThis.Notification.requestPermission = vi
+ .fn()
+ .mockResolvedValue("granted");
+
+ // Setup Service Worker mock
+ Object.defineProperty(navigator, "serviceWorker", {
+ value: {
+ ready: Promise.resolve(mockServiceWorkerRegistration),
+ },
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("initialization", () => {
+ it("should initialize with correct default values", () => {
+ const { result } = renderHook(() => useNotifications());
+
+ expect(result.current.permission).toBe("default");
+ expect(result.current.isSupported).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("should detect unsupported browsers", () => {
+ // Remove Notification from window
+ const originalNotification = globalThis.Notification;
+ // @ts-expect-error - intentionally testing unsupported environment
+ delete globalThis.Notification;
+
+ const { result } = renderHook(() => useNotifications());
+
+ expect(result.current.isSupported).toBe(false);
+
+ // Restore
+ globalThis.Notification = originalNotification;
+ });
+ });
+
+ describe("requestPermission", () => {
+ it("should request and update permission state", async () => {
+ const { result } = renderHook(() => useNotifications());
+
+ let permissionResult: string | undefined;
+ await act(async () => {
+ permissionResult = await result.current.requestPermission();
+ });
+
+ expect(permissionResult).toBe("granted");
+ expect(result.current.permission).toBe("granted");
+ expect(globalThis.Notification.requestPermission).toHaveBeenCalledOnce();
+ });
+
+ it("should handle denied permission", async () => {
+ globalThis.Notification.requestPermission = vi
+ .fn()
+ .mockResolvedValue("denied");
+
+ const { result } = renderHook(() => useNotifications());
+
+ let permissionResult: string | undefined;
+ await act(async () => {
+ permissionResult = await result.current.requestPermission();
+ });
+
+ expect(permissionResult).toBe("denied");
+ expect(result.current.permission).toBe("denied");
+ });
+
+ it("should throw error if notifications not supported", async () => {
+ // Remove Notification from window
+ const originalNotification = globalThis.Notification;
+ // @ts-expect-error - intentionally testing unsupported environment
+ delete globalThis.Notification;
+
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await expect(result.current.requestPermission()).rejects.toThrow(
+ "Notifications are not supported"
+ );
+ });
+
+ // Restore
+ globalThis.Notification = originalNotification;
+ });
+
+ it("should handle permission request errors", async () => {
+ const testError = new Error("Permission request failed");
+ globalThis.Notification.requestPermission = vi
+ .fn()
+ .mockRejectedValue(testError);
+
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await expect(result.current.requestPermission()).rejects.toThrow(
+ testError
+ );
+ });
+
+ expect(result.current.error).toBe(testError);
+ });
+ });
+
+ describe("showNotification", () => {
+ beforeEach(() => {
+ Object.defineProperty(globalThis.Notification, "permission", {
+ writable: true,
+ value: "granted",
+ });
+ });
+
+ it("should show notification via service worker", async () => {
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await result.current.showNotification({
+ title: "Test Notification",
+ body: "This is a test",
+ });
+ });
+
+ expect(
+ mockServiceWorkerRegistration.showNotification
+ ).toHaveBeenCalledWith(
+ "Test Notification",
+ expect.objectContaining({
+ body: "This is a test",
+ icon: "/pwa-192x192.svg",
+ badge: "/pwa-192x192.svg",
+ })
+ );
+ });
+
+ it("should include custom options", async () => {
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await result.current.showNotification({
+ title: "Custom Notification",
+ body: "With options",
+ icon: "/custom-icon.png",
+ tag: "custom-tag",
+ requireInteraction: true,
+ data: { id: 123 },
+ });
+ });
+
+ expect(
+ mockServiceWorkerRegistration.showNotification
+ ).toHaveBeenCalledWith(
+ "Custom Notification",
+ expect.objectContaining({
+ body: "With options",
+ icon: "/custom-icon.png",
+ tag: "custom-tag",
+ requireInteraction: true,
+ data: { id: 123 },
+ })
+ );
+ });
+
+ it("should throw error if permission not granted", async () => {
+ Object.defineProperty(globalThis.Notification, "permission", {
+ writable: true,
+ value: "denied",
+ });
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await expect(
+ result.current.showNotification({
+ title: "Test",
+ body: "Should fail",
+ })
+ ).rejects.toThrow("Notification permission not granted");
+ });
+ });
+
+ it("should fallback to browser notification if service worker unavailable", async () => {
+ // Mock service worker without showNotification
+ Object.defineProperty(navigator, "serviceWorker", {
+ value: {
+ ready: Promise.resolve({}),
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await result.current.showNotification({
+ title: "Fallback Notification",
+ body: "Using browser API",
+ });
+ });
+
+ expect(mockNotification).toHaveBeenCalledWith("Fallback Notification", {
+ body: "Using browser API",
+ icon: "/pwa-192x192.svg",
+ tag: undefined,
+ requireInteraction: undefined,
+ data: undefined,
+ });
+ });
+
+ it("should handle notification errors", async () => {
+ const testError = new Error("Notification failed");
+ mockServiceWorkerRegistration.showNotification.mockRejectedValueOnce(
+ testError
+ );
+
+ const { result } = renderHook(() => useNotifications());
+
+ await act(async () => {
+ await expect(
+ result.current.showNotification({
+ title: "Test",
+ body: "Should fail",
+ })
+ ).rejects.toThrow(testError);
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).toBe(testError);
+ });
+ });
+ });
+
+ describe("loading states", () => {
+ it("should set loading state during permission request", async () => {
+ let resolvePermission: (value: string) => void;
+ const permissionPromise = new Promise((resolve) => {
+ resolvePermission = resolve;
+ });
+
+ globalThis.Notification.requestPermission = vi
+ .fn()
+ .mockReturnValue(permissionPromise);
+
+ const { result } = renderHook(() => useNotifications());
+
+ act(() => {
+ result.current.requestPermission();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ await act(async () => {
+ resolvePermission!("granted");
+ await permissionPromise;
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it("should set loading state during notification display", async () => {
+ Object.defineProperty(globalThis.Notification, "permission", {
+ writable: true,
+ value: "granted",
+ }); // Ensure permission is granted
+
+ mockServiceWorkerRegistration.showNotification.mockResolvedValueOnce(
+ undefined
+ );
+
+ const { result } = renderHook(() => useNotifications());
+
+ // Notification should complete successfully
+ await act(async () => {
+ await result.current.showNotification({
+ title: "Test",
+ body: "Loading test",
+ });
+ });
+
+ // After completion, loading should be false
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+});
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
new file mode 100644
index 0000000..d4589e5
--- /dev/null
+++ b/src/hooks/useNotifications.ts
@@ -0,0 +1,170 @@
+// SPDX-FileCopyrightText: 2025 SecPal
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { useState, useEffect, useCallback } from "react";
+
+export type NotificationPermissionState = "default" | "granted" | "denied";
+
+export interface NotificationOptions {
+ title: string;
+ body: string;
+ icon?: string;
+ badge?: string;
+ tag?: string;
+ requireInteraction?: boolean;
+ data?: Record;
+}
+
+interface UseNotificationsReturn {
+ permission: NotificationPermissionState;
+ isSupported: boolean;
+ requestPermission: () => Promise;
+ showNotification: (options: NotificationOptions) => Promise;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+/**
+ * Hook for managing push notifications in the app
+ * Handles permission requests, subscription management, and notification display
+ *
+ * @example
+ * ```tsx
+ * const { permission, requestPermission, showNotification } = useNotifications();
+ *
+ * const handleSubscribe = async () => {
+ * const state = await requestPermission();
+ * if (state === "granted") {
+ * await showNotification({
+ * title: "Welcome!",
+ * body: "You'll now receive important updates"
+ * });
+ * }
+ * };
+ * ```
+ */
+export function useNotifications(): UseNotificationsReturn {
+ const [permission, setPermission] =
+ useState("default");
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Check if notifications are supported
+ const isSupported =
+ typeof window !== "undefined" &&
+ "Notification" in window &&
+ "serviceWorker" in navigator;
+
+ // Initialize permission state
+ useEffect(() => {
+ if (isSupported) {
+ setPermission(Notification.permission);
+ }
+ // Note: Permission changes are rare (user must manually change in browser settings)
+ // We don't poll for changes to avoid performance overhead
+ // Permission state is updated after requestPermission() is called
+ }, [isSupported]);
+
+ /**
+ * Request notification permission from the user
+ */
+ const requestPermission =
+ useCallback(async (): Promise => {
+ if (!isSupported) {
+ const err = new Error(
+ "Notifications are not supported in this browser"
+ );
+ setError(err);
+ throw err;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const result = await Notification.requestPermission();
+ setPermission(result);
+ return result;
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ setError(error);
+ throw error;
+ } finally {
+ setIsLoading(false);
+ }
+ }, [isSupported]);
+
+ /**
+ * Show a notification to the user
+ * Falls back to browser notification if service worker is unavailable
+ */
+ const showNotification = useCallback(
+ async (options: NotificationOptions): Promise => {
+ if (!isSupported) {
+ throw new Error("Notifications are not supported");
+ }
+
+ if (permission !== "granted") {
+ throw new Error("Notification permission not granted");
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Try to use service worker notification first (preferred)
+ const registration = await navigator.serviceWorker.ready;
+
+ if (registration && registration.showNotification) {
+ await registration.showNotification(options.title, {
+ body: options.body,
+ icon: options.icon || "/pwa-192x192.svg",
+ badge: options.badge || "/pwa-192x192.svg",
+ tag: options.tag,
+ requireInteraction: options.requireInteraction,
+ data: options.data,
+ });
+ } else {
+ // Fallback to regular notification
+ new Notification(options.title, {
+ body: options.body,
+ icon: options.icon || "/pwa-192x192.svg",
+ tag: options.tag,
+ requireInteraction: options.requireInteraction,
+ data: options.data,
+ });
+ }
+ } catch (err) {
+ // Handle specific notification errors
+ const error = err instanceof Error ? err : new Error(String(err));
+
+ if (error.name === "SecurityError") {
+ console.error(
+ "Notification failed due to security error (cross-origin or insecure context):",
+ error
+ );
+ } else if (error.name === "NotAllowedError") {
+ console.error(
+ "Notification blocked by user or browser policy:",
+ error
+ );
+ }
+
+ setError(error);
+ throw error;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [isSupported, permission]
+ );
+
+ return {
+ permission,
+ isSupported,
+ requestPermission,
+ showNotification,
+ isLoading,
+ error,
+ };
+}
diff --git a/src/hooks/useShareTarget.test.ts b/src/hooks/useShareTarget.test.ts
new file mode 100644
index 0000000..63cd2f6
--- /dev/null
+++ b/src/hooks/useShareTarget.test.ts
@@ -0,0 +1,250 @@
+// SPDX-FileCopyrightText: 2025 SecPal
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useShareTarget } from "./useShareTarget";
+
+describe("useShareTarget", () => {
+ const originalHistory = window.history;
+
+ beforeEach(() => {
+ // Mock window.location using vi.stubGlobal
+ vi.stubGlobal("location", {
+ href: "https://secpal.app/",
+ pathname: "/",
+ search: "",
+ hash: "",
+ });
+
+ // Mock window.history
+ vi.stubGlobal("history", {
+ ...originalHistory,
+ replaceState: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it("should initialize with default values", () => {
+ const { result } = renderHook(() => useShareTarget());
+
+ expect(result.current.sharedData).toBeNull();
+ expect(typeof result.current.clearSharedData).toBe("function");
+ });
+
+ it("should detect shared text data", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?title=Hello&text=World&url=https://example.com",
+ pathname: "/share",
+ search: "?title=Hello&text=World&url=https://example.com",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toEqual({
+ title: "Hello",
+ text: "World",
+ url: "https://example.com",
+ });
+ });
+
+ // URL cleanup: cleanUrl="/", hash=""
+ expect(window.history.replaceState).toHaveBeenCalledWith({}, "", "/");
+ });
+
+ it("should handle partial shared data", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?text=SharedText",
+ pathname: "/share",
+ search: "?text=SharedText",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toEqual({
+ title: undefined,
+ text: "SharedText",
+ url: undefined,
+ });
+ });
+ });
+
+ it("should handle URL encoded data", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?title=Hello%20World&text=Test%20%26%20More",
+ pathname: "/share",
+ search: "?title=Hello%20World&text=Test%20%26%20More",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toEqual({
+ title: "Hello World",
+ text: "Test & More",
+ url: undefined,
+ });
+ });
+ });
+
+ it("should not detect share when not on /share path", () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/home?title=Hello",
+ pathname: "/home",
+ search: "?title=Hello",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ expect(result.current.sharedData).toBeNull();
+ expect(window.history.replaceState).not.toHaveBeenCalled();
+ });
+
+ it("should not detect share when no search params", () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share",
+ pathname: "/share",
+ search: "",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ expect(result.current.sharedData).toBeNull();
+ expect(window.history.replaceState).not.toHaveBeenCalled();
+ });
+
+ it("should clear shared data", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?text=Test",
+ pathname: "/share",
+ search: "?text=Test",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toEqual({
+ title: undefined,
+ text: "Test",
+ url: undefined,
+ });
+ });
+
+ result.current.clearSharedData();
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toBeNull();
+ });
+ });
+
+ it("should handle multiple share events", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?text=First",
+ pathname: "/share",
+ search: "?text=First",
+ hash: "",
+ } as Location;
+
+ const { result, rerender } = renderHook(() => useShareTarget());
+
+ await waitFor(() => {
+ expect(result.current.sharedData?.text).toBe("First");
+ });
+
+ result.current.clearSharedData();
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toBeNull();
+ });
+
+ // Simulate new share
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?text=Second",
+ pathname: "/share",
+ search: "?text=Second",
+ hash: "",
+ } as Location;
+
+ rerender();
+
+ // Note: In real implementation, this would need the component to remount
+ // or have a different trigger mechanism
+ });
+
+ it("should handle empty string values", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?title=&text=NotEmpty",
+ pathname: "/share",
+ search: "?title=&text=NotEmpty",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ await waitFor(() => {
+ expect(result.current.sharedData).toEqual({
+ title: undefined, // Empty string should be undefined
+ text: "NotEmpty",
+ url: undefined,
+ });
+ });
+ });
+
+ it("should handle shared data processing", async () => {
+ // @ts-expect-error - Mocking location for tests
+ window.location = {
+ ...window.location,
+ href: "https://secpal.app/share?text=Test",
+ pathname: "/share",
+ search: "?text=Test",
+ hash: "",
+ } as Location;
+
+ const { result } = renderHook(() => useShareTarget());
+
+ // Data should be parsed and available
+ await waitFor(() => {
+ expect(result.current.sharedData).not.toBeNull();
+ });
+ });
+
+ it("should work in SSR environment", () => {
+ // The hook has a guard: if (typeof window === "undefined") return default values
+ // Since we can't actually delete window in this test environment,
+ // we verify that it returns null when not on the /share path
+ const { result } = renderHook(() => useShareTarget());
+
+ // Should have default values since we're not on /share
+ expect(result.current.sharedData).toBeNull();
+ });
+});
diff --git a/src/hooks/useShareTarget.ts b/src/hooks/useShareTarget.ts
new file mode 100644
index 0000000..dc9a13c
--- /dev/null
+++ b/src/hooks/useShareTarget.ts
@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: 2025 SecPal
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { useState, useEffect } from "react";
+
+/**
+ * Data structure for shared content received via Share Target API
+ * Note: File sharing is not yet implemented. The hook currently only handles
+ * text-based sharing (title, text, url). File support planned for Issue #101.
+ */
+export interface SharedData {
+ title?: string;
+ text?: string;
+ url?: string;
+ // files?: File[]; // Planned for future implementation (Issue #101)
+}
+
+interface UseShareTargetReturn {
+ sharedData: SharedData | null;
+ clearSharedData: () => void;
+}
+
+/**
+ * Hook for handling data shared to the PWA from other apps
+ * Automatically detects when the app is opened via Share Target API
+ *
+ * @example
+ * ```tsx
+ * const { isSharing, sharedData, clearSharedData } = useShareTarget();
+ *
+ * useEffect(() => {
+ * if (sharedData) {
+ * // Handle the shared data
+ * processSharedData(sharedData);
+ * clearSharedData();
+ * }
+ * }, [sharedData]);
+ * ```
+ */
+export function useShareTarget(): UseShareTargetReturn {
+ const [sharedData, setSharedData] = useState(null);
+
+ useEffect(() => {
+ // Only run in browser
+ if (typeof window === "undefined") return;
+
+ const handleShareTarget = () => {
+ try {
+ const url = new URL(window.location.href);
+
+ // Check if this is a share target navigation
+ if (url.pathname === "/share" && url.searchParams.size > 0) {
+ // Parse share data with explicit null/empty checks
+ const title = url.searchParams.get("title");
+ const text = url.searchParams.get("text");
+ const urlParam = url.searchParams.get("url");
+
+ const data: SharedData = {
+ title: title !== null && title !== "" ? title : undefined,
+ text: text !== null && text !== "" ? text : undefined,
+ url: urlParam !== null && urlParam !== "" ? urlParam : undefined,
+ };
+
+ // Handle files from POST request (if available)
+ // Note: Files are typically handled via formData in the Service Worker
+ // This is a simplified client-side version for GET-based sharing
+
+ setSharedData(data);
+
+ // Clean up URL without the share parameters (preserve hash)
+ // Only update history if replaceState is available
+ if (window.history?.replaceState) {
+ window.history.replaceState(
+ {},
+ "",
+ window.location.pathname === "/share"
+ ? "/" + window.location.hash
+ : window.location.pathname + window.location.hash
+ );
+ }
+ }
+ } catch (error) {
+ console.error("Failed to process share target:", error);
+ }
+ };
+
+ handleShareTarget();
+
+ // Listen for navigation events (popstate) to detect URL changes for multiple shares
+ window.addEventListener("popstate", handleShareTarget);
+
+ // Clean up event listener on unmount
+ return () => {
+ window.removeEventListener("popstate", handleShareTarget);
+ };
+ }, []);
+
+ const clearSharedData = () => {
+ setSharedData(null);
+ };
+
+ return {
+ sharedData,
+ clearSharedData,
+ };
+}
diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts
new file mode 100644
index 0000000..f5a32e3
--- /dev/null
+++ b/src/lib/analytics.test.ts
@@ -0,0 +1,408 @@
+// SPDX-FileCopyrightText: 2025 SecPal
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { analytics } from "./analytics";
+import { db } from "./db";
+
+// Mock IndexedDB
+vi.mock("./db", () => ({
+ db: {
+ analytics: {
+ add: vi.fn().mockResolvedValue(1),
+ where: vi.fn(() => ({
+ equals: vi.fn(() => ({
+ toArray: vi.fn().mockResolvedValue([]),
+ and: vi.fn(() => ({
+ delete: vi.fn().mockResolvedValue(0),
+ })),
+ })),
+ })),
+ toArray: vi.fn().mockResolvedValue([]),
+ bulkUpdate: vi.fn().mockResolvedValue(0),
+ },
+ },
+}));
+
+describe("OfflineAnalytics", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.spyOn(console, "log").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("initialization", () => {
+ it("should generate a unique session ID", () => {
+ // Access the private sessionId through a test method if needed
+ // For now, we'll test functionality that depends on it
+ expect(analytics).toBeDefined();
+ });
+
+ it("should set userId", () => {
+ analytics!.setUserId("test-user-123");
+ // User ID will be included in subsequent events
+ });
+ });
+
+ describe("track", () => {
+ it("should track basic event", async () => {
+ await analytics!.track("page_view", "navigation", "view_home");
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "page_view",
+ category: "navigation",
+ action: "view_home",
+ synced: false,
+ timestamp: expect.any(Number),
+ sessionId: expect.any(String),
+ })
+ );
+ });
+
+ it("should track event with options", async () => {
+ await analytics!.track("button_click", "interaction", "submit", {
+ label: "login-button",
+ value: 1,
+ metadata: { page: "/login" },
+ });
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "button_click",
+ category: "interaction",
+ action: "submit",
+ label: "login-button",
+ value: 1,
+ metadata: { page: "/login" },
+ })
+ );
+ });
+
+ it("should include userId if set", async () => {
+ analytics!.setUserId("user-456");
+
+ await analytics!.track("page_view", "navigation", "view_dashboard");
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: "user-456",
+ })
+ );
+ });
+
+ it("should handle tracking errors gracefully", async () => {
+ const consoleError = vi.spyOn(console, "error");
+ const error = new Error("Database error");
+ vi.mocked(db.analytics!.add).mockRejectedValueOnce(error);
+
+ await analytics!.track("page_view", "test", "test");
+
+ expect(consoleError).toHaveBeenCalledWith(
+ "Failed to track analytics event:",
+ error
+ );
+ });
+ });
+
+ describe("convenience methods", () => {
+ it("should track page view", async () => {
+ await analytics!.trackPageView("/dashboard", "Dashboard");
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "page_view",
+ category: "navigation",
+ action: "page_view",
+ label: "/dashboard",
+ metadata: { title: "Dashboard" },
+ })
+ );
+ });
+
+ it("should track click", async () => {
+ await analytics!.trackClick("submit-button", { form: "login" });
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "button_click",
+ category: "interaction",
+ action: "click",
+ label: "submit-button",
+ metadata: { form: "login" },
+ })
+ );
+ });
+
+ it("should track form submit", async () => {
+ await analytics!.trackFormSubmit("login-form", true, { method: "email" });
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "form_submit",
+ category: "interaction",
+ action: "form_submit",
+ label: "login-form",
+ value: 1,
+ metadata: { method: "email" },
+ })
+ );
+ });
+
+ it("should track form submit failure", async () => {
+ await analytics!.trackFormSubmit("login-form", false);
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: 0,
+ })
+ );
+ });
+
+ it("should track error without stack by default", async () => {
+ const error = new Error("Test error");
+ await analytics!.trackError(error, { component: "LoginForm" });
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "error",
+ category: "error",
+ action: "Error",
+ label: "Test error",
+ metadata: expect.objectContaining({
+ component: "LoginForm",
+ }),
+ })
+ );
+
+ // Ensure stack is NOT included by default
+ const call = vi.mocked(db.analytics!.add).mock.calls[0]?.[0];
+ expect(call?.metadata).not.toHaveProperty("stack");
+ });
+
+ it("should track error with stack when explicitly requested", async () => {
+ const error = new Error("Test error with stack");
+ await analytics!.trackError(error, { component: "LoginForm" }, true);
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "error",
+ category: "error",
+ action: "Error",
+ label: "Test error with stack",
+ metadata: expect.objectContaining({
+ component: "LoginForm",
+ stack: expect.any(String),
+ }),
+ })
+ );
+ });
+
+ it("should track performance", async () => {
+ await analytics!.trackPerformance("page_load", 1234, { page: "/home" });
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "performance",
+ category: "performance",
+ action: "page_load",
+ value: 1234,
+ metadata: { page: "/home" },
+ })
+ );
+ });
+
+ it("should track feature usage", async () => {
+ await analytics!.trackFeatureUsage("dark-mode", { enabled: true });
+
+ expect(db.analytics!.add).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "feature_usage",
+ category: "feature",
+ action: "use",
+ label: "dark-mode",
+ metadata: { enabled: true },
+ })
+ );
+ });
+ });
+
+ describe("syncEvents", () => {
+ it("should sync unsynced events when online", async () => {
+ const mockEvents = [
+ {
+ id: 1,
+ type: "page_view",
+ category: "test",
+ action: "test",
+ synced: false,
+ timestamp: Date.now(),
+ sessionId: "test",
+ },
+ {
+ id: 2,
+ type: "button_click",
+ category: "test",
+ action: "test",
+ synced: false,
+ timestamp: Date.now(),
+ sessionId: "test",
+ },
+ ];
+
+ vi.mocked(db.analytics!.where).mockReturnValue({
+ equals: vi.fn().mockReturnValue({
+ toArray: vi.fn().mockResolvedValue(mockEvents),
+ and: vi.fn(),
+ }),
+ } as never);
+
+ await analytics!.syncEvents();
+
+ expect(db.analytics!.bulkUpdate).toHaveBeenCalledWith([
+ { key: 1, changes: { synced: true } },
+ { key: 2, changes: { synced: true } },
+ ]);
+ });
+
+ it("should not sync when no unsynced events", async () => {
+ vi.mocked(db.analytics!.where).mockReturnValue({
+ equals: vi.fn().mockReturnValue({
+ toArray: vi.fn().mockResolvedValue([]),
+ and: vi.fn(),
+ }),
+ } as never);
+
+ await analytics!.syncEvents();
+
+ expect(db.analytics!.bulkUpdate).not.toHaveBeenCalled();
+ });
+
+ it("should handle sync errors gracefully", async () => {
+ const consoleError = vi.spyOn(console, "error");
+ const error = new Error("Sync failed");
+ vi.mocked(db.analytics!.where).mockImplementation(() => {
+ throw error;
+ });
+
+ await analytics!.syncEvents();
+
+ expect(consoleError).toHaveBeenCalledWith(
+ "Failed to sync analytics events:",
+ error
+ );
+ });
+ });
+
+ describe("getStats", () => {
+ it("should return analytics statistics", async () => {
+ const mockEvents = [
+ {
+ id: 1,
+ type: "page_view" as const,
+ category: "test",
+ action: "test",
+ synced: true,
+ timestamp: Date.now(),
+ sessionId: "test",
+ },
+ {
+ id: 2,
+ type: "button_click" as const,
+ category: "test",
+ action: "test",
+ synced: false,
+ timestamp: Date.now(),
+ sessionId: "test",
+ },
+ {
+ id: 3,
+ type: "page_view" as const,
+ category: "test",
+ action: "test",
+ synced: false,
+ timestamp: Date.now(),
+ sessionId: "test",
+ },
+ ];
+
+ vi.mocked(db.analytics!.toArray).mockResolvedValue(mockEvents);
+
+ const stats = await analytics!.getStats();
+
+ expect(stats).toEqual({
+ total: 3,
+ synced: 1,
+ unsynced: 2,
+ byType: {
+ page_view: 2,
+ button_click: 1,
+ },
+ });
+ });
+
+ it("should return empty stats when no events", async () => {
+ vi.mocked(db.analytics!.toArray).mockResolvedValue([]);
+
+ const stats = await analytics!.getStats();
+
+ expect(stats).toEqual({
+ total: 0,
+ synced: 0,
+ unsynced: 0,
+ byType: {},
+ });
+ });
+ });
+
+ describe("clearOldEvents", () => {
+ it("should delete old synced events", async () => {
+ const mockDelete = vi.fn().mockResolvedValue(5);
+
+ vi.mocked(db.analytics!.where).mockReturnValue({
+ equals: vi.fn().mockReturnValue({
+ and: vi.fn().mockReturnValue({
+ delete: mockDelete,
+ }),
+ toArray: vi.fn(),
+ }),
+ } as never);
+
+ await analytics!.clearOldEvents();
+
+ expect(db.analytics!.where).toHaveBeenCalledWith("synced");
+ });
+ });
+
+ describe("online/offline handling", () => {
+ it("should detect initial online state", () => {
+ // Navigator.onLine is mocked in setup
+ expect(analytics).toBeDefined();
+ });
+
+ it("should trigger sync when coming online", async () => {
+ const syncSpy = vi.spyOn(analytics!, "syncEvents");
+
+ // Simulate coming online
+ window.dispatchEvent(new Event("online"));
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(syncSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("cleanup", () => {
+ it("should stop periodic sync", () => {
+ const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
+
+ analytics!.stopPeriodicSync();
+
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
new file mode 100644
index 0000000..7647d84
--- /dev/null
+++ b/src/lib/analytics.ts
@@ -0,0 +1,412 @@
+// SPDX-FileCopyrightText: 2025 SecPal
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { db, type AnalyticsEvent, type AnalyticsEventType } from "./db";
+
+// Re-export types for external use
+export type { AnalyticsEvent, AnalyticsEventType };
+
+class OfflineAnalytics {
+ private sessionId: string;
+ private userId?: string;
+ private isOnline: boolean;
+ private syncInterval?: number;
+ private syncTimeout?: number;
+ private onlineHandler: () => void;
+ private offlineHandler: () => void;
+ private isSyncing: boolean = false;
+ private isDestroyed: boolean = false;
+
+ constructor() {
+ this.sessionId = this.generateSessionId();
+ this.isOnline = typeof navigator !== "undefined" && navigator.onLine;
+
+ // Bind event handlers for cleanup
+ this.onlineHandler = () => this.handleOnline();
+ this.offlineHandler = () => this.handleOffline();
+
+ // Set up online/offline listeners
+ if (typeof window !== "undefined") {
+ window.addEventListener("online", this.onlineHandler);
+ window.addEventListener("offline", this.offlineHandler);
+ }
+
+ // Start periodic sync
+ this.startPeriodicSync();
+ }
+
+ /**
+ * Generate a unique session ID using cryptographically secure random
+ * Falls back to timestamp + Math.random for older browsers
+ */
+ private generateSessionId(): string {
+ // Prefer crypto.randomUUID for cryptographic security
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
+ return `session_${crypto.randomUUID()}`;
+ }
+
+ // Fallback for older browsers using Math.random()
+ // SECURITY NOTE: This is acceptable because:
+ // 1. Session IDs are NOT used for authentication or authorization
+ // 2. They are only used for grouping analytics events (non-security context)
+ // 3. Collision risk is negligible (timestamp ensures uniqueness across sessions)
+ // 4. No PII is stored (privacy-first design)
+ // 5. Primary path uses crypto.randomUUID (cryptographically secure)
+ console.warn(
+ "crypto.randomUUID not available, falling back to timestamp-based session ID"
+ );
+ return `session_${Date.now()}_${Math.random().toString(36).substring(2)}`;
+ }
+
+ /**
+ * Set the current user ID for analytics
+ */
+ setUserId(userId: string): void {
+ this.userId = userId;
+ }
+
+ /**
+ * Track an analytics event
+ * @param metadata - Additional context (do not include PII or sensitive data)
+ */
+ async track(
+ type: AnalyticsEventType,
+ category: string,
+ action: string,
+ options?: {
+ label?: string;
+ value?: number;
+ metadata?: Record;
+ }
+ ): Promise {
+ // Prevent tracking after destroy
+ if (this.isDestroyed) {
+ console.warn(
+ "Analytics instance has been destroyed, ignoring track call"
+ );
+ return;
+ }
+
+ const event: AnalyticsEvent = {
+ type,
+ category,
+ action,
+ label: options?.label,
+ value: options?.value,
+ metadata: options?.metadata,
+ timestamp: Date.now(),
+ synced: false,
+ sessionId: this.sessionId,
+ userId: this.userId,
+ };
+
+ try {
+ // Store event in IndexedDB
+ await db.analytics.add(event);
+
+ // If online, debounce sync to avoid excessive syncing
+ if (this.isOnline) {
+ this.debouncedSync();
+ }
+ } catch (error) {
+ console.error("Failed to track analytics event:", error);
+ }
+ }
+
+ /**
+ * Debounced sync - waits 1 second after last event before syncing
+ */
+ private debouncedSync(): void {
+ if (this.syncTimeout) {
+ clearTimeout(this.syncTimeout);
+ }
+
+ this.syncTimeout = window.setTimeout(() => {
+ this.syncEvents();
+ this.syncTimeout = undefined;
+ }, 1000); // 1 second debounce
+ }
+
+ /**
+ * Track a page view
+ */
+ async trackPageView(path: string, title?: string): Promise {
+ await this.track("page_view", "navigation", "page_view", {
+ label: path,
+ metadata: { title },
+ });
+ }
+
+ /**
+ * Track a button click
+ */
+ async trackClick(
+ elementId: string,
+ context?: Record
+ ): Promise {
+ await this.track("button_click", "interaction", "click", {
+ label: elementId,
+ metadata: context,
+ });
+ }
+
+ /**
+ * Track a form submission
+ */
+ async trackFormSubmit(
+ formName: string,
+ success: boolean,
+ metadata?: Record
+ ): Promise {
+ await this.track("form_submit", "interaction", "form_submit", {
+ label: formName,
+ value: success ? 1 : 0,
+ metadata,
+ });
+ }
+
+ /**
+ * Track an error
+ * @param error - Error object
+ * @param context - Additional context (do not include sensitive data)
+ * @param includeStack - Whether to include full stack trace (default: false). Stack traces may contain sensitive file paths.
+ */
+ async trackError(
+ error: Error,
+ context?: Record,
+ includeStack: boolean = false
+ ): Promise {
+ await this.track("error", "error", error.name, {
+ label: error.message,
+ metadata: {
+ ...context,
+ ...(includeStack ? { stack: error.stack } : {}),
+ },
+ });
+ }
+
+ /**
+ * Track a performance metric
+ */
+ async trackPerformance(
+ metric: string,
+ value: number,
+ metadata?: Record
+ ): Promise {
+ await this.track("performance", "performance", metric, {
+ value,
+ metadata,
+ });
+ }
+
+ /**
+ * Track feature usage
+ */
+ async trackFeatureUsage(
+ feature: string,
+ metadata?: Record
+ ): Promise {
+ await this.track("feature_usage", "feature", "use", {
+ label: feature,
+ metadata,
+ });
+ }
+
+ /**
+ * Handle online event
+ */
+ private handleOnline(): void {
+ this.isOnline = true;
+ this.syncEvents();
+ }
+
+ /**
+ * Handle offline event
+ */
+ private handleOffline(): void {
+ this.isOnline = false;
+ }
+
+ /**
+ * Start periodic sync (every 5 minutes)
+ */
+ private startPeriodicSync(): void {
+ if (typeof window === "undefined") return;
+
+ this.syncInterval = window.setInterval(
+ () => {
+ if (this.isOnline) {
+ this.syncEvents();
+ }
+ },
+ 5 * 60 * 1000
+ ); // 5 minutes
+ }
+
+ /**
+ * Stop periodic sync
+ */
+ stopPeriodicSync(): void {
+ if (this.syncInterval) {
+ clearInterval(this.syncInterval);
+ }
+ }
+
+ /**
+ * Clean up resources (event listeners, intervals)
+ * Call this when the analytics instance is no longer needed (e.g., in tests)
+ *
+ * Note: For the singleton instance, this is intentionally only called during
+ * test cleanup. In production, event listeners persist for the app lifetime.
+ */
+ destroy(): void {
+ this.isDestroyed = true;
+
+ // Remove event listeners
+ if (typeof window !== "undefined") {
+ window.removeEventListener("online", this.onlineHandler);
+ window.removeEventListener("offline", this.offlineHandler);
+ }
+
+ // Clear intervals and timeouts
+ if (this.syncInterval) {
+ clearInterval(this.syncInterval);
+ this.syncInterval = undefined;
+ }
+ if (this.syncTimeout) {
+ clearTimeout(this.syncTimeout);
+ this.syncTimeout = undefined;
+ }
+ }
+
+ /**
+ * Sync unsynced events to the server
+ *
+ * NOTE: Backend sync is not yet implemented. Events are currently only
+ * marked as "synced" locally. In production, this would send events to
+ * an analytics endpoint. See README for current limitations.
+ */
+ async syncEvents(): Promise {
+ if (!this.isOnline || this.isDestroyed) return;
+
+ // Prevent concurrent syncs - atomic check and set
+ if (this.isSyncing) {
+ console.log("Sync already in progress, skipping...");
+ return;
+ }
+
+ this.isSyncing = true;
+
+ try {
+ // Get all unsynced events
+ const unsyncedEvents = await db.analytics
+ .where("synced")
+ .equals(0)
+ .toArray();
+
+ if (unsyncedEvents.length === 0) {
+ this.isSyncing = false;
+ return;
+ }
+
+ // TODO: Implement actual sync to backend endpoint
+ // In production, this would POST events to /api/analytics
+ // For now, we just mark them as synced for local testing
+
+ // Simulate network request
+ console.log(`Syncing ${unsyncedEvents.length} analytics events...`);
+
+ // Mark events as synced - single-pass bulk update with proper type narrowing
+ const eventsWithId = unsyncedEvents.filter(
+ (e): e is AnalyticsEvent & { id: number } => e.id !== undefined
+ );
+
+ await db.analytics.bulkUpdate(
+ eventsWithId.map((e) => ({
+ key: e.id,
+ changes: { synced: true },
+ }))
+ );
+
+ console.log(`Successfully synced ${eventsWithId.length} events`);
+ } catch (error) {
+ console.error("Failed to sync analytics events:", error);
+ } finally {
+ this.isSyncing = false;
+ }
+ }
+
+ /**
+ * Get analytics stats
+ */
+ async getStats(): Promise<{
+ total: number;
+ synced: number;
+ unsynced: number;
+ byType: Record;
+ }> {
+ const allEvents = await db.analytics.toArray();
+ const syncedEvents = allEvents.filter((e) => e.synced);
+ const unsyncedEvents = allEvents.filter((e) => !e.synced);
+
+ const byType = allEvents.reduce(
+ (acc, event) => {
+ const type = event.type as AnalyticsEventType;
+ acc[type] = (acc[type] || 0) + 1;
+ return acc;
+ },
+ {} as Record
+ );
+
+ return {
+ total: allEvents.length,
+ synced: syncedEvents.length,
+ unsynced: unsyncedEvents.length,
+ byType,
+ };
+ }
+
+ /**
+ * Clear old synced events (older than 30 days)
+ */
+ async clearOldEvents(): Promise {
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
+
+ await db.analytics
+ .where("synced")
+ .equals(1)
+ .and((event) => event.timestamp < thirtyDaysAgo)
+ .delete();
+ }
+}
+
+/**
+ * Get the singleton analytics instance
+ * @throws {Error} If analytics is not available in this environment
+ */
+export function getAnalytics(): OfflineAnalytics {
+ if (!analyticsInstance) {
+ throw new Error(
+ "Analytics not available in this environment. Browser may not support required PWA features."
+ );
+ }
+ return analyticsInstance;
+}
+
+// Export singleton instance with safe initialization
+// If initialization fails (e.g., old browser), instance will be null
+// Use getAnalytics() for safe access with error handling
+let analyticsInstance: OfflineAnalytics | null = null;
+
+try {
+ analyticsInstance = new OfflineAnalytics();
+} catch (error) {
+ console.error(
+ "Failed to initialize analytics singleton. This browser may not support required PWA features:",
+ error
+ );
+}
+
+// Backwards compatibility: Direct export (may be null)
+// Prefer using getAnalytics() for better error handling
+export const analytics = analyticsInstance;
diff --git a/src/lib/db.test.ts b/src/lib/db.test.ts
index a6c6547..fabe803 100644
--- a/src/lib/db.test.ts
+++ b/src/lib/db.test.ts
@@ -222,8 +222,8 @@ describe("IndexedDB Database", () => {
expect(db.name).toBe("SecPalDB");
});
- it("should have version 1", () => {
- expect(db.verno).toBe(1);
+ it("should have version 2", () => {
+ expect(db.verno).toBe(2);
});
it("should have all required tables", () => {
@@ -231,6 +231,7 @@ describe("IndexedDB Database", () => {
expect(tableNames).toContain("guards");
expect(tableNames).toContain("syncQueue");
expect(tableNames).toContain("apiCache");
+ expect(tableNames).toContain("analytics");
});
});
});
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 2e66a64..863b547 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -38,6 +38,31 @@ export interface ApiCacheEntry {
expiresAt: Date;
}
+export type AnalyticsEventType =
+ | "page_view"
+ | "button_click"
+ | "form_submit"
+ | "error"
+ | "performance"
+ | "feature_usage";
+
+/**
+ * Analytics event for offline tracking
+ */
+export interface AnalyticsEvent {
+ id?: number;
+ type: AnalyticsEventType;
+ category: string;
+ action: string;
+ label?: string;
+ value?: number;
+ metadata?: Record;
+ timestamp: number;
+ synced: boolean;
+ sessionId: string;
+ userId?: string;
+}
+
/**
* SecPal IndexedDB database
*
@@ -45,11 +70,13 @@ export interface ApiCacheEntry {
* - Guards (employees)
* - Sync queue (operations to sync when online)
* - API cache (cached responses for offline access)
+ * - Analytics (offline event tracking)
*/
export const db = new Dexie("SecPalDB") as Dexie & {
guards: EntityTable;
syncQueue: EntityTable;
apiCache: EntityTable;
+ analytics: EntityTable;
};
// Schema version 1
@@ -58,3 +85,13 @@ db.version(1).stores({
syncQueue: "id, status, createdAt, attempts",
apiCache: "url, expiresAt",
});
+
+// Schema version 2 - Add analytics table
+// Note: Per Dexie.js best practices, all existing tables must be re-declared
+// when upgrading schema versions, even if they haven't changed
+db.version(2).stores({
+ guards: "id, email, lastSynced",
+ syncQueue: "id, status, createdAt, attempts",
+ apiCache: "url, expiresAt",
+ analytics: "++id, synced, timestamp, sessionId, type",
+});
diff --git a/vite.config.ts b/vite.config.ts
index 454adb0..fbe2946 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -109,6 +109,25 @@ export default defineConfig(({ mode }) => {
],
},
],
+ share_target: {
+ action: "/share",
+ method: "POST",
+ enctype: "multipart/form-data",
+ params: {
+ title: "title",
+ text: "text",
+ url: "url",
+ // Note: File handling is configured here but not yet fully implemented
+ // in useShareTarget hook. The hook currently uses GET parameters only.
+ // Full POST + file support tracked in Issue #101
+ files: [
+ {
+ name: "files",
+ accept: ["image/*", "application/pdf", ".doc", ".docx"],
+ },
+ ],
+ },
+ },
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2}"],