From 99af1fe00d8aee4fa502dd63e4dc3969f873c165 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 10:01:31 +0300 Subject: [PATCH] Add Phase 5D useSearchHistory hook with per-language namespacing Persists search history to localStorage via @/shared/storage with language-scoped keys (afl_history_{lang}). Supports dedup by URL, max 10 items, and clear functionality. --- src/shared/hooks/useSearchHistory.test.ts | 195 ++++++++++++++++++++++ src/shared/hooks/useSearchHistory.ts | 106 ++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 src/shared/hooks/useSearchHistory.test.ts create mode 100644 src/shared/hooks/useSearchHistory.ts diff --git a/src/shared/hooks/useSearchHistory.test.ts b/src/shared/hooks/useSearchHistory.test.ts new file mode 100644 index 00000000..90389f98 --- /dev/null +++ b/src/shared/hooks/useSearchHistory.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useSearchHistory } from "./useSearchHistory"; +import type { SearchHistoryItem } from "./useSearchHistory"; + +// --------------------------------------------------------------------------- +// localStorage mock +// --------------------------------------------------------------------------- + +function createMockLocalStorage() { + const store = new Map(); + return { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + get length() { + return store.size; + }, + key: vi.fn((index: number) => [...store.keys()][index] ?? null), + clear: vi.fn(() => store.clear()), + store, + } satisfies Storage & { store: Map }; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const flightItem: SearchHistoryItem = { + type: "flight-number", + url: "/ru/onlineboard/flight/SU0001-20250528", + label: "SU 0001", +}; + +const routeItem: SearchHistoryItem = { + type: "board-route", + url: "/ru/onlineboard/route/SVO-JFK-20250528", + label: "SVO - JFK", +}; + +const scheduleItem: SearchHistoryItem = { + type: "schedule-route", + url: "/ru/schedule/route/SVO-LED/20250601-20250607", + label: "SVO - LED", +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("useSearchHistory", () => { + let mockStorage: ReturnType; + + beforeEach(() => { + mockStorage = createMockLocalStorage(); + vi.stubGlobal("localStorage", mockStorage); + }); + + it("starts with empty history when nothing in storage", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + expect(result.current.items).toEqual([]); + }); + + it("loads existing history from storage on mount", () => { + mockStorage.store.set( + "afl_history_ru", + JSON.stringify([flightItem]), + ); + + const { result } = renderHook(() => useSearchHistory("ru")); + expect(result.current.items).toEqual([flightItem]); + }); + + it("adds an item and persists to storage", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + }); + + expect(result.current.items).toEqual([flightItem]); + expect(mockStorage.setItem).toHaveBeenCalledWith( + "afl_history_ru", + JSON.stringify([flightItem]), + ); + }); + + it("prepends new items (most recent first)", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + }); + act(() => { + result.current.add(routeItem); + }); + + expect(result.current.items).toEqual([routeItem, flightItem]); + }); + + it("deduplicates by URL — skips if most recent matches", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + }); + act(() => { + result.current.add(flightItem); + }); + + expect(result.current.items).toEqual([flightItem]); + }); + + it("moves duplicate to front when URL exists in older position", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + }); + act(() => { + result.current.add(routeItem); + }); + act(() => { + result.current.add(flightItem); + }); + + expect(result.current.items).toEqual([flightItem, routeItem]); + }); + + it("limits history to 10 items", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + for (let i = 0; i < 12; i++) { + act(() => { + result.current.add({ + type: "flight-number", + url: `/flight-${i}`, + label: `Flight ${i}`, + }); + }); + } + + expect(result.current.items).toHaveLength(10); + // Most recent should be first + expect(result.current.items[0]?.url).toBe("/flight-11"); + }); + + it("namespaces by language — different langs have separate history", () => { + const { result: ruResult } = renderHook(() => useSearchHistory("ru")); + const { result: enResult } = renderHook(() => useSearchHistory("en")); + + act(() => { + ruResult.current.add(flightItem); + }); + + expect(ruResult.current.items).toEqual([flightItem]); + expect(enResult.current.items).toEqual([]); + }); + + it("clear removes all items and deletes from storage", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + result.current.add(routeItem); + }); + act(() => { + result.current.clear(); + }); + + expect(result.current.items).toEqual([]); + expect(mockStorage.removeItem).toHaveBeenCalledWith("afl_history_ru"); + }); + + it("gracefully handles corrupted storage data", () => { + mockStorage.store.set("afl_history_ru", "not-valid-json{{{"); + + const { result } = renderHook(() => useSearchHistory("ru")); + expect(result.current.items).toEqual([]); + }); + + it("gracefully handles invalid schema in storage", () => { + mockStorage.store.set( + "afl_history_ru", + JSON.stringify([{ invalid: true }]), + ); + + const { result } = renderHook(() => useSearchHistory("ru")); + expect(result.current.items).toEqual([]); + }); +}); diff --git a/src/shared/hooks/useSearchHistory.ts b/src/shared/hooks/useSearchHistory.ts new file mode 100644 index 00000000..dcbe394c --- /dev/null +++ b/src/shared/hooks/useSearchHistory.ts @@ -0,0 +1,106 @@ +/** + * Search history hook backed by localStorage via `@/shared/storage`. + * + * Persists search history per language with namespaced storage keys. + * Angular equivalent: `SearchHistoryService` (ClientApp/src/app/shared/ + * services/history/search-history.service.ts), but enhanced with + * localStorage persistence and per-language namespacing. + * + * @module + */ + +import { useState, useCallback } from "react"; +import { z } from "zod"; +import { storage } from "@/shared/storage.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Search history item type — mirrors Angular's ISearchHistoryItemType */ +export type SearchHistoryItemType = + | "board-route" + | "schedule-route" + | "flight-number"; + +/** Search history item */ +export interface SearchHistoryItem { + type: SearchHistoryItemType; + url: string; + label: string; +} + +export interface UseSearchHistoryResult { + items: SearchHistoryItem[]; + add: (item: SearchHistoryItem) => void; + clear: () => void; +} + +// --------------------------------------------------------------------------- +// Schema (for validated storage round-tripping) +// --------------------------------------------------------------------------- + +const searchHistoryItemSchema = z.object({ + type: z.enum(["board-route", "schedule-route", "flight-number"]), + url: z.string(), + label: z.string(), +}); + +const searchHistorySchema = z.array(searchHistoryItemSchema); + +type SearchHistoryData = z.infer; + +// --------------------------------------------------------------------------- +// Storage key +// --------------------------------------------------------------------------- + +const MAX_HISTORY_ITEMS = 10; + +function storageKey(lang: string): string { + return `history_${lang}`; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Hook for search history persisted to localStorage via `@/shared/storage`. + * + * @param lang - Current language code (e.g. "ru", "en"). History is + * namespaced per language so switching locales shows the appropriate + * history. + */ +export function useSearchHistory(lang: string): UseSearchHistoryResult { + const key = storageKey(lang); + + const [items, setItems] = useState(() => { + return storage.get(key, searchHistorySchema) ?? []; + }); + + const add = useCallback( + (item: SearchHistoryItem) => { + setItems((prev) => { + // Deduplicate by URL — don't add if the most recent entry matches + if (prev[0]?.url === item.url) { + return prev; + } + + // Remove any existing entry with same URL, then prepend + const filtered = prev.filter((existing) => existing.url !== item.url); + const next = [item, ...filtered].slice(0, MAX_HISTORY_ITEMS); + + storage.set(key, next, searchHistorySchema); + return next; + }); + }, + [key], + ); + + const clear = useCallback(() => { + storage.delete(key); + setItems([]); + }, [key]); + + return { items, add, clear }; +}