/** * Storage composition summarization (spec §02 → Storage Monitor; docs/architecture/02 §6). * * Portable core: no browser APIs, no React. The Storage Monitor is **not** a "used * of quota" gauge — the browser's `quota` from `navigator.storage.estimate()` is a * padded, browser-decided approximation, not a real free-space figure, so a budget * fraction would be false precision (web.dev → storage-for-the-web). Instead we show * what storage is *made of*: Snippets, Datasets, and App (everything else — the * precached app shell + IndexedDB overhead). This module turns three measured byte * counts into ordered segments; the math lives here so it is trivially testable. * * The only reliable figure from the estimate is `usage` (bytes actually stored for * the whole origin). Snippet and dataset bytes we measure ourselves, so * `App = usage − snippets − datasets`. When the estimate is missing we still show * snippets + datasets from our own data. */ /** A category of stored data and its measured size in bytes. */ interface StorageSegment { key: 'snippets' | 'datasets' | 'app'; /** User-facing label, centralized here so the component stays presentational. */ label: string; bytes: number; } /** Measured inputs: snippet/dataset bytes (always known) + origin usage (when the API is present). */ export interface StorageInput { /** Whole-origin bytes from `navigator.storage.estimate().usage`, if available. */ usageBytes?: number; /** Summed serialized size of all snippets. */ snippetBytes: number; /** Summed size of all datasets (each `Dataset` carries its byte `size`). */ datasetBytes: number; } /** Presentation-ready storage composition. */ export interface StorageComposition { /** Ordered segments to display: Snippets, Datasets, then App when measurable. */ segments: StorageSegment[]; /** Sum of the segments shown — origin `usage` when measured, else snippets+datasets. */ totalBytes: number; /** * True when the origin estimate was available, so the `app` segment (the precached * shell + overhead) is meaningful and `totalBytes` is whole-origin usage. False when * the Storage Manager API is absent — we still show snippets + datasets, and * `totalBytes` is just their sum. */ originMeasured: boolean; } /** * The monitor stays hidden until the user's own data — snippets + datasets — reaches * this size (spec §02). Below it storage is noise (nothing to manage), and the bar * would be dominated by the immovable precached app shell anyway. Deliberately keyed * off *user* bytes, not total: the ~precache baseline is roughly constant, so gating * on total would make the monitor always-visible and the breakdown unbalanced. */ export const STORAGE_MONITOR_MIN_USER_BYTES = 10 * 1024 * 1024; /** A finite, non-negative byte count, or null when the value is unusable. */ function usableBytes(n: number | undefined): number | null { if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null; return n; } /** A finite, non-negative byte count; junk degrades to 0. */ function nonNeg(n: number): number { return usableBytes(n) ?? 0; } /** * Decompose measured storage into ordered segments. The `app` segment (everything * beyond our snippets+datasets) is included only when the origin `usage` is available * and at least our measured data — a smaller estimate is the browser's approximation * lagging, not real, so we fall back to a snippets+datasets-only view. */ export function summarizeStorage(input: StorageInput): StorageComposition { const snippets = nonNeg(input.snippetBytes); const datasets = nonNeg(input.datasetBytes); const usage = usableBytes(input.usageBytes); const known = snippets + datasets; const segments: StorageSegment[] = [ { key: 'snippets', label: 'Snippets', bytes: snippets }, { key: 'datasets', label: 'Datasets', bytes: datasets }, ]; if (usage !== null && usage >= known) { segments.push({ key: 'app', label: 'App', bytes: usage - known }); return { segments, totalBytes: usage, originMeasured: true }; } return { segments, totalBytes: known, originMeasured: false }; } /** UTF-8 byte length of a value's JSON serialization (0 when it can't be serialized). */ export function jsonByteSize(value: unknown): number { try { return new TextEncoder().encode(JSON.stringify(value) ?? '').length; } catch { return 0; } } 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]}`; }