diff --git a/periscope/src/frontend/src/hooks/use-reviewed-count.ts b/periscope/src/frontend/src/hooks/use-reviewed-count.ts new file mode 100644 index 0000000..289dfba --- /dev/null +++ b/periscope/src/frontend/src/hooks/use-reviewed-count.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + legacyReviewedFindingsKey, + migrateLocalKey, + reviewedFindingsKey, +} from "@/lib/storage-keys"; + +/** Dashboard-only: length of the reviewed-finding id list in localStorage. */ +export function useReviewedCount(projectId: string): number { + const [count, setCount] = useState(0); + + useEffect(() => { + try { + const raw = migrateLocalKey( + reviewedFindingsKey(projectId), + legacyReviewedFindingsKey(projectId), + ); + if (!raw) { + setCount(0); + return; + } + const parsed: unknown = JSON.parse(raw); + setCount(Array.isArray(parsed) ? parsed.length : 0); + } catch (err) { + console.warn("useReviewedCount: could not read reviewed ids", err); + setCount(0); + } + }, [projectId]); + + return count; +} diff --git a/periscope/src/frontend/src/hooks/use-reviewed-findings.ts b/periscope/src/frontend/src/hooks/use-reviewed-findings.ts new file mode 100644 index 0000000..32963f9 --- /dev/null +++ b/periscope/src/frontend/src/hooks/use-reviewed-findings.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Finding } from "@/lib/types"; +import { getFindingKey } from "@/lib/utils"; +import { + legacyReviewedFindingsKey, + migrateLocalKey, + reviewedFindingsKey, +} from "@/lib/storage-keys"; + +function readStoredIds(projectId: string): Set { + try { + const raw = migrateLocalKey( + reviewedFindingsKey(projectId), + legacyReviewedFindingsKey(projectId), + ); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((id): id is string => typeof id === "string")); + } catch (err) { + console.warn("useReviewedFindings: could not read reviewed ids", err); + return new Set(); + } +} + +export function useReviewedFindings(projectId: string, findings: Finding[]) { + const liveKeys = useMemo(() => { + const keys = new Set(); + findings.forEach((finding, index) => keys.add(getFindingKey(finding, index))); + return keys; + }, [findings]); + + const [reviewedIds, setReviewedIds] = useState>(() => { + if (typeof window === "undefined") return new Set(); + return readStoredIds(projectId); + }); + + const skipFirstWrite = useRef(true); + useEffect(() => { + if (skipFirstWrite.current) { + skipFirstWrite.current = false; + return; + } + localStorage.setItem(reviewedFindingsKey(projectId), JSON.stringify([...reviewedIds])); + }, [projectId, reviewedIds]); + + useEffect(() => { + if (findings.length === 0) return; + setReviewedIds((prev) => { + let dropped = false; + const next = new Set(); + for (const key of prev) { + if (liveKeys.has(key)) next.add(key); + else dropped = true; + } + return dropped ? next : prev; + }); + }, [findings.length, liveKeys]); + + const toggleReviewed = useCallback((key: string) => { + setReviewedIds((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const isReviewed = useCallback((key: string) => reviewedIds.has(key), [reviewedIds]); + + return { + reviewedIds, + toggleReviewed, + isReviewed, + reviewedCount: reviewedIds.size, + }; +} diff --git a/tests/test_periscope_frontend_reviewed_hooks_rewrite.py b/tests/test_periscope_frontend_reviewed_hooks_rewrite.py new file mode 100644 index 0000000..d28da00 --- /dev/null +++ b/tests/test_periscope_frontend_reviewed_hooks_rewrite.py @@ -0,0 +1,33 @@ +"""Reviewed-finding localStorage hooks live under periscope/src.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "periscope" / "src" / "frontend" / "src" + + +def test_reviewed_hooks_are_src(): + for rel in ("hooks/use-reviewed-count.ts", "hooks/use-reviewed-findings.ts"): + text = (SRC / rel).read_text(encoding="utf-8") + assert "Native Periscope overlay" not in text[:400] + + +def test_reviewed_count_reads_migrated_key(): + text = (SRC / "hooks/use-reviewed-count.ts").read_text(encoding="utf-8") + assert "export function useReviewedCount" in text + assert "reviewedFindingsKey" in text + assert "legacyReviewedFindingsKey" in text + assert "migrateLocalKey" in text + + +def test_reviewed_findings_return_shape(): + text = (SRC / "hooks/use-reviewed-findings.ts").read_text(encoding="utf-8") + assert "export function useReviewedFindings" in text + assert "toggleReviewed" in text + assert "isReviewed" in text + assert "reviewedCount" in text + assert "reviewedIds" in text + assert "getFindingKey" in text + assert "skipFirstWrite" in text or "hydrated" in text