import { get, set } from 'idb-keyval'; const IDB_KEY = 'comment-read-state-v2'; const LS_KEY = 'comment-read-state-v2'; const LEGACY_IDB_KEY = 'comment-read-state'; const LEGACY_LS_KEY = 'comment-read-state'; const SAVE_DEBOUNCE_MS = 300; const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days /** * Per-task read state: tracks individual comment IDs that have been seen. * `lastUpdated` is used for stale cleanup (prune entries older than 30 days). */ interface TaskReadEntry { readIds: string[]; lastUpdated: number; manualUnread?: boolean; } type ReadState = Record; // key = "teamName/taskId" // Legacy format for migration (v1 stored a single timestamp per task) type LegacyReadState = Record; // --- localStorage helpers --- function lsLoad(): ReadState | null { try { const raw = localStorage.getItem(LS_KEY); if (!raw) return null; const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; return parsed as ReadState; } catch { return null; } } function lsSave(state: ReadState): void { try { localStorage.setItem(LS_KEY, JSON.stringify(state)); } catch { // localStorage full or unavailable — silently ignore } } function lsLoadLegacy(): LegacyReadState | null { try { const raw = localStorage.getItem(LEGACY_LS_KEY); if (!raw) return null; const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; // Verify it's the old format (values are numbers, not objects) const entries = Object.entries(parsed as Record); if (entries.length > 0 && typeof entries[0][1] === 'number') { return parsed as LegacyReadState; } return null; } catch { return null; } } /** * Migrate legacy per-task timestamp to per-comment ID format. * Since we don't have comment IDs from the old format, we treat all * comments with timestamps <= the old lastRead as "read" by storing * a sentinel marker. The actual per-comment tracking starts fresh. */ function migrateLegacy(legacy: LegacyReadState): ReadState { const migrated: ReadState = {}; for (const [key, timestamp] of Object.entries(legacy)) { if (typeof timestamp === 'number' && timestamp > 0) { // Store legacy timestamp as a sentinel — getUnreadCount will use it // for comments older than migration, and per-ID for newer ones. migrated[key] = { readIds: [], lastUpdated: timestamp, }; } } return migrated; } // Synchronous init from localStorage — guarantees first render sees read state let cache: ReadState = {}; const v2Data = lsLoad(); if (v2Data && Object.keys(v2Data).length > 0) { cache = v2Data; } else { const legacyData = lsLoadLegacy(); if (legacyData && Object.keys(legacyData).length > 0) { cache = migrateLegacy(legacyData); } } let loaded = Object.keys(cache).length > 0; let idbAvailable = true; // flips to false on first IndexedDB failure let saveTimer: ReturnType | null = null; const listeners = new Set<() => void>(); // --- useSyncExternalStore API --- export function subscribe(listener: () => void): () => void { listeners.add(listener); if (!loaded) void load(); return () => { listeners.delete(listener); }; } export function getSnapshot(): ReadState { return cache; } // --- Mutations --- /** * Mark specific comment IDs as read for a given team/task. */ export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void { const key = `${teamName}/${taskId}`; const prev = cache[key]; if (commentIds.length === 0) { if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId); return; } const prevSet = new Set(prev?.readIds ?? []); let changed = false; for (const id of commentIds) { if (!prevSet.has(id)) { prevSet.add(id); changed = true; } } if (!changed && !prev?.manualUnread) return; cache = { ...cache, [key]: { readIds: Array.from(prevSet), lastUpdated: Date.now(), }, }; notify(); scheduleSave(); } /** * @deprecated Use markCommentsRead() instead. Kept for backward compatibility * with code that hasn't migrated yet (e.g. flush fallback). */ export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void { const key = `${teamName}/${taskId}`; const prev = cache[key]; // Update lastUpdated to at least this timestamp (for legacy migration support) const prevLastUpdated = prev?.lastUpdated ?? 0; if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return; cache = { ...cache, [key]: { readIds: prev?.readIds ?? [], lastUpdated: Math.max(prevLastUpdated, latestTimestamp), }, }; notify(); scheduleSave(); } /** * Manually mark a task as unread even when it has no unread comments. */ export function markTaskUnread(teamName: string, taskId: string): void { const key = `${teamName}/${taskId}`; const prev = cache[key]; if (prev?.manualUnread) return; cache = { ...cache, [key]: { readIds: prev?.readIds ?? [], lastUpdated: Date.now(), manualUnread: true, }, }; notify(); scheduleSave(); } /** * Clear only the manual unread marker. Comment read state is preserved. */ export function clearTaskManualUnread(teamName: string, taskId: string): void { const key = `${teamName}/${taskId}`; const prev = cache[key]; if (!prev?.manualUnread) return; cache = { ...cache, [key]: { readIds: prev.readIds, lastUpdated: Date.now(), }, }; notify(); scheduleSave(); } /** * Count unread comments for a task. * A comment is unread if its ID is NOT in the readIds set. * * Legacy migration: when readIds is empty (data migrated from v1 timestamp * format), comments created at or before the legacy cutoff are treated as read. * Once any per-ID tracking starts (readIds non-empty), the cutoff is ignored * — only explicit IDs determine read state. This prevents `lastUpdated` * (which is refreshed by markCommentsRead on every save for stale-cleanup * purposes) from accidentally marking ALL comments as read. */ export function getUnreadCount( readState: ReadState, teamName: string, taskId: string, comments: { id?: string; createdAt: string }[] ): number { const key = `${teamName}/${taskId}`; const entry = readState[key]; if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0; if (!entry) return comments.length; const readSet = new Set(entry.readIds); // Only use the timestamp cutoff for pure-legacy entries (no per-ID tracking yet). // Once readIds is non-empty, per-ID tracking is authoritative and the timestamp // must NOT be used — it gets refreshed to Date.now() on every save. const legacyCutoff = readSet.size === 0 ? entry.lastUpdated : 0; let count = 0; for (const c of comments) { // If comment has an ID and it's in the read set → read if (c.id && readSet.has(c.id)) continue; // Legacy-only: comment created before/at the migration cutoff → read if (legacyCutoff > 0) { const ts = new Date(c.createdAt).getTime(); if (ts <= legacyCutoff) continue; } // Otherwise → unread count++; } return entry.manualUnread && count === 0 ? 1 : count; } /** * Get the set of read comment IDs for a team/task pair. */ export function getReadCommentIds(teamName: string, taskId: string): Set { const key = `${teamName}/${taskId}`; const entry = cache[key]; return new Set(entry?.readIds ?? []); } /** * Get the legacy migration cutoff timestamp for a team/task pair (0 if none). * Returns non-zero only for pure-legacy entries where readIds is empty. * Once per-ID tracking has started (readIds non-empty), the cutoff is 0 * because lastUpdated gets refreshed to Date.now() on every save and * would incorrectly mark all comments as read. */ export function getLegacyCutoff(teamName: string, taskId: string): number { const key = `${teamName}/${taskId}`; const entry = cache[key]; if (!entry) return 0; // Only honour the timestamp when no per-ID tracking exists (pure legacy data). if (entry.readIds.length > 0) return 0; return entry.lastUpdated; } /** @deprecated Use getReadCommentIds() + getLegacyCutoff() instead. */ export function getLastReadTimestamp(teamName: string, taskId: string): number { const key = `${teamName}/${taskId}`; return cache[key]?.lastUpdated ?? 0; } // --- Internal --- function hasIndexedDB(): boolean { return typeof indexedDB !== 'undefined'; } function notify(): void { listeners.forEach((l) => l()); } function scheduleSave(): void { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = null; void save(); }, SAVE_DEBOUNCE_MS); } async function load(): Promise { if (loaded) return; if (hasIndexedDB() && idbAvailable) { try { // Try v2 format first const stored = await get(IDB_KEY); if (stored && typeof stored === 'object') { const merged = { ...cache }; for (const [k, v] of Object.entries(stored)) { if (!v || typeof v !== 'object') continue; const entry = v; const prev = merged[k]; if (!prev) { merged[k] = entry; } else { // Merge: union of readIds, max lastUpdated const mergedIds = new Set([...prev.readIds, ...entry.readIds]); merged[k] = { readIds: Array.from(mergedIds), lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated), ...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}), }; } } cache = merged; notify(); } else { // Try legacy IDB format const legacy = await get(LEGACY_IDB_KEY); if (legacy && typeof legacy === 'object') { const migrated = migrateLegacy(legacy); const merged = { ...cache }; for (const [k, v] of Object.entries(migrated)) { if (!merged[k]) { merged[k] = v; } else { merged[k] = { readIds: [...new Set([...merged[k].readIds, ...v.readIds])], lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated), ...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}), }; } } cache = merged; notify(); } } } catch { idbAvailable = false; } } loaded = true; } async function save(): Promise { // Always write to localStorage (sync, reliable) lsSave(cache); // Also write to IndexedDB (async, primary) if (idbAvailable && hasIndexedDB()) { try { await set(IDB_KEY, cache); } catch { idbAvailable = false; } } } export async function cleanupStale(): Promise { const now = Date.now(); let changed = false; const result: ReadState = {}; for (const [k, v] of Object.entries(cache)) { if (now - v.lastUpdated < STALE_THRESHOLD_MS) { result[k] = v; } else { changed = true; } } if (!changed) return; // Update in-memory cache cache = result; notify(); // Persist to both storages lsSave(result); if (idbAvailable && hasIndexedDB()) { try { await set(IDB_KEY, result); } catch { idbAvailable = false; } } }