Add storage-estimate core and adapter (M6 Storage Monitor groundwork)

Portable summarizer (clamped fraction, ok/warning/critical level, availability
flag, humanizeBytes) with the browser StorageManager.estimate() feature-detect
isolated to the infrastructure adapter. No UI consumer yet — core-first per the
AI developer protocol; the Storage Monitor panel (spec §02) wires to it next.
This commit is contained in:
2026-06-07 17:14:07 +03:00
parent 548aa199d9
commit f92118712c
3 changed files with 272 additions and 0 deletions
+107
View File
@@ -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]}`;
}