diff --git a/src/components/molecular/QueueStatusPill/QueueStatusPill.accessibility.test.tsx b/src/components/molecular/QueueStatusPill/QueueStatusPill.accessibility.test.tsx new file mode 100644 index 00000000..47f5e233 --- /dev/null +++ b/src/components/molecular/QueueStatusPill/QueueStatusPill.accessibility.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; + +const estimateStorage = vi.fn(); +vi.mock('@/lib/offline-queue/storage-quota', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, estimateStorage: () => estimateStorage() }; +}); + +vi.mock('@/hooks/useOfflineQueue', () => ({ + useOfflineQueue: () => ({ + queueCount: 1, + failedCount: 0, + isSyncing: false, + isOnline: true, + retryFailed: vi.fn(), + }), +})); + +import QueueStatusPill from './QueueStatusPill'; + +expect.extend(toHaveNoViolations); + +describe('QueueStatusPill Accessibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has no violations without a storage warning', async () => { + estimateStorage.mockResolvedValue({ + usage: null, + quota: null, + ratio: null, + warning: false, + supported: true, + }); + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no violations with a storage warning shown', async () => { + estimateStorage.mockResolvedValue({ + usage: 9 * 1024 * 1024, + quota: 10 * 1024 * 1024, + ratio: 0.9, + warning: true, + supported: true, + }); + const { container, findByRole } = render(); + await findByRole('alert'); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/molecular/QueueStatusPill/QueueStatusPill.stories.tsx b/src/components/molecular/QueueStatusPill/QueueStatusPill.stories.tsx new file mode 100644 index 00000000..2463029b --- /dev/null +++ b/src/components/molecular/QueueStatusPill/QueueStatusPill.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import QueueStatusPill from './QueueStatusPill'; + +/** + * QueueStatusPill composes the offline-queue status indicator with a + * storage-quota warning. In Storybook the live `useOfflineQueue` hook and the + * StorageManager API drive the rendering; the storage warning only appears when + * the browser reports usage at/over 80% of quota. See the unit tests for the + * warning-state coverage (storage is mocked there). + */ +const meta: Meta = { + title: 'Components/Molecular/QueueStatusPill', + component: QueueStatusPill, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'QueueStatusPill component for the molecular category — offline-queue status + storage-quota warning.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + showRetryButton: { + control: 'boolean', + description: 'Show retry button for failed messages', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + showRetryButton: true, + }, +}; diff --git a/src/components/molecular/QueueStatusPill/QueueStatusPill.test.tsx b/src/components/molecular/QueueStatusPill/QueueStatusPill.test.tsx new file mode 100644 index 00000000..bce4fe2a --- /dev/null +++ b/src/components/molecular/QueueStatusPill/QueueStatusPill.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the storage-quota helper so we control the warning state. +const estimateStorage = vi.fn(); +vi.mock('@/lib/offline-queue/storage-quota', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, estimateStorage: () => estimateStorage() }; +}); + +// Mock the offline-queue hook the wrapped indicator uses (keep it quiet/empty +// so the indicator renders nothing and we isolate the storage-warning behavior). +vi.mock('@/hooks/useOfflineQueue', () => ({ + useOfflineQueue: () => ({ + queueCount: 1, // non-empty so the indicator renders (not null) + failedCount: 0, + isSyncing: false, + isOnline: true, + retryFailed: vi.fn(), + }), +})); + +import QueueStatusPill from './QueueStatusPill'; + +describe('QueueStatusPill', () => { + beforeEach(() => { + vi.clearAllMocks(); + estimateStorage.mockResolvedValue({ + usage: null, + quota: null, + ratio: null, + warning: false, + supported: true, + }); + }); + + it('renders without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('does NOT show a storage warning under the threshold', async () => { + render(); + // give the effect a tick + await waitFor(() => expect(estimateStorage).toHaveBeenCalled()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows a storage warning when usage is near quota', async () => { + estimateStorage.mockResolvedValue({ + usage: 8.5 * 1024 * 1024, + quota: 10 * 1024 * 1024, + ratio: 0.85, + warning: true, + supported: true, + }); + render(); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/storage is almost full/i); + expect(alert).toHaveTextContent(/8\.5 MB of 10\.0 MB/); + }); + + it('omits the usage figure when only the ratio is known', async () => { + estimateStorage.mockResolvedValue({ + usage: null, + quota: null, + ratio: 0.9, + warning: true, + supported: true, + }); + render(); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/storage is almost full/i); + expect(alert).not.toHaveTextContent(/MB of/); + }); +}); diff --git a/src/components/molecular/QueueStatusPill/QueueStatusPill.tsx b/src/components/molecular/QueueStatusPill/QueueStatusPill.tsx new file mode 100644 index 00000000..98b00fd4 --- /dev/null +++ b/src/components/molecular/QueueStatusPill/QueueStatusPill.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import QueueStatusIndicator from '@/components/atomic/QueueStatusIndicator'; +import { + estimateStorage, + formatStorageUsage, + type StorageEstimateResult, +} from '@/lib/offline-queue/storage-quota'; + +export interface QueueStatusPillProps { + /** Show retry button for failed messages (passed through to the indicator). */ + showRetryButton?: boolean; + /** Callback when retry is clicked. */ + onRetry?: () => void; + /** Additional CSS classes. */ + className?: string; +} + +/** + * QueueStatusPill component (#32, feature 020). + * + * Composes the atomic QueueStatusIndicator (queued / syncing / failed / offline) + * with a **storage-quota warning**: when the origin's IndexedDB usage is at/over + * 80% of quota, it surfaces a warning so the user knows the offline queue may + * stop persisting new items. Storage info degrades gracefully — browsers without + * the StorageManager API simply show no warning. + * + * @category molecular + */ +export default function QueueStatusPill({ + showRetryButton = true, + onRetry, + className = '', +}: QueueStatusPillProps) { + const [storage, setStorage] = useState(null); + + useEffect(() => { + let cancelled = false; + void estimateStorage().then((result) => { + if (!cancelled) setStorage(result); + }); + return () => { + cancelled = true; + }; + }, []); + + const showStorageWarning = storage?.warning === true; + + return ( +
+ + + {showStorageWarning && ( +
+ + Device storage is almost full + {formatStorageUsage(storage.usage, storage.quota) + ? ` (${formatStorageUsage(storage.usage, storage.quota)})` + : ''} + . Queued messages may not be saved until you free up space or come + back online. + +
+ )} +
+ ); +} diff --git a/src/components/molecular/QueueStatusPill/index.tsx b/src/components/molecular/QueueStatusPill/index.tsx new file mode 100644 index 00000000..90312064 --- /dev/null +++ b/src/components/molecular/QueueStatusPill/index.tsx @@ -0,0 +1,2 @@ +export { default } from './QueueStatusPill'; +export type { QueueStatusPillProps } from './QueueStatusPill'; diff --git a/src/lib/offline-queue/__tests__/storage-quota.test.ts b/src/lib/offline-queue/__tests__/storage-quota.test.ts new file mode 100644 index 00000000..b6ac27d9 --- /dev/null +++ b/src/lib/offline-queue/__tests__/storage-quota.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + estimateStorage, + isStorageNearQuota, + formatStorageUsage, + STORAGE_WARNING_THRESHOLD, +} from '../storage-quota'; + +const originalNavigator = globalThis.navigator; + +function setEstimate(impl: (() => Promise) | undefined) { + Object.defineProperty(globalThis, 'navigator', { + value: impl ? { storage: { estimate: impl } } : {}, + configurable: true, + writable: true, + }); +} + +afterEach(() => { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + vi.restoreAllMocks(); +}); + +describe('estimateStorage', () => { + it('returns unsupported when StorageManager is missing', async () => { + setEstimate(undefined); + const r = await estimateStorage(); + expect(r.supported).toBe(false); + expect(r.warning).toBe(false); + expect(r.ratio).toBeNull(); + }); + + it('computes ratio + no warning below the threshold', async () => { + setEstimate(async () => ({ usage: 5, quota: 100 })); + const r = await estimateStorage(); + expect(r.supported).toBe(true); + expect(r.ratio).toBeCloseTo(0.05); + expect(r.warning).toBe(false); + }); + + it('flags a warning at/over the 80% threshold', async () => { + setEstimate(async () => ({ usage: 80, quota: 100 })); + const r = await estimateStorage(); + expect(r.ratio).toBeCloseTo(STORAGE_WARNING_THRESHOLD); + expect(r.warning).toBe(true); + }); + + it('treats a zero/invalid quota as unknown (no divide-by-zero warning)', async () => { + setEstimate(async () => ({ usage: 5, quota: 0 })); + const r = await estimateStorage(); + expect(r.supported).toBe(true); + expect(r.ratio).toBeNull(); + expect(r.warning).toBe(false); + }); + + it('fails open when estimate() throws', async () => { + setEstimate(async () => { + throw new Error('denied'); + }); + const r = await estimateStorage(); + expect(r.supported).toBe(true); + expect(r.warning).toBe(false); + }); +}); + +describe('isStorageNearQuota', () => { + it('mirrors the warning flag', async () => { + setEstimate(async () => ({ usage: 90, quota: 100 })); + expect(await isStorageNearQuota()).toBe(true); + setEstimate(async () => ({ usage: 10, quota: 100 })); + expect(await isStorageNearQuota()).toBe(false); + }); +}); + +describe('formatStorageUsage', () => { + it('formats MB pairs', () => { + expect(formatStorageUsage(8.5 * 1024 * 1024, 10 * 1024 * 1024)).toBe( + '8.5 MB of 10.0 MB' + ); + }); + it('returns null when either side is unknown', () => { + expect(formatStorageUsage(null, 100)).toBeNull(); + expect(formatStorageUsage(100, null)).toBeNull(); + }); +}); diff --git a/src/lib/offline-queue/storage-quota.ts b/src/lib/offline-queue/storage-quota.ts new file mode 100644 index 00000000..8628eab3 --- /dev/null +++ b/src/lib/offline-queue/storage-quota.ts @@ -0,0 +1,89 @@ +/** + * Storage-quota helper for the offline queue (#32, feature 020). + * + * The offline queue persists items in IndexedDB. On constrained devices + * (especially iOS Safari, which caps per-origin storage aggressively) the queue + * can fill the origin's quota and silently fail to persist new items. This + * helper wraps the StorageManager API (`navigator.storage.estimate()`) so the UI + * can warn the user before that happens. + * + * `navigator.storage` is unavailable in some browsers (older Safari, non-secure + * contexts) — every function degrades gracefully to "unknown / no warning" + * rather than throwing. + */ + +/** Fraction of quota at which we surface a warning (80%). */ +export const STORAGE_WARNING_THRESHOLD = 0.8; + +export interface StorageEstimateResult { + /** Bytes currently used by this origin, or null if unavailable. */ + usage: number | null; + /** Total bytes available to this origin, or null if unavailable. */ + quota: number | null; + /** usage/quota in [0,1], or null if unavailable. */ + ratio: number | null; + /** True when ratio >= STORAGE_WARNING_THRESHOLD. */ + warning: boolean; + /** True when the StorageManager API isn't available in this environment. */ + supported: boolean; +} + +const EMPTY: StorageEstimateResult = { + usage: null, + quota: null, + ratio: null, + warning: false, + supported: false, +}; + +/** + * Estimate current origin storage usage. Never throws — returns an + * `unsupported` result if the API is missing or the call fails. + */ +export async function estimateStorage(): Promise { + if ( + typeof navigator === 'undefined' || + !navigator.storage || + typeof navigator.storage.estimate !== 'function' + ) { + return EMPTY; + } + + try { + const { usage, quota } = await navigator.storage.estimate(); + if (typeof usage !== 'number' || typeof quota !== 'number' || quota <= 0) { + return { ...EMPTY, supported: true }; + } + const ratio = usage / quota; + return { + usage, + quota, + ratio, + warning: ratio >= STORAGE_WARNING_THRESHOLD, + supported: true, + }; + } catch { + return { ...EMPTY, supported: true }; + } +} + +/** + * Convenience: true when storage usage is at/over the warning threshold. + */ +export async function isStorageNearQuota(): Promise { + const { warning } = await estimateStorage(); + return warning; +} + +/** + * Format a usage/quota pair as a short human string, e.g. "8.2 MB of 10 MB". + * Returns null when either side is unknown. + */ +export function formatStorageUsage( + usage: number | null, + quota: number | null +): string | null { + if (usage == null || quota == null) return null; + const mb = (n: number) => `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${mb(usage)} of ${mb(quota)}`; +}