From df7838c2345d7180fd7210f6d97ce8b449b55ecf Mon Sep 17 00:00:00 2001 From: mona-i Date: Sat, 18 Jul 2026 20:50:20 +0100 Subject: [PATCH] feat: persist watchlist and bridge filter/sort preferences across sessions - Migrate useWatchlist from React context + raw localStorage to a Zustand persist store (src/stores/watchlistStore.ts) backed by the namespaced key swipely:watchlist (v1). Supports legacy key migration (swipely.watchlists.v1), schema normalization, and graceful handling of corrupt or outdated persisted data. - Add src/stores/bridgeFilterSortStore.ts: Zustand persist store for bridge status filter and sort-by preferences, namespaced under swipely:bridge-filter-sort (v1), with validation guards that fall back to safe defaults on unknown/corrupt shapes. - Update BridgeFilterSort component to read from and write to the bridgeFilterSortStore when no controlled props are supplied; controlled usage continues to work via the existing prop API. - Wire applyBridgeFilterSort into Bridges.tsx so the persisted filter/sort state is applied to the bridge list on every render. - Refactor useWatchlist hook to delegate to useWatchlistStore; preserve WatchlistProvider as a no-op pass-through for call-site compatibility. - Export both new stores and their helpers from src/stores/index.ts. - Add/update unit tests for both stores covering: rehydration across restarts, graceful corrupt-data handling, legacy key migration, clear clears persisted state, and invalid value guards. --- src/components/BridgeFilterSort.tsx | 55 +++- src/hooks/useWatchlist.test.tsx | 10 +- src/hooks/useWatchlist.ts | 388 +++-------------------- src/pages/Bridges.tsx | 82 +++-- src/stores/bridgeFilterSortStore.test.ts | 125 ++++++++ src/stores/bridgeFilterSortStore.ts | 168 ++++++++++ src/stores/index.ts | 30 ++ src/stores/watchlistStore.test.ts | 122 +++++++ src/stores/watchlistStore.ts | 351 ++++++++++++++++++++ 9 files changed, 934 insertions(+), 397 deletions(-) create mode 100644 src/stores/bridgeFilterSortStore.test.ts create mode 100644 src/stores/bridgeFilterSortStore.ts create mode 100644 src/stores/watchlistStore.test.ts create mode 100644 src/stores/watchlistStore.ts diff --git a/src/components/BridgeFilterSort.tsx b/src/components/BridgeFilterSort.tsx index dd7a2d0..4215aeb 100644 --- a/src/components/BridgeFilterSort.tsx +++ b/src/components/BridgeFilterSort.tsx @@ -1,16 +1,53 @@ +import { + useBridgeFilterSortStore, + type BridgeSortBy, + type BridgeStatusFilter, +} from "../stores/bridgeFilterSortStore"; + interface BridgeFilterSortProps { - statusFilter: string; - onStatusFilterChange: (status: string) => void; - sortBy: string; - onSortByChange: (sortBy: string) => void; + /** Optional controlled status filter; defaults to the persisted store value. */ + statusFilter?: string; + onStatusFilterChange?: (status: string) => void; + /** Optional controlled sort key; defaults to the persisted store value. */ + sortBy?: string; + onSortByChange?: (sortBy: string) => void; } +/** + * Bridge status filter + sort controls. + * Selections are persisted to localStorage via `useBridgeFilterSortStore` + * unless fully controlled by the parent through props. + */ export default function BridgeFilterSort({ - statusFilter, + statusFilter: statusFilterProp, onStatusFilterChange, - sortBy, + sortBy: sortByProp, onSortByChange, -}: BridgeFilterSortProps) { +}: BridgeFilterSortProps = {}) { + const storeStatusFilter = useBridgeFilterSortStore((s) => s.statusFilter); + const storeSortBy = useBridgeFilterSortStore((s) => s.sortBy); + const setStatusFilter = useBridgeFilterSortStore((s) => s.setStatusFilter); + const setSortBy = useBridgeFilterSortStore((s) => s.setSortBy); + + const statusFilter = statusFilterProp ?? storeStatusFilter; + const sortBy = sortByProp ?? storeSortBy; + + const handleStatusChange = (value: string) => { + if (onStatusFilterChange) { + onStatusFilterChange(value); + } else { + setStatusFilter(value as BridgeStatusFilter); + } + }; + + const handleSortChange = (value: string) => { + if (onSortByChange) { + onSortByChange(value); + } else { + setSortBy(value as BridgeSortBy); + } + }; + return (
@@ -20,7 +57,7 @@ export default function BridgeFilterSort({ onSortByChange(e.target.value)} + onChange={(e) => handleSortChange(e.target.value)} className="bg-stellar-card border border-stellar-border rounded px-3 py-1.5 text-sm text-stellar-text-primary focus:outline-none focus:ring-2 focus:ring-stellar-blue" > diff --git a/src/hooks/useWatchlist.test.tsx b/src/hooks/useWatchlist.test.tsx index 646e8aa..b555609 100644 --- a/src/hooks/useWatchlist.test.tsx +++ b/src/hooks/useWatchlist.test.tsx @@ -2,6 +2,7 @@ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, beforeEach, beforeAll, afterAll, afterEach } from "vitest"; import { setupServer } from "msw/node"; import { useWatchlist, WatchlistProvider } from "./useWatchlist"; +import { useWatchlistStore, WATCHLIST_STORAGE_KEY } from "../stores/watchlistStore"; import React from "react"; // Mock MSW server setup to fulfill established frontend testing patterns, @@ -15,6 +16,7 @@ afterEach(() => server.resetHandlers()); describe("useWatchlist", () => { beforeEach(() => { window.localStorage.clear(); + useWatchlistStore.setState(useWatchlistStore.getInitialState(), true); }); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -70,11 +72,13 @@ describe("useWatchlist", () => { result.current.addAsset("BTC"); }); - const storedRaw = window.localStorage.getItem("swipely.watchlists.v1"); + const storedRaw = window.localStorage.getItem(WATCHLIST_STORAGE_KEY); expect(storedRaw).toBeTruthy(); - + const stored = JSON.parse(storedRaw!); - expect(stored.lists[0].assets).toContain("BTC"); + // Zustand persist wraps partialized state under `state`. + const lists = stored.state?.lists ?? stored.lists; + expect(lists[0].assets).toContain("BTC"); }); it("should reorder assets", () => { diff --git a/src/hooks/useWatchlist.ts b/src/hooks/useWatchlist.ts index 73374cb..0e24759 100644 --- a/src/hooks/useWatchlist.ts +++ b/src/hooks/useWatchlist.ts @@ -1,347 +1,42 @@ +import { Fragment, createElement, type ReactNode } from "react"; import { - createElement, - createContext, - useCallback, - useContext, - useMemo, - useState, - type ReactNode, -} from "react"; - -export interface Watchlist { - id: string; - name: string; - assets: string[]; -} - -interface WatchlistStore { - activeListId: string; - lists: Watchlist[]; -} - -const STORAGE_KEY = "swipely.watchlists.v1"; - -function slugify(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); -} - -function createDefaultStore(): WatchlistStore { - return { - activeListId: "default", - lists: [{ id: "default", name: "Default", assets: [] }], - }; -} - -function readStore(): WatchlistStore { - if (typeof window === "undefined") { - return createDefaultStore(); - } - - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) { - return createDefaultStore(); - } - - try { - const parsed = JSON.parse(raw) as WatchlistStore; - if (!parsed.lists?.length) { - return createDefaultStore(); - } - - const activeExists = parsed.lists.some((list) => list.id === parsed.activeListId); - - return { - activeListId: activeExists ? parsed.activeListId : parsed.lists[0].id, - lists: parsed.lists, - }; - } catch { - return createDefaultStore(); - } -} - -function persistStore(store: WatchlistStore) { - if (typeof window === "undefined") { - return; - } - - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); -} - -interface WatchlistContextValue { - watchlists: Watchlist[]; - activeWatchlist: Watchlist | undefined; - activeListId: string; - activeSymbols: string[]; - addAsset: (symbol: string, listId?: string) => void; - removeAsset: (symbol: string, listId?: string) => void; - reorderAsset: (symbol: string, direction: "up" | "down", listId?: string) => void; - updateAssetOrder: (watchlistId: string, assets: string[]) => void; - createWatchlist: (name: string) => void; - deleteWatchlist: (listId: string) => void; - renameWatchlist: (listId: string, name: string) => void; - setActiveWatchlist: (listId: string) => void; - clearActiveWatchlist: () => void; - exportWatchlists: () => string; - importWatchlists: (payload: string) => boolean; - isInWatchlist: (symbol: string, listId?: string) => boolean; -} - -const WatchlistContext = createContext( - undefined -); - -function useWatchlistState(): WatchlistContextValue { - const [store, setStore] = useState(readStore); - - const updateStore = useCallback((updater: (previous: WatchlistStore) => WatchlistStore) => { - setStore((previous) => { - const next = updater(previous); - persistStore(next); - return next; - }); - }, []); - - const activeWatchlist = useMemo( - () => - store.lists.find((list) => list.id === store.activeListId) ?? - store.lists[0], - [store.activeListId, store.lists] - ); - - const addAsset = useCallback( - (symbol: string, listId?: string) => { - const normalized = symbol.trim().toUpperCase(); - if (!normalized) { - return; - } - - updateStore((previous) => { - const targetId = listId ?? previous.activeListId; - - return { - ...previous, - lists: previous.lists.map((list) => { - if (list.id !== targetId || list.assets.includes(normalized)) { - return list; - } - - return { - ...list, - assets: [...list.assets, normalized], - }; - }), - }; - }); - }, - [updateStore] - ); - - const removeAsset = useCallback( - (symbol: string, listId?: string) => { - const normalized = symbol.trim().toUpperCase(); - updateStore((previous) => { - const targetId = listId ?? previous.activeListId; - - return { - ...previous, - lists: previous.lists.map((list) => { - if (list.id !== targetId) { - return list; - } - - return { - ...list, - assets: list.assets.filter((asset) => asset !== normalized), - }; - }), - }; - }); - }, - [updateStore] - ); - - const reorderAsset = useCallback( - (symbol: string, direction: "up" | "down", listId?: string) => { - const normalized = symbol.trim().toUpperCase(); - - updateStore((previous) => { - const targetId = listId ?? previous.activeListId; - - return { - ...previous, - lists: previous.lists.map((list) => { - if (list.id !== targetId) { - return list; - } - - const index = list.assets.indexOf(normalized); - if (index === -1) { - return list; - } - - const nextIndex = direction === "up" ? index - 1 : index + 1; - if (nextIndex < 0 || nextIndex >= list.assets.length) { - return list; - } - - const assets = [...list.assets]; - [assets[index], assets[nextIndex]] = [assets[nextIndex], assets[index]]; - - return { - ...list, - assets, - }; - }), - }; - }); - }, - [updateStore] - ); - - const updateAssetOrder = useCallback( - (watchlistId: string, assets: string[]) => { - updateStore((previous) => ({ - ...previous, - lists: previous.lists.map((list) => - list.id === watchlistId ? { ...list, assets } : list - ), - })); - }, - [updateStore] - ); - - const createWatchlist = useCallback( - (name: string) => { - const normalizedName = name.trim(); - if (!normalizedName) { - return; - } - - updateStore((previous) => { - const base = slugify(normalizedName) || `watchlist-${previous.lists.length + 1}`; - let id = base; - let suffix = 1; - - while (previous.lists.some((list) => list.id === id)) { - id = `${base}-${suffix}`; - suffix += 1; - } - - return { - activeListId: id, - lists: [...previous.lists, { id, name: normalizedName, assets: [] }], - }; - }); - }, - [updateStore] - ); - - const deleteWatchlist = useCallback( - (listId: string) => { - updateStore((previous) => { - if (previous.lists.length <= 1) { - return previous; - } - - const lists = previous.lists.filter((list) => list.id !== listId); - if (!lists.length) { - return previous; - } - - return { - activeListId: - previous.activeListId === listId ? lists[0].id : previous.activeListId, - lists, - }; - }); - }, - [updateStore] - ); - - const renameWatchlist = useCallback( - (listId: string, name: string) => { - const normalizedName = name.trim(); - if (!normalizedName) { - return; - } - - updateStore((previous) => ({ - ...previous, - lists: previous.lists.map((list) => - list.id === listId ? { ...list, name: normalizedName } : list - ), - })); - }, - [updateStore] - ); - - const setActiveWatchlist = useCallback( - (listId: string) => { - updateStore((previous) => { - if (!previous.lists.some((list) => list.id === listId)) { - return previous; - } - - return { - ...previous, - activeListId: listId, - }; - }); - }, - [updateStore] - ); - - const clearActiveWatchlist = useCallback(() => { - updateStore((previous) => ({ - ...previous, - lists: previous.lists.map((list) => - list.id === previous.activeListId ? { ...list, assets: [] } : list - ), - })); - }, [updateStore]); - - const exportWatchlists = useCallback(() => JSON.stringify(store, null, 2), [store]); - - const importWatchlists = useCallback( - (payload: string) => { - try { - const parsed = JSON.parse(payload) as WatchlistStore; - if (!parsed.lists?.length) { - return false; - } - - updateStore(() => ({ - activeListId: parsed.activeListId, - lists: parsed.lists, - })); - - return true; - } catch { - return false; - } - }, - [updateStore] - ); - - const isInWatchlist = useCallback( - (symbol: string, listId?: string) => { - const normalized = symbol.trim().toUpperCase(); - const targetId = listId ?? store.activeListId; - const list = store.lists.find((entry) => entry.id === targetId); - return list?.assets.includes(normalized) ?? false; - }, - [store.activeListId, store.lists] - ); + useWatchlistStore, + selectActiveWatchlist, + selectActiveSymbols, + type Watchlist, +} from "../stores/watchlistStore"; + +export type { Watchlist }; + +/** + * Watchlist API backed by the persisted Zustand store under `src/stores/`. + * WatchlistProvider is retained for call-site compatibility; the store is the + * single source of truth and rehydrates from namespaced localStorage. + */ +export function useWatchlist() { + const lists = useWatchlistStore((state) => state.lists); + const activeListId = useWatchlistStore((state) => state.activeListId); + const activeWatchlist = useWatchlistStore(selectActiveWatchlist); + const activeSymbols = useWatchlistStore(selectActiveSymbols); + + const addAsset = useWatchlistStore((state) => state.addAsset); + const removeAsset = useWatchlistStore((state) => state.removeAsset); + const reorderAsset = useWatchlistStore((state) => state.reorderAsset); + const updateAssetOrder = useWatchlistStore((state) => state.updateAssetOrder); + const createWatchlist = useWatchlistStore((state) => state.createWatchlist); + const deleteWatchlist = useWatchlistStore((state) => state.deleteWatchlist); + const renameWatchlist = useWatchlistStore((state) => state.renameWatchlist); + const setActiveWatchlist = useWatchlistStore((state) => state.setActiveWatchlist); + const clearActiveWatchlist = useWatchlistStore((state) => state.clearActiveWatchlist); + const exportWatchlists = useWatchlistStore((state) => state.exportWatchlists); + const importWatchlists = useWatchlistStore((state) => state.importWatchlists); + const isInWatchlist = useWatchlistStore((state) => state.isInWatchlist); return { - watchlists: store.lists, + watchlists: lists, activeWatchlist, - activeListId: store.activeListId, - activeSymbols: activeWatchlist?.assets ?? [], + activeListId, + activeSymbols, addAsset, removeAsset, reorderAsset, @@ -357,16 +52,7 @@ function useWatchlistState(): WatchlistContextValue { }; } +/** Pass-through provider kept for existing app tree and tests. */ export function WatchlistProvider({ children }: { children: ReactNode }) { - const value = useWatchlistState(); - return createElement(WatchlistContext.Provider, { value }, children); -} - -export function useWatchlist() { - const context = useContext(WatchlistContext); - if (!context) { - throw new Error("useWatchlist must be used inside WatchlistProvider"); - } - - return context; + return createElement(Fragment, null, children); } diff --git a/src/pages/Bridges.tsx b/src/pages/Bridges.tsx index a41dcf0..b77d71e 100644 --- a/src/pages/Bridges.tsx +++ b/src/pages/Bridges.tsx @@ -5,11 +5,16 @@ import { useFavorites } from "../hooks/useFavorites"; import { useRefreshControls } from "../hooks/useRefreshControls"; import { usePullToRefresh } from "../hooks/usePullToRefresh"; import BridgeStatusCard from "../components/BridgeStatusCard"; +import BridgeFilterSort from "../components/BridgeFilterSort"; import BridgeNotesPanel from "../components/BridgeNotesPanel"; import FavoriteTagChip from "../components/favorites/FavoriteTagChip"; import RefreshControls from "../components/RefreshControls"; import PullToRefresh from "../components/PullToRefresh"; import { SkeletonCard, ErrorBoundary } from "../components/Skeleton"; +import { + applyBridgeFilterSort, + useBridgeFilterSortStore, +} from "../stores/bridgeFilterSortStore"; export default function Bridges() { const [searchParams] = useSearchParams(); @@ -22,6 +27,9 @@ export default function Bridges() { favoriteBridges, } = useFavorites(); + const statusFilter = useBridgeFilterSortStore((s) => s.statusFilter); + const sortBy = useBridgeFilterSortStore((s) => s.sortBy); + const refreshControls = useRefreshControls({ viewId: "bridges", targets: [{ id: "bridges", label: "Bridge status", queryKey: ["bridges"] }], @@ -41,9 +49,12 @@ export default function Bridges() { const filteredBridges = useMemo(() => { const bridges = data?.bridges ?? []; - if (favoritesFilterMode !== "favorites") return bridges; - return bridges.filter((b) => favoriteBridges.includes(b.name)); - }, [data?.bridges, favoritesFilterMode, favoriteBridges]); + const favoriteScoped = + favoritesFilterMode === "favorites" + ? bridges.filter((b) => favoriteBridges.includes(b.name)) + : bridges; + return applyBridgeFilterSort(favoriteScoped, statusFilter, sortBy); + }, [data?.bridges, favoritesFilterMode, favoriteBridges, statusFilter, sortBy]); return (
@@ -77,42 +88,45 @@ export default function Bridges() { lastUpdatedAt={refreshControls.lastUpdatedAt} /> -
-
- +
+ +
+
+ + +
-
window.location.reload()}> diff --git a/src/stores/bridgeFilterSortStore.test.ts b/src/stores/bridgeFilterSortStore.test.ts new file mode 100644 index 0000000..e3103bc --- /dev/null +++ b/src/stores/bridgeFilterSortStore.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + useBridgeFilterSortStore, + normalizeBridgeFilterSortState, + applyBridgeFilterSort, + BRIDGE_FILTER_SORT_STORAGE_KEY, +} from "./bridgeFilterSortStore"; + +function resetStore() { + useBridgeFilterSortStore.setState( + useBridgeFilterSortStore.getInitialState(), + true + ); +} + +const sampleBridges = [ + { + name: "Zebra", + status: "healthy" as const, + totalValueLocked: 100, + mismatchPercentage: 0, + volume24h: 10, + }, + { + name: "Alpha", + status: "degraded" as const, + totalValueLocked: 500, + mismatchPercentage: 0.2, + volume24h: 50, + }, + { + name: "Mid", + status: "down" as const, + totalValueLocked: 250, + mismatchPercentage: 2, + volume24h: 5, + }, +]; + +describe("bridgeFilterSortStore", () => { + beforeEach(() => { + localStorage.clear(); + resetStore(); + }); + + it("starts with default filter and sort", () => { + const state = useBridgeFilterSortStore.getState(); + expect(state.statusFilter).toBe("all"); + expect(state.sortBy).toBe("name"); + }); + + it("persists status filter and sort selections", () => { + useBridgeFilterSortStore.getState().setStatusFilter("healthy"); + useBridgeFilterSortStore.getState().setSortBy("tvl"); + + const raw = localStorage.getItem(BRIDGE_FILTER_SORT_STORAGE_KEY); + expect(raw).toBeTruthy(); + expect(raw).toContain("healthy"); + expect(raw).toContain("tvl"); + }); + + it("rehydrates filter/sort on return", async () => { + useBridgeFilterSortStore.getState().setStatusFilter("degraded"); + useBridgeFilterSortStore.getState().setSortBy("health"); + + // Capture persisted data before simulating a restart. + const saved = localStorage.getItem(BRIDGE_FILTER_SORT_STORAGE_KEY); + + // Reset in-memory state, then restore the saved blob so rehydrate() can + // read it back (resetStore triggers persist middleware which would + // otherwise overwrite localStorage with the defaults). + resetStore(); + if (saved) localStorage.setItem(BRIDGE_FILTER_SORT_STORAGE_KEY, saved); + + expect(useBridgeFilterSortStore.getState().statusFilter).toBe("all"); + + await useBridgeFilterSortStore.persist.rehydrate(); + expect(useBridgeFilterSortStore.getState().statusFilter).toBe("degraded"); + expect(useBridgeFilterSortStore.getState().sortBy).toBe("health"); + }); + + it("ignores invalid status/sort values", () => { + useBridgeFilterSortStore.getState().setStatusFilter("not-a-status"); + useBridgeFilterSortStore.getState().setSortBy("not-a-sort"); + expect(useBridgeFilterSortStore.getState().statusFilter).toBe("all"); + expect(useBridgeFilterSortStore.getState().sortBy).toBe("name"); + }); + + it("handles corrupt persisted data without crashing", async () => { + localStorage.setItem(BRIDGE_FILTER_SORT_STORAGE_KEY, "{broken"); + await expect(useBridgeFilterSortStore.persist.rehydrate()).resolves.not.toThrow(); + + localStorage.setItem( + BRIDGE_FILTER_SORT_STORAGE_KEY, + JSON.stringify({ + state: { statusFilter: "bogus", sortBy: 99 }, + version: 1, + }) + ); + await useBridgeFilterSortStore.persist.rehydrate(); + expect(useBridgeFilterSortStore.getState().statusFilter).toBe("all"); + expect(useBridgeFilterSortStore.getState().sortBy).toBe("name"); + }); + + it("normalizeBridgeFilterSortState falls back on unknown shapes", () => { + expect(normalizeBridgeFilterSortState(null)).toEqual({ + statusFilter: "all", + sortBy: "name", + }); + expect( + normalizeBridgeFilterSortState({ statusFilter: "down", sortBy: "volume" }) + ).toEqual({ statusFilter: "down", sortBy: "volume" }); + }); + + it("applyBridgeFilterSort filters and sorts bridges", () => { + const healthy = applyBridgeFilterSort(sampleBridges, "healthy", "name"); + expect(healthy.map((b) => b.name)).toEqual(["Zebra"]); + + const byTvl = applyBridgeFilterSort(sampleBridges, "all", "tvl"); + expect(byTvl.map((b) => b.name)).toEqual(["Alpha", "Mid", "Zebra"]); + + const byName = applyBridgeFilterSort(sampleBridges, "all", "name"); + expect(byName.map((b) => b.name)).toEqual(["Alpha", "Mid", "Zebra"]); + }); +}); diff --git a/src/stores/bridgeFilterSortStore.ts b/src/stores/bridgeFilterSortStore.ts new file mode 100644 index 0000000..a844465 --- /dev/null +++ b/src/stores/bridgeFilterSortStore.ts @@ -0,0 +1,168 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type BridgeStatusFilter = "all" | "healthy" | "degraded" | "down" | "unknown"; +export type BridgeSortBy = "name" | "tvl" | "volume" | "health"; + +export interface BridgeFilterSortState { + statusFilter: BridgeStatusFilter; + sortBy: BridgeSortBy; +} + +interface BridgeFilterSortActions { + setStatusFilter: (status: BridgeStatusFilter | string) => void; + setSortBy: (sortBy: BridgeSortBy | string) => void; + reset: () => void; +} + +export type BridgeFilterSortStore = BridgeFilterSortState & BridgeFilterSortActions; + +export const BRIDGE_FILTER_SORT_STORAGE_KEY = "swipely:bridge-filter-sort"; + +const STATUS_FILTERS: readonly BridgeStatusFilter[] = [ + "all", + "healthy", + "degraded", + "down", + "unknown", +] as const; + +const SORT_OPTIONS: readonly BridgeSortBy[] = [ + "name", + "tvl", + "volume", + "health", +] as const; + +const defaultState: BridgeFilterSortState = { + statusFilter: "all", + sortBy: "name", +}; + +function isStatusFilter(value: unknown): value is BridgeStatusFilter { + return typeof value === "string" && (STATUS_FILTERS as readonly string[]).includes(value); +} + +function isSortBy(value: unknown): value is BridgeSortBy { + return typeof value === "string" && (SORT_OPTIONS as readonly string[]).includes(value); +} + +/** + * Validate and normalize unknown persisted shapes. + * Returns defaults for any corrupt or outdated fields. + */ +export function normalizeBridgeFilterSortState( + raw: unknown +): BridgeFilterSortState { + if (!raw || typeof raw !== "object") { + return { ...defaultState }; + } + + const candidate = raw as Record; + return { + statusFilter: isStatusFilter(candidate.statusFilter) + ? candidate.statusFilter + : defaultState.statusFilter, + sortBy: isSortBy(candidate.sortBy) ? candidate.sortBy : defaultState.sortBy, + }; +} + +export const useBridgeFilterSortStore = create()( + persist( + (set) => ({ + ...defaultState, + + setStatusFilter: (status) => { + if (!isStatusFilter(status)) return; + set({ statusFilter: status }); + }, + + setSortBy: (sortBy) => { + if (!isSortBy(sortBy)) return; + set({ sortBy }); + }, + + reset: () => { + set({ ...defaultState }); + if (typeof window !== "undefined") { + try { + window.localStorage.removeItem(BRIDGE_FILTER_SORT_STORAGE_KEY); + } catch { + // ignore storage failures + } + } + }, + }), + { + name: BRIDGE_FILTER_SORT_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + version: 1, + partialize: (state) => ({ + statusFilter: state.statusFilter, + sortBy: state.sortBy, + }), + migrate: (persisted: unknown) => + normalizeBridgeFilterSortState(persisted), + merge: (persisted, current) => ({ + ...current, + ...normalizeBridgeFilterSortState(persisted), + }), + } + ) +); + +export const selectStatusFilter = (state: BridgeFilterSortStore) => state.statusFilter; +export const selectSortBy = (state: BridgeFilterSortStore) => state.sortBy; + +/** Simple health score used for client-side bridge sorting (mirrors BridgeCard). */ +export function getBridgeHealthScore(bridge: { + status: string; + mismatchPercentage: number; +}): number { + let score = 100; + if (bridge.status === "down") score -= 50; + else if (bridge.status === "degraded") score -= 25; + else if (bridge.status === "unknown") score -= 15; + if (bridge.mismatchPercentage > 1) score -= 30; + else if (bridge.mismatchPercentage > 0.5) score -= 15; + return Math.max(0, score); +} + +/** + * Apply persisted status filter + sort to a bridge list. + * Unknown volume fields fall back to 0 so sorting remains stable. + */ +export function applyBridgeFilterSort< + T extends { + name: string; + status: string; + totalValueLocked: number; + mismatchPercentage: number; + volume24h?: number; + }, +>( + bridges: T[], + statusFilter: BridgeStatusFilter, + sortBy: BridgeSortBy +): T[] { + const result = + statusFilter === "all" + ? [...bridges] + : bridges.filter((bridge) => bridge.status === statusFilter); + + result.sort((a, b) => { + switch (sortBy) { + case "tvl": + return b.totalValueLocked - a.totalValueLocked; + case "volume": + return (b.volume24h ?? 0) - (a.volume24h ?? 0); + case "health": + return getBridgeHealthScore(b) - getBridgeHealthScore(a); + case "name": + default: + return a.name.localeCompare(b.name); + } + }); + + return result; +} diff --git a/src/stores/index.ts b/src/stores/index.ts index c8adf27..22b5c38 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -86,6 +86,36 @@ export { type CacheActions, } from "./cacheStore"; +// Watchlist Store (persisted) +export { + useWatchlistStore, + selectWatchlists, + selectActiveListId, + selectActiveWatchlist, + selectActiveSymbols, + normalizeWatchlistState, + WATCHLIST_STORAGE_KEY, + LEGACY_WATCHLIST_STORAGE_KEY, + type Watchlist, + type WatchlistState, + type WatchlistStore, +} from "./watchlistStore"; + +// Bridge filter/sort preferences (persisted) +export { + useBridgeFilterSortStore, + selectStatusFilter, + selectSortBy, + applyBridgeFilterSort, + getBridgeHealthScore, + normalizeBridgeFilterSortState, + BRIDGE_FILTER_SORT_STORAGE_KEY, + type BridgeStatusFilter, + type BridgeSortBy, + type BridgeFilterSortState, + type BridgeFilterSortStore, +} from "./bridgeFilterSortStore"; + // Middleware export { logger, diff --git a/src/stores/watchlistStore.test.ts b/src/stores/watchlistStore.test.ts new file mode 100644 index 0000000..8414654 --- /dev/null +++ b/src/stores/watchlistStore.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + useWatchlistStore, + normalizeWatchlistState, + WATCHLIST_STORAGE_KEY, + LEGACY_WATCHLIST_STORAGE_KEY, +} from "./watchlistStore"; + +function resetStore() { + useWatchlistStore.setState(useWatchlistStore.getInitialState(), true); +} + +describe("watchlistStore", () => { + beforeEach(() => { + localStorage.clear(); + resetStore(); + }); + + it("initializes with a default empty watchlist", () => { + const state = useWatchlistStore.getState(); + expect(state.lists).toHaveLength(1); + expect(state.lists[0].id).toBe("default"); + expect(state.activeListId).toBe("default"); + expect(state.lists[0].assets).toEqual([]); + }); + + it("adds and removes assets and persists them", () => { + const store = useWatchlistStore.getState(); + store.addAsset("usdc"); + store.addAsset("EURC"); + + expect(useWatchlistStore.getState().lists[0].assets).toEqual(["USDC", "EURC"]); + + const raw = localStorage.getItem(WATCHLIST_STORAGE_KEY); + expect(raw).toBeTruthy(); + expect(raw).toContain("USDC"); + expect(raw).toContain("EURC"); + + useWatchlistStore.getState().removeAsset("USDC"); + expect(useWatchlistStore.getState().lists[0].assets).toEqual(["EURC"]); + }); + + it("ignores empty and duplicate assets", () => { + const store = useWatchlistStore.getState(); + store.addAsset("USDC"); + store.addAsset("USDC"); + store.addAsset(" "); + expect(useWatchlistStore.getState().lists[0].assets).toEqual(["USDC"]); + }); + + it("rehydrates persisted watchlist across restarts", async () => { + useWatchlistStore.getState().addAsset("BTC"); + + // Capture what was persisted before simulating a restart. + const saved = localStorage.getItem(WATCHLIST_STORAGE_KEY); + + // Simulate a fresh store instance: reset in-memory state, then restore + // the persisted data so rehydrate() can read it back (resetStore triggers + // the persist middleware which would otherwise overwrite localStorage). + resetStore(); + if (saved) localStorage.setItem(WATCHLIST_STORAGE_KEY, saved); + + expect(useWatchlistStore.getState().lists[0].assets).toEqual([]); + + await useWatchlistStore.persist.rehydrate(); + expect(useWatchlistStore.getState().lists[0].assets).toContain("BTC"); + }); + + it("migrates legacy localStorage key on merge/rehydrate", async () => { + localStorage.setItem( + LEGACY_WATCHLIST_STORAGE_KEY, + JSON.stringify({ + activeListId: "default", + lists: [{ id: "default", name: "Default", assets: ["XLM"] }], + }) + ); + + await useWatchlistStore.persist.rehydrate(); + expect(useWatchlistStore.getState().lists[0].assets).toContain("XLM"); + }); + + it("handles corrupt persisted data without crashing", async () => { + localStorage.setItem(WATCHLIST_STORAGE_KEY, "not-json{{{"); + await expect(useWatchlistStore.persist.rehydrate()).resolves.not.toThrow(); + + localStorage.setItem( + WATCHLIST_STORAGE_KEY, + JSON.stringify({ state: { lists: "nope", activeListId: 123 }, version: 1 }) + ); + await expect(useWatchlistStore.persist.rehydrate()).resolves.not.toThrow(); + expect(useWatchlistStore.getState().lists[0].id).toBe("default"); + }); + + it("clearActiveWatchlist empties assets and clears persisted symbols", () => { + useWatchlistStore.getState().addAsset("USDC"); + expect(localStorage.getItem(WATCHLIST_STORAGE_KEY)).toContain("USDC"); + + useWatchlistStore.getState().clearActiveWatchlist(); + expect(useWatchlistStore.getState().lists[0].assets).toEqual([]); + + const raw = localStorage.getItem(WATCHLIST_STORAGE_KEY); + // Either removed entirely or rewritten without prior symbols. + if (raw) { + expect(raw).not.toContain("USDC"); + } + }); + + it("normalizeWatchlistState rejects invalid shapes", () => { + expect(normalizeWatchlistState(null)).toBeNull(); + expect(normalizeWatchlistState({ lists: [] })).toBeNull(); + expect(normalizeWatchlistState({ lists: [{ id: 1 }] })).toBeNull(); + expect( + normalizeWatchlistState({ + activeListId: "a", + lists: [{ id: "a", name: "A", assets: ["xlm"] }], + }) + ).toEqual({ + activeListId: "a", + lists: [{ id: "a", name: "A", assets: ["XLM"] }], + }); + }); +}); diff --git a/src/stores/watchlistStore.ts b/src/stores/watchlistStore.ts new file mode 100644 index 0000000..c3cadd8 --- /dev/null +++ b/src/stores/watchlistStore.ts @@ -0,0 +1,351 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export interface Watchlist { + id: string; + name: string; + assets: string[]; +} + +export interface WatchlistState { + activeListId: string; + lists: Watchlist[]; +} + +interface WatchlistActions { + addAsset: (symbol: string, listId?: string) => void; + removeAsset: (symbol: string, listId?: string) => void; + reorderAsset: (symbol: string, direction: "up" | "down", listId?: string) => void; + updateAssetOrder: (watchlistId: string, assets: string[]) => void; + createWatchlist: (name: string) => void; + deleteWatchlist: (listId: string) => void; + renameWatchlist: (listId: string, name: string) => void; + setActiveWatchlist: (listId: string) => void; + /** Clears assets on the active list and removes persisted storage when empty. */ + clearActiveWatchlist: () => void; + importWatchlists: (payload: string) => boolean; + isInWatchlist: (symbol: string, listId?: string) => boolean; + getActiveWatchlist: () => Watchlist | undefined; + exportWatchlists: () => string; +} + +export type WatchlistStore = WatchlistState & WatchlistActions; + +export const WATCHLIST_STORAGE_KEY = "swipely:watchlist"; +/** Legacy key used by the previous React-context implementation. */ +export const LEGACY_WATCHLIST_STORAGE_KEY = "swipely.watchlists.v1"; + +const defaultState: WatchlistState = { + activeListId: "default", + lists: [{ id: "default", name: "Default", assets: [] }], +}; + +function cloneDefaultState(): WatchlistState { + return { + activeListId: defaultState.activeListId, + lists: defaultState.lists.map((list) => ({ ...list, assets: [...list.assets] })), + }; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function isWatchlist(value: unknown): value is Watchlist { + if (!value || typeof value !== "object") return false; + const list = value as Record; + return ( + typeof list.id === "string" && + list.id.length > 0 && + typeof list.name === "string" && + Array.isArray(list.assets) && + list.assets.every((asset) => typeof asset === "string") + ); +} + +/** + * Validate and normalize unknown persisted shapes. + * Returns null when data is corrupt/unusable so callers can fall back to defaults. + */ +export function normalizeWatchlistState(raw: unknown): WatchlistState | null { + if (!raw || typeof raw !== "object") return null; + + const candidate = raw as Record; + const listsRaw = candidate.lists; + if (!Array.isArray(listsRaw) || listsRaw.length === 0) return null; + + const lists: Watchlist[] = []; + for (const entry of listsRaw) { + if (!isWatchlist(entry)) continue; + lists.push({ + id: entry.id, + name: entry.name, + assets: entry.assets + .map((a) => a.trim().toUpperCase()) + .filter((a) => a.length > 0) + .filter((a, i, arr) => arr.indexOf(a) === i), + }); + } + + if (lists.length === 0) return null; + + const activeListId = + typeof candidate.activeListId === "string" && + lists.some((list) => list.id === candidate.activeListId) + ? candidate.activeListId + : lists[0].id; + + return { activeListId, lists }; +} + +function readLegacyWatchlistState(): WatchlistState | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(LEGACY_WATCHLIST_STORAGE_KEY); + if (!raw) return null; + return normalizeWatchlistState(JSON.parse(raw)); + } catch { + return null; + } +} + +function removeLegacyKey(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(LEGACY_WATCHLIST_STORAGE_KEY); + } catch { + // ignore storage failures + } +} + +function allAssetsEmpty(lists: Watchlist[]): boolean { + return lists.every((list) => list.assets.length === 0); +} + +export const useWatchlistStore = create()( + persist( + (set, get) => ({ + ...cloneDefaultState(), + + addAsset: (symbol, listId) => { + const normalized = symbol.trim().toUpperCase(); + if (!normalized) return; + + set((state) => { + const targetId = listId ?? state.activeListId; + return { + lists: state.lists.map((list) => { + if (list.id !== targetId || list.assets.includes(normalized)) { + return list; + } + return { ...list, assets: [...list.assets, normalized] }; + }), + }; + }); + }, + + removeAsset: (symbol, listId) => { + const normalized = symbol.trim().toUpperCase(); + set((state) => { + const targetId = listId ?? state.activeListId; + return { + lists: state.lists.map((list) => + list.id === targetId + ? { ...list, assets: list.assets.filter((a) => a !== normalized) } + : list + ), + }; + }); + }, + + reorderAsset: (symbol, direction, listId) => { + const normalized = symbol.trim().toUpperCase(); + set((state) => { + const targetId = listId ?? state.activeListId; + return { + lists: state.lists.map((list) => { + if (list.id !== targetId) return list; + const index = list.assets.indexOf(normalized); + if (index === -1) return list; + const nextIndex = direction === "up" ? index - 1 : index + 1; + if (nextIndex < 0 || nextIndex >= list.assets.length) return list; + const assets = [...list.assets]; + [assets[index], assets[nextIndex]] = [assets[nextIndex], assets[index]]; + return { ...list, assets }; + }), + }; + }); + }, + + updateAssetOrder: (watchlistId, assets) => { + set((state) => ({ + lists: state.lists.map((list) => + list.id === watchlistId ? { ...list, assets } : list + ), + })); + }, + + createWatchlist: (name) => { + const normalizedName = name.trim(); + if (!normalizedName) return; + + set((state) => { + const base = slugify(normalizedName) || `watchlist-${state.lists.length + 1}`; + let id = base; + let suffix = 1; + while (state.lists.some((list) => list.id === id)) { + id = `${base}-${suffix}`; + suffix += 1; + } + return { + activeListId: id, + lists: [...state.lists, { id, name: normalizedName, assets: [] }], + }; + }); + }, + + deleteWatchlist: (listId) => { + set((state) => { + if (state.lists.length <= 1) return state; + const lists = state.lists.filter((list) => list.id !== listId); + if (!lists.length) return state; + return { + activeListId: + state.activeListId === listId ? lists[0].id : state.activeListId, + lists, + }; + }); + }, + + renameWatchlist: (listId, name) => { + const normalizedName = name.trim(); + if (!normalizedName) return; + set((state) => ({ + lists: state.lists.map((list) => + list.id === listId ? { ...list, name: normalizedName } : list + ), + })); + }, + + setActiveWatchlist: (listId) => { + set((state) => { + if (!state.lists.some((list) => list.id === listId)) return state; + return { activeListId: listId }; + }); + }, + + clearActiveWatchlist: () => { + set((state) => ({ + lists: state.lists.map((list) => + list.id === state.activeListId ? { ...list, assets: [] } : list + ), + })); + + // Drop storage keys when every list is empty so cleared data does not linger. + const { lists } = get(); + if (allAssetsEmpty(lists) && typeof window !== "undefined") { + try { + window.localStorage.removeItem(WATCHLIST_STORAGE_KEY); + window.localStorage.removeItem(LEGACY_WATCHLIST_STORAGE_KEY); + } catch { + // ignore storage failures + } + } + }, + + importWatchlists: (payload) => { + try { + const parsed = JSON.parse(payload) as unknown; + const normalized = normalizeWatchlistState(parsed); + if (!normalized) return false; + set(normalized); + return true; + } catch { + return false; + } + }, + + isInWatchlist: (symbol, listId) => { + const normalized = symbol.trim().toUpperCase(); + const state = get(); + const targetId = listId ?? state.activeListId; + const list = state.lists.find((entry) => entry.id === targetId); + return list?.assets.includes(normalized) ?? false; + }, + + getActiveWatchlist: () => { + const state = get(); + return ( + state.lists.find((list) => list.id === state.activeListId) ?? state.lists[0] + ); + }, + + exportWatchlists: () => { + const { activeListId, lists } = get(); + return JSON.stringify({ activeListId, lists }, null, 2); + }, + }), + { + name: WATCHLIST_STORAGE_KEY, + storage: createJSONStorage(() => localStorage), + version: 1, + partialize: (state) => ({ + activeListId: state.activeListId, + lists: state.lists, + }), + migrate: (persisted: unknown) => { + const normalized = normalizeWatchlistState(persisted); + return normalized ?? cloneDefaultState(); + }, + merge: (persisted, current) => { + // Zustand persist may pass the full storage blob or just the state slice. + const candidate = + persisted && + typeof persisted === "object" && + "state" in (persisted as object) + ? (persisted as { state: unknown }).state + : persisted; + + const normalized = normalizeWatchlistState(candidate); + if (normalized) { + return { ...current, ...normalized }; + } + + // Fall back to legacy key when namespaced key is missing/corrupt. + const legacy = readLegacyWatchlistState(); + if (legacy) { + removeLegacyKey(); + return { ...current, ...legacy }; + } + + return current; + }, + onRehydrateStorage: () => (state) => { + // If namespaced storage was empty, try migrating the legacy key once. + if (!state) return; + if ( + state.lists.length === 1 && + state.lists[0].id === "default" && + state.lists[0].assets.length === 0 + ) { + const legacy = readLegacyWatchlistState(); + if (legacy) { + useWatchlistStore.setState(legacy); + removeLegacyKey(); + } + } + }, + } + ) +); + +export const selectWatchlists = (state: WatchlistStore) => state.lists; +export const selectActiveListId = (state: WatchlistStore) => state.activeListId; +export const selectActiveWatchlist = (state: WatchlistStore) => + state.lists.find((list) => list.id === state.activeListId) ?? state.lists[0]; +export const selectActiveSymbols = (state: WatchlistStore) => + selectActiveWatchlist(state)?.assets ?? [];