diff --git a/src/app/infrastructure/storage-estimate.ts b/src/app/infrastructure/storage-estimate.ts new file mode 100644 index 0000000..741d4f7 --- /dev/null +++ b/src/app/infrastructure/storage-estimate.ts @@ -0,0 +1,39 @@ +/** + * Storage estimate adapter (spec §02 → Storage Monitor; docs/architecture/02 §6). + * + * This is the ONLY module that touches the browser's Storage Manager API + * (`navigator.storage.estimate()`). It feature-detects the API, then defers all + * presentation math to the portable core (`src/core/storage-estimate.ts`). When + * the API is unavailable — older browsers, insecure contexts — it returns an + * unavailable summary rather than throwing, so callers get a total function. + */ + +import { summarizeStorage, type StorageSummary } from '../../core/storage-estimate'; + +/** The unavailable summary, returned when the Storage Manager API is absent. */ +const UNAVAILABLE: StorageSummary = { + usedBytes: 0, + quotaBytes: 0, + fraction: 0, + level: 'ok', + available: false, +}; + +/** + * Read the browser's storage estimate and summarize it. Returns an unavailable + * summary when `navigator.storage.estimate` is missing or the call rejects; + * never throws. All math lives in `summarizeStorage`. + */ +export async function readStorageEstimate(): Promise { + const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined; + if (!storage || typeof storage.estimate !== 'function') { + return UNAVAILABLE; + } + + try { + const { usage, quota } = await storage.estimate(); + return summarizeStorage({ usage, quota }); + } catch { + return UNAVAILABLE; + } +} diff --git a/src/core/storage-estimate.test.ts b/src/core/storage-estimate.test.ts new file mode 100644 index 0000000..b2540d9 --- /dev/null +++ b/src/core/storage-estimate.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'vitest'; +import { + CRITICAL_THRESHOLD, + WARNING_THRESHOLD, + humanizeBytes, + summarizeStorage, +} from './storage-estimate'; + +describe('summarizeStorage', () => { + test('computes the fraction and passes usage/quota through', () => { + const s = summarizeStorage({ usage: 250, quota: 1000 }); + expect(s.available).toBe(true); + expect(s.usedBytes).toBe(250); + expect(s.quotaBytes).toBe(1000); + expect(s.fraction).toBeCloseTo(0.25); + expect(s.level).toBe('ok'); + }); + + test('clamps fraction to 1 when usage exceeds quota', () => { + const s = summarizeStorage({ usage: 1500, quota: 1000 }); + expect(s.fraction).toBe(1); + expect(s.level).toBe('critical'); + }); + + test('guards divide-by-zero: quota of 0 is unusable', () => { + const s = summarizeStorage({ usage: 10, quota: 0 }); + expect(s.available).toBe(false); + expect(s.fraction).toBe(0); + expect(s.usedBytes).toBe(0); + expect(s.quotaBytes).toBe(0); + expect(s.level).toBe('ok'); + }); + + describe('level boundaries', () => { + test('just below warning (0.79) is ok', () => { + expect(summarizeStorage({ usage: 79, quota: 100 }).level).toBe('ok'); + }); + + test('exactly at warning (0.80) is warning', () => { + const s = summarizeStorage({ usage: 80, quota: 100 }); + expect(s.fraction).toBeCloseTo(WARNING_THRESHOLD); + expect(s.level).toBe('warning'); + }); + + test('just below critical (0.94) is warning', () => { + expect(summarizeStorage({ usage: 94, quota: 100 }).level).toBe('warning'); + }); + + test('exactly at critical (0.95) is critical', () => { + const s = summarizeStorage({ usage: 95, quota: 100 }); + expect(s.fraction).toBeCloseTo(CRITICAL_THRESHOLD); + expect(s.level).toBe('critical'); + }); + }); + + describe('availability', () => { + test('missing usage -> unavailable', () => { + const s = summarizeStorage({ quota: 1000 }); + expect(s.available).toBe(false); + expect(s.fraction).toBe(0); + }); + + test('missing quota -> unavailable', () => { + const s = summarizeStorage({ usage: 1000 }); + expect(s.available).toBe(false); + expect(s.fraction).toBe(0); + }); + + test('both missing (empty input) -> unavailable', () => { + const s = summarizeStorage({}); + expect(s.available).toBe(false); + }); + + test('non-finite or negative values -> unavailable', () => { + expect(summarizeStorage({ usage: NaN, quota: 1000 }).available).toBe(false); + expect(summarizeStorage({ usage: Infinity, quota: 1000 }).available).toBe(false); + expect(summarizeStorage({ usage: -1, quota: 1000 }).available).toBe(false); + expect(summarizeStorage({ usage: 10, quota: -5 }).available).toBe(false); + }); + + test('zero usage with a real quota is available and ok', () => { + const s = summarizeStorage({ usage: 0, quota: 1000 }); + expect(s.available).toBe(true); + expect(s.fraction).toBe(0); + expect(s.level).toBe('ok'); + }); + }); +}); + +describe('humanizeBytes', () => { + test('zero renders as whole bytes', () => { + expect(humanizeBytes(0)).toBe('0 B'); + }); + + test('small values stay in whole bytes', () => { + expect(humanizeBytes(1)).toBe('1 B'); + expect(humanizeBytes(512)).toBe('512 B'); + expect(humanizeBytes(1023)).toBe('1023 B'); + }); + + test('KB boundary (1024) and within KB', () => { + expect(humanizeBytes(1024)).toBe('1.0 KB'); + expect(humanizeBytes(1536)).toBe('1.5 KB'); + }); + + test('MB boundary and within MB', () => { + expect(humanizeBytes(1024 * 1024)).toBe('1.0 MB'); + expect(humanizeBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + expect(humanizeBytes(2.5 * 1024 * 1024)).toBe('2.5 MB'); + }); + + test('GB boundary', () => { + expect(humanizeBytes(1024 * 1024 * 1024)).toBe('1.0 GB'); + }); + + test('caps at the largest unit (TB)', () => { + expect(humanizeBytes(1024 ** 4)).toBe('1.0 TB'); + expect(humanizeBytes(1024 ** 5)).toBe('1024.0 TB'); + }); + + test('negative and non-finite inputs degrade to 0 B', () => { + expect(humanizeBytes(-1)).toBe('0 B'); + expect(humanizeBytes(NaN)).toBe('0 B'); + expect(humanizeBytes(Infinity)).toBe('0 B'); + }); +}); diff --git a/src/core/storage-estimate.ts b/src/core/storage-estimate.ts new file mode 100644 index 0000000..b72461e --- /dev/null +++ b/src/core/storage-estimate.ts @@ -0,0 +1,107 @@ +/** + * Storage estimate summarization (spec §02 → Storage Monitor; spec §10 → "Warn + * before storage failure"; docs/architecture/02 §6). + * + * Portable core: no browser APIs, no React. The browser's Storage Manager API + * (`navigator.storage.estimate()`) yields a raw `{ usage, quota }` pair in + * bytes — both optional, since a browser may omit them. This module turns that + * raw pair into presentation-ready facts: a clamped fraction, an escalating + * level, and an availability flag. The infrastructure adapter + * (`src/app/infrastructure/storage-estimate.ts`) feature-detects the API and + * feeds its output through here; all the math lives in this pure function so it + * is trivially testable. + * + * Thresholds: warning at >= 0.8 is the documented value (arch 02 §6 `WARN_AT`). + * Critical at >= 0.95 is not specified by spec/arch; it is this module's default + * for the second escalation step ("nearly full") and is the single source of + * truth here. + */ + +/** Raw input shape, mirroring the browser's `StorageEstimate` (bytes, both optional). */ +export interface StorageEstimateInput { + /** Bytes currently used, or undefined when the browser omits it. */ + usage?: number; + /** Total bytes available (quota), or undefined when the browser omits it. */ + quota?: number; +} + +/** Escalating fullness level driving the storage monitor's warning copy/colour. */ +export type StorageLevel = 'ok' | 'warning' | 'critical'; + +/** Presentation-ready summary derived from a raw estimate. */ +export interface StorageSummary { + /** Bytes used (0 when unusable). */ + usedBytes: number; + /** Quota in bytes (0 when unusable). */ + quotaBytes: number; + /** usedBytes / quotaBytes, clamped to 0..1; 0 when quota is 0/undefined. */ + fraction: number; + /** Escalating fullness level derived from `fraction`. */ + level: StorageLevel; + /** False when usage/quota are missing or non-finite — the estimate is unusable. */ + available: boolean; +} + +/** Fraction at which the monitor begins warning (arch 02 §6 `WARN_AT`). */ +export const WARNING_THRESHOLD = 0.8; +/** Fraction at which the monitor escalates to critical ("nearly full"). */ +export const CRITICAL_THRESHOLD = 0.95; + +/** A non-finite or negative number is not a usable byte count. */ +function usableBytes(n: number | undefined): number | null { + if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null; + return n; +} + +/** Map a clamped fraction to its escalation level. */ +function levelFor(fraction: number): StorageLevel { + if (fraction >= CRITICAL_THRESHOLD) return 'critical'; + if (fraction >= WARNING_THRESHOLD) return 'warning'; + return 'ok'; +} + +/** + * Turn a raw storage estimate into a presentation-ready summary. Pure: no I/O. + * When usage is missing/invalid, or quota is missing/invalid/zero, the estimate + * is unusable — `available: false`, everything zeroed, level `ok` (we don't warn + * on data we don't have). Otherwise the fraction is `usage / quota` clamped to + * 0..1 and the level escalates at the thresholds above. + */ +export function summarizeStorage(input: StorageEstimateInput): StorageSummary { + const usage = usableBytes(input.usage); + const quota = usableBytes(input.quota); + + // Quota of 0 can't yield a meaningful fraction; treat as unusable. + if (usage === null || quota === null || quota === 0) { + return { usedBytes: 0, quotaBytes: 0, fraction: 0, level: 'ok', available: false }; + } + + const fraction = Math.min(1, usage / quota); + return { + usedBytes: usage, + quotaBytes: quota, + fraction, + level: levelFor(fraction), + available: true, + }; +} + +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +/** + * Render a byte count as a human-readable string using binary (1024) units. + * Bytes are shown as a whole number with the `B` unit; KB and up get one + * decimal place (e.g. `1.5 MB`). Negative/non-finite inputs degrade to `0 B`. + */ +export function humanizeBytes(n: number): string { + if (!Number.isFinite(n) || n < 0) return '0 B'; + if (n < 1024) return `${Math.round(n)} B`; + + let value = n; + let unit = 0; + while (value >= 1024 && unit < BYTE_UNITS.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(1)} ${BYTE_UNITS[unit]}`; +}