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:
132
src/lib/utils/dictationDraft.ts
Normal file
132
src/lib/utils/dictationDraft.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user