feat(dictation): PR 1.4 — draft recovery + addToHistory persistence outcome

addToHistory previously swallowed SQLite write failures with a console
warning while still pushing the entry into the in-memory history store
— a silent data-loss bug, since the row vanished on next restart with
no signal to the user. It now returns { persisted, error }, only mutates
in-memory history after the disk write lands, and the dictation flow
awaits the result so it can surface a "Couldn't save" toast and keep
the user's content intact for a retry. The new dictation autosave
writes transcript + segments to localStorage on a 1.5s debounce, on
every segment boundary, and on visibilitychange/pagehide — covering
both live capture and the stopped-but-unsaved walk-away case. On
DictationPage mount any draft <24h old surfaces an inline
Restore/Discard banner that styles as a peer to the existing error
notices. The draft is cleared only after a successful SQLite write or
explicit Discard/Clear; on persistence failure it stays on disk so the
user can recover on relaunch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:36:27 +01:00
parent 6389fc2ce7
commit e3292bd597
4 changed files with 359 additions and 9 deletions

View File

@@ -0,0 +1,132 @@
// Draft autosave/recovery for the Dictation page (PR 1.4).
//
// Covers two failure modes that previously lost user work:
// 1. App or window closes mid-capture before the user hits Stop.
// 2. User stops, walks away, app exits before the SQLite write
// surfaces its outcome — or the SQLite write fails outright.
//
// The payload is intentionally tiny — transcript + segments + language
// — so the localStorage write stays cheap on every keystroke. The
// autosave call site debounces to 1.5s; this module just owns the
// shape and the parse/serialise round trip.
import type { Segment } from "$lib/types/app";
import { parseStoredJson } from "$lib/utils/storage.js";
export const DICTATION_DRAFT_KEY = "kon.dictation.draft.v1";
/** 24 hours in ms — drafts older than this are ignored on mount. */
export const DRAFT_RECOVERY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export interface DictationDraftPayload {
/** ISO-8601 string. Compared to Date.now() to gate the recovery banner. */
updatedAt: string;
transcript: string;
segments: Segment[];
language?: string;
}
/**
* Build a draft payload from current dictation state. Returns `null` if
* there is nothing worth persisting (empty transcript and no segments).
* The caller is expected to call `clearDraft()` instead when this returns
* null, so we don't pollute localStorage with empty drafts.
*/
export function buildDraftPayload(
transcript: string,
segments: Segment[],
language: string | undefined,
now: Date = new Date(),
): DictationDraftPayload | null {
const hasTranscript = transcript.trim().length > 0;
const hasSegments = Array.isArray(segments) && segments.length > 0;
if (!hasTranscript && !hasSegments) return null;
return {
updatedAt: now.toISOString(),
transcript,
segments: Array.isArray(segments) ? segments : [],
...(language ? { language } : {}),
};
}
function canUseStorage(): boolean {
return typeof localStorage !== "undefined";
}
export function saveDraft(payload: DictationDraftPayload): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(DICTATION_DRAFT_KEY, JSON.stringify(payload));
} catch {
// Quota exceeded or storage disabled — silently drop. The user still
// has the in-memory transcript; we just lose the recover-on-relaunch
// safety net for this run. No toast; the failure mode is rare and
// surfacing it would confuse more than it helps.
}
}
export function clearDraft(): void {
if (!canUseStorage()) return;
try {
localStorage.removeItem(DICTATION_DRAFT_KEY);
} catch {
/* same rationale as saveDraft */
}
}
/**
* Read the persisted draft, returning it only if it exists, parses
* cleanly, and is fresher than `maxAgeMs` (default 24 h). Anything older
* is treated as stale and the caller can show no banner.
*
* We do NOT auto-prune the stale entry here — leaving it in place is
* harmless (it's still <a few KB) and avoids a write on every mount.
*/
export function loadRecoverableDraft(
now: Date = new Date(),
maxAgeMs: number = DRAFT_RECOVERY_MAX_AGE_MS,
): DictationDraftPayload | null {
if (!canUseStorage()) return null;
const raw = localStorage.getItem(DICTATION_DRAFT_KEY);
const parsed = parseStoredJson<DictationDraftPayload>(raw);
if (!parsed || typeof parsed.updatedAt !== "string") return null;
const updatedAtMs = Date.parse(parsed.updatedAt);
if (!Number.isFinite(updatedAtMs)) return null;
if (now.getTime() - updatedAtMs > maxAgeMs) return null;
// Defensive: if the stored draft has neither transcript nor segments
// (shouldn't happen because saveDraft refuses empty payloads, but a
// hand-edited or truncated entry could trip this), treat as no draft.
const hasTranscript = typeof parsed.transcript === "string" && parsed.transcript.trim().length > 0;
const hasSegments = Array.isArray(parsed.segments) && parsed.segments.length > 0;
if (!hasTranscript && !hasSegments) return null;
return {
updatedAt: parsed.updatedAt,
transcript: typeof parsed.transcript === "string" ? parsed.transcript : "",
segments: Array.isArray(parsed.segments) ? parsed.segments : [],
...(typeof parsed.language === "string" ? { language: parsed.language } : {}),
};
}
/**
* Coarse "x ago" formatter for the recovery banner. Intentionally short
* — we only need enough resolution for the user to recognise their own
* recent work ("3 minutes ago", "2 hours ago"). Returns "just now" for
* <30s and clamps to "yesterday" for things ~24h old (the recovery cap).
*/
export function formatTimeAgo(iso: string, now: Date = new Date()): string {
const then = Date.parse(iso);
if (!Number.isFinite(then)) return "";
const deltaMs = Math.max(0, now.getTime() - then);
const seconds = Math.floor(deltaMs / 1000);
if (seconds < 30) return "just now";
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
return "yesterday";
}