Add notification toasts and surface persistence failures instead of failing silently

This commit is contained in:
2026-06-05 11:19:50 +03:00
parent c50f141d57
commit 3839f92f1d
10 changed files with 644 additions and 5 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { StorageQuotaError } from '../infrastructure/snippet-store';
import { storageErrorNotification } from './storage-errors';
describe('storageErrorNotification', () => {
describe('a failure the user can fix (storage full)', () => {
it('tells them the step to take and carries no diagnostic detail', () => {
const n = storageErrorNotification('save', new StorageQuotaError());
expect(n.kind).toBe('error');
expect(n.title).toBe('Storage full');
expect(n.message.toLowerCase()).toContain('delete');
// Nothing to report — the fix is in the user's hands.
expect(n.detail).toBeUndefined();
});
});
describe('a failure the user cannot fix', () => {
it('explains a load failure and attaches a reportable diagnostic', () => {
const n = storageErrorNotification('load', new DOMException('blocked', 'SecurityError'));
expect(n.kind).toBe('error');
expect(n.title).toBe("Couldn't open your library");
expect(n.detail).toBeDefined();
// The diagnostic names the operation and the underlying error.
expect(n.detail).toContain('load');
expect(n.detail).toContain('SecurityError');
expect(n.detail).toContain('blocked');
});
it('uses the right title for a failed save vs delete and includes detail', () => {
const save = storageErrorNotification('save', new Error('boom'));
expect(save.title).toBe("Couldn't save your changes");
expect(save.detail).toContain('save failed');
expect(save.detail).toContain('boom');
const del = storageErrorNotification('delete', new Error('boom'));
expect(del.title).toBe("Couldn't delete the snippet");
expect(del.detail).toContain('delete failed');
});
it('describes a non-Error throw without crashing', () => {
const n = storageErrorNotification('save', 'weird string');
expect(n.detail).toContain('weird string');
});
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Translate a persistence failure into a user-facing notification (spec §10 →
* "the user is told when a save fails rather than losing data silently").
*
* Pure and framework-free so the wording is unit-tested directly. The mapping
* encodes the product's actionability rule (Carbon: a user action is mandatory
* on an error message — but only the user's *own* action belongs in the copy):
*
* - A failure the user CAN resolve (storage full) → tell them the step to
* take, and carry no `detail` (there is nothing for them to report).
* - A failure the user CANNOT resolve (storage blocked, an unexpected DB
* error) → explain it plainly AND attach diagnostic `detail` (the operation
* that failed + the underlying error) so it can be reported and traced.
*/
import { StorageQuotaError } from '../infrastructure/snippet-store';
import type { NotifyOptions } from '../stores/NotificationStore';
/** Which persistence operation failed — shapes both the wording and the trace. */
export type StorageOp = 'load' | 'save' | 'delete';
/** Compact, reportable description of an unexpected error for the `detail` field. */
function diagnostic(op: StorageOp, err: unknown): string {
const base = `Storage ${op} failed`;
if (err instanceof Error) {
// Include the error name when it adds signal (DOMException subtypes, etc.).
return err.name && err.name !== 'Error'
? `${base}: ${err.name}: ${err.message}`
: `${base}: ${err.message}`;
}
return `${base}: ${String(err)}`;
}
/**
* Map a thrown persistence error to the notification to show for it. `op` is the
* operation that failed; `err` is whatever the adapter threw.
*/
export function storageErrorNotification(op: StorageOp, err: unknown): NotifyOptions {
// Storage full — the one failure the user can act on themselves. No detail:
// there's nothing to report, the next step is in the message.
if (err instanceof StorageQuotaError) {
return {
kind: 'error',
title: 'Storage full',
message:
"This snippet couldn't be saved because snippet storage is full. " +
'Delete snippets you no longer need to free space, then edit again to retry.',
};
}
// Couldn't read the library on startup — almost always blocked storage.
if (op === 'load') {
return {
kind: 'error',
title: "Couldn't open your library",
message:
"Astrolabe couldn't open local storage, so your saved snippets aren't available yet. " +
'This usually means the browser is blocking storage — for example, private-browsing mode. ' +
'Reload to try again.',
detail: diagnostic(op, err),
};
}
// An unexpected save/delete failure the user can't do anything about. Plain
// explanation for them; diagnostic detail for whoever they report it to.
const title = op === 'delete' ? "Couldn't delete the snippet" : "Couldn't save your changes";
return {
kind: 'error',
title,
message:
'A storage error stopped Astrolabe from completing the last change, so it may not survive a reload. ' +
'If this keeps happening, your browser may be blocking local storage (such as private-browsing mode).',
detail: diagnostic(op, err),
};
}