From a1dfa5f628741fc1bf1d846aeb5315c11be08f11 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 17 Apr 2026 00:17:55 +0300 Subject: [PATCH] Add useAppSettings hook for parsing app config day ranges --- src/shared/hooks/useAppSettings.test.ts | 85 +++++++++++++++++++++++++ src/shared/hooks/useAppSettings.ts | 68 ++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/shared/hooks/useAppSettings.test.ts create mode 100644 src/shared/hooks/useAppSettings.ts diff --git a/src/shared/hooks/useAppSettings.test.ts b/src/shared/hooks/useAppSettings.test.ts new file mode 100644 index 00000000..96cef73a --- /dev/null +++ b/src/shared/hooks/useAppSettings.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useAppSettings } from "./useAppSettings.js"; +import type { AppSettingsResponse } from "@/shared/api/appSettings.js"; + +const mockGetAppSettings = vi.fn(); + +vi.mock("@/shared/api/appSettings.js", () => ({ + getAppSettings: (...args: unknown[]) => mockGetAppSettings(...args), +})); + +vi.mock("@/shared/api/provider.js", () => ({ + useApiClient: () => ({ get: vi.fn() }), +})); + +describe("useAppSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("parses searchFrom/searchTo strings into numbers", async () => { + const response: AppSettingsResponse = { + uiOptions: { + filter: { + onlineboard: { searchFrom: "2d", searchTo: "14d" }, + schedule: { searchFrom: "30d", searchTo: "45d" }, + }, + }, + }; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.onlineboardSearchFrom).toBe(2); + expect(result.current.onlineboardSearchTo).toBe(14); + expect(result.current.scheduleSearchFrom).toBe(30); + expect(result.current.scheduleSearchTo).toBe(45); + expect(result.current.error).toBeNull(); + }); + + it("returns defaults when uiOptions.filter is missing", async () => { + const response: AppSettingsResponse = {}; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.onlineboardSearchFrom).toBe(2); + expect(result.current.onlineboardSearchTo).toBe(14); + expect(result.current.scheduleSearchFrom).toBe(30); + expect(result.current.scheduleSearchTo).toBe(30); + }); + + it("returns defaults when a specific value does not match /\\d+d/", async () => { + const response: AppSettingsResponse = { + uiOptions: { + filter: { + onlineboard: { searchFrom: "blah", searchTo: "14d" }, + schedule: { searchFrom: "30d", searchTo: "30d" }, + }, + }, + }; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.onlineboardSearchFrom).toBe(2); + expect(result.current.onlineboardSearchTo).toBe(14); + }); + + it("starts with loading=true and returns error on failure", async () => { + const err = new Error("network"); + mockGetAppSettings.mockRejectedValue(err); + + const { result } = renderHook(() => useAppSettings()); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe(err); + expect(result.current.onlineboardSearchFrom).toBe(2); + }); +}); diff --git a/src/shared/hooks/useAppSettings.ts b/src/shared/hooks/useAppSettings.ts new file mode 100644 index 00000000..b096004f --- /dev/null +++ b/src/shared/hooks/useAppSettings.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { getAppSettings } from "@/shared/api/appSettings.js"; + +const DAYS_PATTERN = /^(\d+)d$/; + +const DEFAULTS = { + onlineboardSearchFrom: 2, + onlineboardSearchTo: 14, + scheduleSearchFrom: 30, + scheduleSearchTo: 30, +} as const; + +function parseDays(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const match = DAYS_PATTERN.exec(value); + if (!match) return fallback; + return parseInt(match[1]!, 10); +} + +export interface UseAppSettingsResult { + onlineboardSearchFrom: number; + onlineboardSearchTo: number; + scheduleSearchFrom: number; + scheduleSearchTo: number; + loading: boolean; + error: Error | null; +} + +/** + * Fetches the global app settings and exposes day-range numbers. + * On error or parse failure, returns default values (2/14/30/30). + */ +export function useAppSettings(): UseAppSettingsResult { + const client = useApiClient(); + const [state, setState] = useState>(DEFAULTS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + getAppSettings(client) + .then((response) => { + if (cancelled) return; + const ob = response.uiOptions?.filter?.onlineboard; + const sc = response.uiOptions?.filter?.schedule; + setState({ + onlineboardSearchFrom: parseDays(ob?.searchFrom, DEFAULTS.onlineboardSearchFrom), + onlineboardSearchTo: parseDays(ob?.searchTo, DEFAULTS.onlineboardSearchTo), + scheduleSearchFrom: parseDays(sc?.searchFrom, DEFAULTS.scheduleSearchFrom), + scheduleSearchTo: parseDays(sc?.searchTo, DEFAULTS.scheduleSearchTo), + }); + setLoading(false); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [client]); + + return { ...state, loading, error }; +}