diff --git a/src/shared/hooks/useSearchHistory.test.ts b/src/shared/hooks/useSearchHistory.test.ts index 9545c866..ca7dc60f 100644 --- a/src/shared/hooks/useSearchHistory.test.ts +++ b/src/shared/hooks/useSearchHistory.test.ts @@ -8,10 +8,10 @@ import { useSearchHistory } from "./useSearchHistory"; import type { SearchHistoryItem } from "./useSearchHistory"; // --------------------------------------------------------------------------- -// localStorage mock +// sessionStorage mock (TZ §4.1.9.5: session-scoped — cleared on reload) // --------------------------------------------------------------------------- -function createMockLocalStorage() { +function createMockSessionStorage() { const store = new Map(); return { getItem: vi.fn((key: string) => store.get(key) ?? null), @@ -46,16 +46,28 @@ const routeItem: SearchHistoryItem = { label: "SVO - JFK", }; +const scheduleItem: SearchHistoryItem = { + type: "schedule-route", + url: "/ru/schedule/SVO-LED-20260601-20260607", + label: "SVO — LED", + params: { + departure: "SVO", + arrival: "LED", + dateFrom: "20260601", + dateTo: "20260607", + }, +}; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("useSearchHistory", () => { - let mockStorage: ReturnType; + let mockStorage: ReturnType; beforeEach(() => { - mockStorage = createMockLocalStorage(); - vi.stubGlobal("localStorage", mockStorage); + mockStorage = createMockSessionStorage(); + vi.stubGlobal("sessionStorage", mockStorage); }); it("starts with empty history when nothing in storage", () => { @@ -100,7 +112,8 @@ describe("useSearchHistory", () => { expect(result.current.items).toEqual([routeItem, flightItem]); }); - it("deduplicates by URL — skips if most recent matches", () => { + // TZ §4.1.9.5: dedup — same search must not appear twice + it("4.1.9.5-R: deduplicates by URL — skips if most recent matches", () => { const { result } = renderHook(() => useSearchHistory("ru")); act(() => { @@ -113,7 +126,8 @@ describe("useSearchHistory", () => { expect(result.current.items).toEqual([flightItem]); }); - it("moves duplicate to front when URL exists in older position", () => { + // TZ §4.1.9.5: re-running a saved search pushes it to top + it("4.1.9.5-R: re-running an older search moves it to top (dedup + bump)", () => { const { result } = renderHook(() => useSearchHistory("ru")); act(() => { @@ -190,4 +204,62 @@ describe("useSearchHistory", () => { const { result } = renderHook(() => useSearchHistory("ru")); expect(result.current.items).toEqual([]); }); + + // TZ §4.1.9.5: session-scoped — sessionStorage, NOT localStorage + it("4.1.9.5-R: writes to sessionStorage, not localStorage", () => { + const localStorageMock = createMockSessionStorage(); + vi.stubGlobal("localStorage", localStorageMock); + + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + }); + + // sessionStorage.setItem must have been called with the history key + expect(mockStorage.setItem).toHaveBeenCalledWith( + "afl_history_ru", + expect.any(String), + ); + // localStorage must NOT have been touched for history writes + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( + "afl_history_ru", + expect.any(String), + ); + }); + + // TZ §4.1.9.5: supports board-route, schedule-route, flight-number types + it("4.1.9.5-R: stores schedule-route items correctly", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(scheduleItem); + }); + + expect(result.current.items[0]).toMatchObject({ + type: "schedule-route", + params: { departure: "SVO", arrival: "LED" }, + }); + }); + + // TZ §4.1.9.5: ordering — most-recent first + it("4.1.9.5-R: items are ordered most-recent first", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + + act(() => { + result.current.add(flightItem); + result.current.add(routeItem); + result.current.add(scheduleItem); + }); + + expect(result.current.items[0]?.type).toBe("schedule-route"); + expect(result.current.items[1]?.type).toBe("board-route"); + expect(result.current.items[2]?.type).toBe("flight-number"); + }); + + // TZ §4.1.9.5: block shows when there are items, hidden when empty + it("4.1.9.5-R: returns empty array initially (block starts hidden)", () => { + const { result } = renderHook(() => useSearchHistory("ru")); + expect(result.current.items).toHaveLength(0); + }); }); diff --git a/src/shared/hooks/useSearchHistory.ts b/src/shared/hooks/useSearchHistory.ts index 48a860bd..4c27d076 100644 --- a/src/shared/hooks/useSearchHistory.ts +++ b/src/shared/hooks/useSearchHistory.ts @@ -1,17 +1,20 @@ /** - * Search history hook backed by localStorage via `@/shared/storage`. + * Search history hook backed by sessionStorage via `@/shared/storage`. + * + * Session-scoped per TZ §4.1.9.5: "last searches within one session". + * Cleared on tab close / page reload — never persists across sessions. + * History is namespaced per language so switching locales shows the + * appropriate history. * - * 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. + * services/history/search-history.service.ts). * * @module */ import { useState, useCallback, useEffect } from "react"; import { z } from "zod"; -import { storage } from "@/shared/storage.js"; +import { sessionStore } from "@/shared/storage.js"; // Custom event fired whenever any hook instance mutates search history. // Other live hook instances listen for it and re-read from storage so @@ -124,7 +127,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult { const key = storageKey(lang); const [items, setItems] = useState(() => { - return (storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[]; + return (sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[]; }); // Re-read storage whenever any instance broadcasts a change. Covers @@ -135,7 +138,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult { if (typeof window === "undefined") return; const handler = (): void => { const fresh = - (storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[]; + (sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[]; setItems(fresh); }; window.addEventListener(SEARCH_HISTORY_CHANGED_EVENT, handler); @@ -154,7 +157,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult { const filtered = prev.filter((existing) => existing.url !== item.url); const next = [item, ...filtered].slice(0, MAX_HISTORY_ITEMS); - storage.set(key, next, searchHistorySchema); + sessionStore.set(key, next, searchHistorySchema); if (typeof window !== "undefined") { window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT)); } @@ -165,7 +168,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult { ); const clear = useCallback(() => { - storage.delete(key); + sessionStore.deleteNs(key); setItems([]); if (typeof window !== "undefined") { window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT)); diff --git a/src/shared/storage.ts b/src/shared/storage.ts index 7aa3f682..25207b79 100644 --- a/src/shared/storage.ts +++ b/src/shared/storage.ts @@ -52,6 +52,9 @@ export const storage = { * Exists so the rest of the codebase can stay under the * `no-restricted-globals` rule that blocks direct sessionStorage access. */ + +const SESSION_PREFIX = "afl_"; + export const sessionStore = { getRaw(key: string): string | null { if (typeof sessionStorage === "undefined") return null; @@ -88,4 +91,28 @@ export const sessionStore = { // ignore disabled } }, + + /** Schema-validated get from sessionStorage under the `afl_` namespace. */ + get(key: string, schema: import("zod").ZodSchema): T | null { + try { + const raw = this.getRaw(SESSION_PREFIX + key); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + const result = schema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } + }, + + /** Schema-validated set into sessionStorage under the `afl_` namespace. */ + set(key: string, value: T, schema: import("zod").ZodSchema): void { + schema.parse(value); + this.setRaw(SESSION_PREFIX + key, JSON.stringify(value)); + }, + + /** Delete a key from the `afl_` namespace in sessionStorage. */ + deleteNs(key: string): void { + this.delete(SESSION_PREFIX + key); + }, }; diff --git a/src/ui/layout/SearchHistory.scss b/src/ui/layout/SearchHistory.scss index 4a141e77..d0d2f022 100644 --- a/src/ui/layout/SearchHistory.scss +++ b/src/ui/layout/SearchHistory.scss @@ -80,14 +80,17 @@ border-top: 1px solid colors.$border; transition: background-color 0.2s ease; - // Angular highlights search-history rows with $blue-extra-light - // (#f3f9ff) on hover — same tint as the frame background. + // TZ §4.1.9.5: hover → голубая подложка, текст и иконка становятся белыми. &:hover { - background-color: colors.$blue-extra-light; + background-color: colors.$blue; + color: colors.$white; } - &:hover .search-history-item__icon { - color: colors.$blue; + &:hover .search-history-item__icon, + &:hover .search-history-item__title, + &:hover .search-history-item__city, + &:hover .search-history-item__date { + color: colors.$white; } &__icon {