diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-07-Browser-Caching.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-07-Browser-Caching.md index a60e314..836e6a0 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-07-Browser-Caching.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-07-Browser-Caching.md @@ -26,13 +26,13 @@ Use a consistent prefix to avoid collisions: ## Tasks -- [x] T037 [US5] Write failing tests (TDD Red) in `frontend/src/__tests__/useAnalysisCache.test.ts` (save/load/clear with mocked localStorage, size awareness) and `frontend/src/__tests__/page.test.tsx` (cache restoration on mount, clear cache button) -- [ ] T038 [P] [US5] Create `frontend/src/hooks/useAnalysisCache.ts` — hook managing localStorage with namespaced keys. Saves: headers text, analysis config, analysis result. Size-aware (warns near limits). Methods: `save()`, `load()`, `clear()`, `hasCachedData()`. Verify `useAnalysisCache.test.ts` passes (TDD Green) +- [x] T037 [US5] Write failing tests (TDD Red) in `frontend/src/__tests__/useAnalysisCache.test.tsx` (save/load/clear with mocked localStorage, size awareness) and `frontend/src/__tests__/page.test.tsx` (cache restoration on mount, clear cache button) +- [x] T038 [P] [US5] Create `frontend/src/hooks/useAnalysisCache.ts` — hook managing localStorage with namespaced keys. Saves: headers text, analysis config, analysis result. Size-aware (warns near limits). Methods: `save()`, `load()`, `clear()`, `hasCachedData()`. Verify `useAnalysisCache.test.tsx` passes (TDD Green) - [ ] T039 [US5] Integrate caching into `frontend/src/app/page.tsx` — on mount restore cached data, render cached report, "Clear Cache" button with FontAwesome trash icon (FR-13), subtle indicator when viewing cached results. Verify `page.test.tsx` passes (TDD Green) ## Completion -- [ ] All vitest tests pass: `npx vitest run src/__tests__/useAnalysisCache.test.ts src/__tests__/page.test.tsx` +- [ ] All vitest tests pass: `npx vitest run src/__tests__/useAnalysisCache.test.tsx src/__tests__/page.test.tsx` - [ ] After analysis, headers and results are saved to localStorage - [ ] Page refresh restores the previous analysis (headers in input, report rendered) - [ ] "Clear Cache" button removes all stored data and resets the view to empty state diff --git a/frontend/src/__tests__/useAnalysisCache.test.ts b/frontend/src/__tests__/useAnalysisCache.test.tsx similarity index 100% rename from frontend/src/__tests__/useAnalysisCache.test.ts rename to frontend/src/__tests__/useAnalysisCache.test.tsx diff --git a/frontend/src/hooks/useAnalysisCache.ts b/frontend/src/hooks/useAnalysisCache.ts new file mode 100644 index 0000000..3f53894 --- /dev/null +++ b/frontend/src/hooks/useAnalysisCache.ts @@ -0,0 +1,171 @@ +import { useCallback, useState } from "react"; + +import type { AnalysisConfig, AnalysisReport } from "../types/analysis"; + +export interface CachedAnalysisPayload { + headers: string; + config: AnalysisConfig; + result: AnalysisReport; +} + +export interface CachedAnalysisData extends CachedAnalysisPayload { + timestamp: number; +} + +export interface UseAnalysisCacheState { + save: (payload: CachedAnalysisPayload) => void; + load: () => CachedAnalysisData | null; + clear: () => void; + hasCachedData: () => boolean; + isNearLimit: boolean; +} + +const STORAGE_LIMIT_BYTES = 5 * 1024 * 1024; +const WARNING_THRESHOLD_RATIO = 0.9; + +const HEADER_KEY = "wha:headers"; +const CONFIG_KEY = "wha:config"; +const RESULT_KEY = "wha:result"; +const TIMESTAMP_KEY = "wha:timestamp"; + +const canUseStorage = (): boolean => { + if (typeof window === "undefined") { + return false; + } + if (!window.localStorage) { + return false; + } + + try { + const probeKey = "wha:probe"; + window.localStorage.setItem(probeKey, probeKey); + window.localStorage.removeItem(probeKey); + return true; + } catch { + return false; + } +}; + +const estimateBytes = (value: string): number => { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(value).length; + } + + return value.length; +}; + +const estimateStorageSize = (): number => { + if (!canUseStorage()) { + return 0; + } + + let total = 0; + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (!key) { + continue; + } + + const value = localStorage.getItem(key) ?? ""; + total += estimateBytes(key); + total += estimateBytes(value); + } + + return total; +}; + +const parseJson = (value: string | null): T | null => { + if (!value) { + return null; + } + + try { + return JSON.parse(value) as T; + } catch { + return null; + } +}; + +const useAnalysisCache = (): UseAnalysisCacheState => { + const [isNearLimit, setIsNearLimit] = useState(false); + + const save = useCallback((payload: CachedAnalysisPayload) => { + if (!canUseStorage()) { + setIsNearLimit(false); + return; + } + + const timestamp = Date.now(); + localStorage.setItem(HEADER_KEY, payload.headers); + localStorage.setItem(CONFIG_KEY, JSON.stringify(payload.config)); + localStorage.setItem(RESULT_KEY, JSON.stringify(payload.result)); + localStorage.setItem(TIMESTAMP_KEY, String(timestamp)); + + const usedBytes = estimateStorageSize(); + const warningThreshold = STORAGE_LIMIT_BYTES * WARNING_THRESHOLD_RATIO; + setIsNearLimit(usedBytes >= warningThreshold); + }, []); + + const load = useCallback((): CachedAnalysisData | null => { + if (!canUseStorage()) { + return null; + } + + const headers = localStorage.getItem(HEADER_KEY); + const config = parseJson(localStorage.getItem(CONFIG_KEY)); + const result = parseJson(localStorage.getItem(RESULT_KEY)); + const timestampValue = localStorage.getItem(TIMESTAMP_KEY); + + if (!headers || !config || !result || !timestampValue) { + return null; + } + + const timestamp = Number(timestampValue); + if (Number.isNaN(timestamp)) { + return null; + } + + return { + headers, + config, + result, + timestamp, + }; + }, []); + + const clear = useCallback(() => { + if (!canUseStorage()) { + setIsNearLimit(false); + return; + } + + localStorage.removeItem(HEADER_KEY); + localStorage.removeItem(CONFIG_KEY); + localStorage.removeItem(RESULT_KEY); + localStorage.removeItem(TIMESTAMP_KEY); + setIsNearLimit(false); + }, []); + + const hasCachedData = useCallback((): boolean => { + if (!canUseStorage()) { + return false; + } + + return Boolean( + localStorage.getItem(HEADER_KEY) && + localStorage.getItem(CONFIG_KEY) && + localStorage.getItem(RESULT_KEY) && + localStorage.getItem(TIMESTAMP_KEY), + ); + }, []); + + return { + save, + load, + clear, + hasCachedData, + isNearLimit, + }; +}; + +export default useAnalysisCache; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1cf8aa5..521402b 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, + setupFiles: ["./vitest.setup.ts"], exclude: ["**/e2e/**", "**/node_modules/**", "**/.next/**", "**/dist/**"], }, }); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..0d6b092 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1,52 @@ +const createLocalStorageMock = () => { + let store = new Map(); + + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.has(key) ? store.get(key) ?? null : null; + }, + key(index: number) { + if (index < 0 || index >= store.size) { + return null; + } + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(String(key), String(value)); + }, + }; +}; + +const ensureLocalStorage = () => { + const target = typeof window !== "undefined" ? window : globalThis; + const current = target.localStorage as Storage | undefined; + + if (!current || typeof current.clear !== "function") { + const mock = createLocalStorageMock(); + Object.defineProperty(target, "localStorage", { + value: mock, + configurable: true, + enumerable: true, + writable: false, + }); + if (target !== globalThis) { + Object.defineProperty(globalThis, "localStorage", { + value: mock, + configurable: true, + enumerable: true, + writable: false, + }); + } + } +}; + +ensureLocalStorage();