| 1 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | import { app } from "../lib/bridge"; |
| 3 | import type { ProjectNode } from "../lib/types"; |
| 4 | import { projectTreeReadActivityKey } from "../lib/projectTreeTopic"; |
| 5 | import { markSessionRead, mergeReadStores, readActivityValues, repairReadBaseline, seedSessionReads, type ReadRecord, type ReadStore } from "../lib/sessionReadActivity"; |
| 6 | |
| 7 | const STORE_KEY = "projectTree:readActivity:v3"; |
| 8 | |
| 9 | function loadStore(): ReadStore { |
| 10 | const empty: ReadStore = { version: 3, baselineAt: Date.now(), records: {} }; |
| 11 | try { |
| 12 | const stored = localStorage.getItem(STORE_KEY); |
| 13 | if (stored) { |
| 14 | const data = JSON.parse(stored) as ReadStore; |
| 15 | if (data.version === 3 && data.records) return data; |
| 16 | } |
| 17 | const legacy = JSON.parse(localStorage.getItem("projectTree:readActivity") || "{}") as Record<string, unknown>; |
| 18 | const baseline = Number(localStorage.getItem("projectTree:readActivityBaselineAt")); |
| 19 | if (baseline > 0) empty.baselineAt = baseline; |
| 20 | for (const [key, value] of Object.entries(legacy)) { |
| 21 | if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue; |
| 22 | // Topic timestamps are retained solely as compatibility import data; |
| 23 | // source rows use their own keys and the existing time baseline. |
| 24 | const canonical = key.startsWith("session\u001f"); |
| 25 | const parts = key.split("\u001f"); |
| 26 | const target = canonical ? `ref\u0000${parts[1] || "local"}\u0000${parts[2]}` : key; |
| 27 | empty.records[target] = { metric: canonical ? "result" : "time", value, revision: 0, imported: true }; |
| 28 | } |
| 29 | localStorage.setItem(STORE_KEY, JSON.stringify(empty)); |
| 30 | } catch { /* Storage may be unavailable. */ } |
| 31 | return empty; |
| 32 | } |
| 33 | |
| 34 | function persist(store: ReadStore): ReadStore { |
| 35 | try { |
| 36 | const merged = mergeReadStores(loadStore(), store); |
| 37 | localStorage.setItem(STORE_KEY, JSON.stringify(merged)); |
| 38 | return merged; |
| 39 | } catch { return store; } |
| 40 | } |
| 41 | |
| 42 | // Deduplicate verification across mounted trees; Query also bounds cold rebuilds. |
| 43 | const verifications = new Map<string, ReturnType<typeof app.GetSessionActivityBaseline>>(); |
| 44 | let activeVerifications = 0; |
| 45 | const waitingVerifications: (() => void)[] = []; |
| 46 | function verifyBaseline(node: ProjectNode, key: string) { |
| 47 | const existing = verifications.get(key); |
| 48 | if (existing) return existing; |
| 49 | const request = new Promise<Awaited<ReturnType<typeof app.GetSessionActivityBaseline>>>((resolve, reject) => { |
| 50 | const run = () => { |
| 51 | activeVerifications++; |
| 52 | void app.GetSessionActivityBaseline({ ref: node.session }).then(resolve, reject).finally(() => { |
| 53 | activeVerifications--; |
| 54 | waitingVerifications.shift()?.(); |
| 55 | }); |
| 56 | }; |
| 57 | if (activeVerifications < 2) run(); else waitingVerifications.push(run); |
| 58 | }); |
| 59 | verifications.set(key, request); |
| 60 | void request.finally(() => verifications.delete(key)).catch(() => {}); |
| 61 | return request; |
| 62 | } |
| 63 | |
| 64 | export function useProjectTreeReadActivity(nodes: readonly ProjectNode[]) { |
| 65 | const [store, setStore] = useState<ReadStore>(loadStore); |
| 66 | const [verificationEpoch, setVerificationEpoch] = useState(0); |
| 67 | const readActivity = useMemo(() => readActivityValues(store), [store]); |
| 68 | const markNodeRead = useCallback((node: ProjectNode) => { |
| 69 | setStore(current => { |
| 70 | const next = markSessionRead(current, node); |
| 71 | return next === current ? current : persist(next); |
| 72 | }); |
| 73 | }, []); |
| 74 | |
| 75 | useEffect(() => { |
| 76 | setStore(current => { |
| 77 | const next = seedSessionReads(current, nodes); |
| 78 | return next === current ? current : persist(next); |
| 79 | }); |
| 80 | }, [nodes]); |
| 81 | |
| 82 | useEffect(() => { |
| 83 | let alive = true; |
| 84 | let retry: ReturnType<typeof setTimeout> | undefined; |
| 85 | const retryLater = () => { |
| 86 | if (alive && !retry) retry = setTimeout(() => { if (alive) setVerificationEpoch(value => value + 1); }, 10_000); |
| 87 | }; |
| 88 | const candidates: { node: ProjectNode; key: string; record: ReadRecord }[] = []; |
| 89 | const visit = (node: ProjectNode) => { |
| 90 | const key = projectTreeReadActivityKey(node), record = key ? store.records[key] : undefined; |
| 91 | if (node.session && (!node.session.hostId || node.session.hostId === "local") && key && record?.metric === "result" |
| 92 | && (record.needsBaseline || record.imported && !record.repairVersion)) candidates.push({ node, key, record }); |
| 93 | node.children?.forEach(visit); |
| 94 | }; |
| 95 | nodes.forEach(visit); |
| 96 | for (const { node, key, record } of candidates) { |
| 97 | const request = verifyBaseline(node, key); |
| 98 | void request.then(observation => { |
| 99 | if (!alive || observation.ref.hostId !== node.session?.hostId || observation.ref.sessionId !== node.session?.sessionId) return; |
| 100 | if (!observation.complete || observation.resultSequence === 0) retryLater(); |
| 101 | setStore(current => { |
| 102 | const next = repairReadBaseline(current, key, record, observation); |
| 103 | return next === current ? current : persist(next); |
| 104 | }); |
| 105 | }).catch(retryLater); |
| 106 | } |
| 107 | return () => { alive = false; if (retry) clearTimeout(retry); }; |
| 108 | }, [nodes, store, verificationEpoch]); |
| 109 | |
| 110 | useEffect(() => { |
| 111 | const changed = (event: StorageEvent) => { |
| 112 | if (event.key !== STORE_KEY || !event.newValue) return; |
| 113 | try { |
| 114 | const incoming = JSON.parse(event.newValue) as ReadStore; |
| 115 | if (incoming.version === 3 && incoming.records) setStore(current => mergeReadStores(current, incoming)); |
| 116 | } catch { /* Ignore invalid external storage. */ } |
| 117 | }; |
| 118 | window.addEventListener("storage", changed); |
| 119 | return () => window.removeEventListener("storage", changed); |
| 120 | }, []); |
| 121 | return { readActivity, readBaselineAt: store.baselineAt, markNodeRead }; |
| 122 | } |
| 123 |