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.
This commit is contained in:
2026-04-15 10:01:31 +03:00
parent e172df8cf9
commit 99af1fe00d
2 changed files with 301 additions and 0 deletions
+195
View File
@@ -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<string, string>();
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<string, string> };
}
// ---------------------------------------------------------------------------
// 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<typeof createMockLocalStorage>;
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([]);
});
});
+106
View File
@@ -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<typeof searchHistorySchema>;
// ---------------------------------------------------------------------------
// 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<SearchHistoryItem[]>(() => {
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 };
}