mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add analysis cache hook
This commit is contained in:
@@ -26,13 +26,13 @@ Use a consistent prefix to avoid collisions:
|
|||||||
|
|
||||||
## Tasks
|
## 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)
|
- [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)
|
||||||
- [ ] 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] 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)
|
- [ ] 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
|
## 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
|
- [ ] After analysis, headers and results are saved to localStorage
|
||||||
- [ ] Page refresh restores the previous analysis (headers in input, report rendered)
|
- [ ] 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
|
- [ ] "Clear Cache" button removes all stored data and resets the view to empty state
|
||||||
|
|||||||
171
frontend/src/hooks/useAnalysisCache.ts
Normal file
171
frontend/src/hooks/useAnalysisCache.ts
Normal file
@@ -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 = <T>(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<AnalysisConfig>(localStorage.getItem(CONFIG_KEY));
|
||||||
|
const result = parseJson<AnalysisReport>(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;
|
||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
exclude: ["**/e2e/**", "**/node_modules/**", "**/.next/**", "**/dist/**"],
|
exclude: ["**/e2e/**", "**/node_modules/**", "**/.next/**", "**/dist/**"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
52
frontend/vitest.setup.ts
Normal file
52
frontend/vitest.setup.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const createLocalStorageMock = () => {
|
||||||
|
let store = new Map<string, string>();
|
||||||
|
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user