/** * Typed localStorage read/write helpers for the health-marketing-compliance-rag frontend. * * Keys: * hmc-conversations — array of Conversation objects (max 20, sorted by last_active_at desc) * hmc-saved-responses — array of SavedResponse objects (max 50, sorted by saved_at desc) */ import type { Conversation, Message, SavedResponse } from "../types"; const CONVERSATIONS_KEY = "hmc-conversations"; const SAVED_RESPONSES_KEY = "hmc-saved-responses"; const CONVERSATIONS_CAP = 20; const SAVED_RESPONSES_CAP = 50; const TITLE_MAX_LENGTH = 80; // --------------------------------------------------------------------------- // Conversations // --------------------------------------------------------------------------- /** * Reads the stored conversations array from localStorage. * Returns an empty array on missing key or JSON parse error. */ export function readConversations(): Conversation[] { try { const raw = localStorage.getItem(CONVERSATIONS_KEY); if (raw === null) return []; return JSON.parse(raw) as Conversation[]; } catch { return []; } } /** * Writes conversations to localStorage, enforcing a cap of 20 entries. * Entries are sorted by `last_active_at` descending before the cap is applied, * so the oldest entry is always the one evicted. * * On `QuotaExceededError`: evicts the oldest conversation and retries once. * If the retry also fails, emits a console warning and swallows the error. */ export function writeConversations(conversations: Conversation[]): void { // Sort descending by last_active_at, then cap at 20. const sorted = [...conversations].sort( (a, b) => new Date(b.last_active_at).getTime() - new Date(a.last_active_at).getTime() ); const capped = sorted.slice(0, CONVERSATIONS_CAP); try { localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(capped)); } catch (err) { if (isQuotaError(err)) { // Evict the oldest entry (last in the sorted array) and retry once. const evicted = capped.slice(0, capped.length - 1); try { localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(evicted)); } catch (retryErr) { console.warn( "[storage] Failed to write conversations after quota eviction:", retryErr ); } } } } // --------------------------------------------------------------------------- // Saved responses // --------------------------------------------------------------------------- /** * Reads the stored saved-responses array from localStorage. * Returns an empty array on missing key or JSON parse error. */ export function readSavedResponses(): SavedResponse[] { try { const raw = localStorage.getItem(SAVED_RESPONSES_KEY); if (raw === null) return []; return JSON.parse(raw) as SavedResponse[]; } catch { return []; } } /** * Writes saved responses to localStorage, enforcing a cap of 50 entries. * Entries are sorted by `saved_at` descending before the cap is applied, * so the oldest entry is always the one evicted. * * On `QuotaExceededError`: evicts the oldest response and retries once. * If the retry also fails, emits a console warning and swallows the error. */ export function writeSavedResponses(responses: SavedResponse[]): void { // Sort descending by saved_at, then cap at 50. const sorted = [...responses].sort( (a, b) => new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime() ); const capped = sorted.slice(0, SAVED_RESPONSES_CAP); try { localStorage.setItem(SAVED_RESPONSES_KEY, JSON.stringify(capped)); } catch (err) { if (isQuotaError(err)) { // Evict the oldest entry (last in the sorted array) and retry once. const evicted = capped.slice(0, capped.length - 1); try { localStorage.setItem(SAVED_RESPONSES_KEY, JSON.stringify(evicted)); } catch (retryErr) { console.warn( "[storage] Failed to write saved responses after quota eviction:", retryErr ); } } } } // --------------------------------------------------------------------------- // Utility helpers // --------------------------------------------------------------------------- /** * Derives a conversation title from a message array. * Finds the first message with role "user" and truncates its content to 80 * characters, appending the Unicode ellipsis character (U+2026) if truncated. * Returns "New conversation" when no user message is present. */ export function titleFromMessages(messages: Message[]): string { const userMessage = messages.find((m) => m.role === "user"); if (!userMessage) return "New conversation"; const { content } = userMessage; if (content.length > TITLE_MAX_LENGTH) { return content.slice(0, TITLE_MAX_LENGTH) + "\u2026"; } return content; } /** * Returns the stable ID for a saved response: `"{conversationId}:{msgIndex}"`. */ export function savedResponseId( conversationId: string, msgIndex: number ): string { return `${conversationId}:${msgIndex}`; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function isQuotaError(err: unknown): boolean { if (err instanceof DOMException) { // Standard name across modern browsers. return ( err.name === "QuotaExceededError" || // Legacy Firefox name. err.name === "NS_ERROR_DOM_QUOTA_REACHED" ); } return false; }