import { computed, ref, watch } from 'vue' import { defineStore } from 'pinia' import type { SearchHit } from '~/types' /** * Historial de "documentos vistos". Cada vez que el usuario abre el panel de * detalle de un resultado, registramos el documento aquí. * * Diseño: * - Misma arquitectura que `~/stores/favorites.ts` (persistencia en * localStorage, hidratación post-SSR vía plugin, sync entre pestañas). * - Una entrada por documento: si vuelve a abrirse, se mueve al inicio y se * actualiza `visitedAt` (comportamiento "como el historial del navegador"). * - Límite duro de `HISTORY_LIMIT` entradas: cuando se supera, descartamos las * más antiguas (FIFO al fondo de la lista). * - El identificador de colección no es un set cerrado — funciona con * cualquier colección que se sume en el futuro. */ export type Collection = string export interface HistoryItem { collection: Collection _id: string | number /** Snapshot del documento para poder mostrarlo en el historial sin volver a * consultar al backend (igual que hacemos con favoritos). */ hit: SearchHit /** Última vez que se abrió el detalle (epoch ms). Si el documento se reabre, * este valor se actualiza y el item salta al inicio. */ visitedAt: number } export interface HistoryFile { version: 1 exportedAt: string items: HistoryItem[] } export interface ImportResult { added: number skipped: number invalid: number total: number } const STORAGE_KEY = 'lgcc:history:v1' /** Tope práctico de entradas. 200 cubre de sobra una sesión de uso intensivo * sin inflar el JSON ni convertir la lista en algo inmanejable. Si llegamos * a este número, las entradas más antiguas se van descartando. */ export const HISTORY_LIMIT = 200 function isValidHistoryItem(x: unknown): x is HistoryItem { if (!x || typeof x !== 'object') return false const o = x as Record if (typeof o.collection !== 'string') return false if (typeof o._id !== 'string' && typeof o._id !== 'number') return false if (!o.hit || typeof o.hit !== 'object') return false return true } function readStorage(): HistoryItem[] { if (typeof window === 'undefined') return [] try { const raw = window.localStorage.getItem(STORAGE_KEY) if (!raw) return [] const parsed: unknown = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed.filter(isValidHistoryItem) } catch { return [] } } function writeStorage(items: HistoryItem[]) { if (typeof window === 'undefined') return try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) } catch (e) { console.warn('No se pudo guardar el historial', e) } } function sameKey(a: { collection: Collection, _id: string | number }, b: { collection: Collection, _id: string | number }) { return a.collection === b.collection && String(a._id) === String(b._id) } /** * Store de Pinia para el historial de detalles abiertos. * * - Persiste en `localStorage` con la clave `lgcc:history:v1`. * - Hidrata en cliente vía el plugin `~/plugins/history.client.ts` para que * el payload SSR vacío no pise lo que ya hay en el navegador. * - Sincroniza entre pestañas escuchando el evento `storage`. */ export const useHistoryStore = defineStore('history', () => { const items = ref([]) const ready = ref(false) let listenerAttached = false let hydrated = false function hydrate() { if (typeof window === 'undefined') return if (hydrated) return items.value = readStorage() ready.value = true hydrated = true if (!listenerAttached) { window.addEventListener('storage', (e) => { if (e.key !== STORAGE_KEY) return items.value = readStorage() }) listenerAttached = true } } function commit(next: HistoryItem[]) { // Aplicamos el límite al persistir: nunca dejamos más de HISTORY_LIMIT // entradas. Como `visit()` siempre añade al inicio, recortamos por el final. const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next items.value = trimmed writeStorage(trimmed) } // Red de seguridad: cualquier mutación directa de `items.value` se persiste. if (typeof window !== 'undefined') { watch(items, (next) => { if (!hydrated) return writeStorage(next) }, { deep: true }) } // ---- Getters ---------------------------------------------------------- const total = computed(() => items.value.length) const collections = computed(() => { const seen = new Set() for (const it of items.value) seen.add(it.collection) return Array.from(seen).sort() }) function inHistory(collection: Collection, id: string | number): boolean { return items.value.some(it => sameKey(it, { collection, _id: id })) } function byCollection(collection: Collection) { return computed(() => items.value.filter(it => it.collection === collection)) } function countByCollection(collection: Collection) { return computed(() => items.value.filter(it => it.collection === collection).length) } // ---- Acciones --------------------------------------------------------- /** * Registra (o re-registra) una visita a un documento. * * Comportamiento "tipo historial de navegador": si el documento ya estaba, * se elimina de su posición previa y se reinserta al principio con el * timestamp actualizado, así el orden de la lista refleja la última vez * que se vio cada documento. */ function visit(collection: Collection, hit: SearchHit) { if (!hit?._id) return // Limpiamos campos volátiles de Meilisearch/Typesense que cambian entre // búsquedas — sin esto el JSON exportable se llenaría de ruido y, peor, // dos visitas idénticas se considerarían "diferentes" para el watcher // de profundidad. Es la misma higiene que hace el store de favoritos. const cleanHit = { ...hit } as SearchHit delete (cleanHit as Record)._matchesPosition delete (cleanHit as Record)._formatted const key = { collection, _id: hit._id } const rest = items.value.filter(it => !sameKey(it, key)) commit([ { collection, _id: hit._id, hit: cleanHit, visitedAt: Date.now() }, ...rest ]) } function remove(collection: Collection, id: string | number) { commit(items.value.filter(it => !sameKey(it, { collection, _id: id }))) } function clear() { commit([]) } function clearCollection(collection: Collection) { commit(items.value.filter(it => it.collection !== collection)) } // ---- Export / Import -------------------------------------------------- function exportToJson(): string { const file: HistoryFile = { version: 1, exportedAt: new Date().toISOString(), items: items.value } return JSON.stringify(file, null, 2) } function downloadJson(filename?: string) { if (typeof window === 'undefined') return const name = filename || `historial-${new Date().toISOString().slice(0, 10)}.json` const blob = new Blob([exportToJson()], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = name document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } async function copyJsonToClipboard(): Promise { if (typeof window === 'undefined') return false const text = exportToJson() try { await navigator.clipboard.writeText(text) return true } catch { return false } } function importFromJson(json: string, mode: 'merge' | 'replace' = 'merge'): ImportResult { let parsed: unknown try { parsed = JSON.parse(json) } catch { throw new Error('El archivo no es un JSON válido.') } let incoming: unknown[] if (Array.isArray(parsed)) { incoming = parsed } else if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { items?: unknown[] }).items)) { incoming = (parsed as { items: unknown[] }).items } else { throw new Error('El JSON no tiene la forma esperada (debe contener una lista de items de historial).') } const valid = incoming.filter(isValidHistoryItem) const invalid = incoming.length - valid.length if (mode === 'replace') { const next = valid .map(v => ({ ...v, visitedAt: (v as HistoryItem).visitedAt || Date.now() })) // Importamos respetando el orden temporal (más reciente primero). .sort((a, b) => b.visitedAt - a.visitedAt) commit(next) return { added: next.length, skipped: 0, invalid, total: items.value.length } } const next = [...items.value] let added = 0 let skipped = 0 for (const inc of valid) { if (next.some(it => sameKey(it, inc))) { skipped++; continue } next.push({ ...inc, visitedAt: (inc as HistoryItem).visitedAt || Date.now() }) added++ } // Reordenamos por visitedAt para mantener invariante "más reciente arriba". next.sort((a, b) => b.visitedAt - a.visitedAt) commit(next) return { added, skipped, invalid, total: items.value.length } } async function importFromFile(file: File, mode: 'merge' | 'replace' = 'merge') { const text = await file.text() return importFromJson(text, mode) } return { // state items, ready, // getters total, collections, // queries inHistory, byCollection, countByCollection, // mutations hydrate, visit, remove, clear, clearCollection, // export/import exportToJson, downloadJson, copyJsonToClipboard, importFromJson, importFromFile } })