refactor(shared): update utilities, API layer, and types
This commit is contained in:
282
src/shared/api/api.test.ts
Normal file
282
src/shared/api/api.test.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Tests for API client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
api,
|
||||||
|
} from './api';
|
||||||
|
|
||||||
|
describe('api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET requests', () => {
|
||||||
|
test('should return data and status on successful request', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.get<{ id: number; name: string }>('/api/test');
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockData);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST requests', () => {
|
||||||
|
test('should send JSON body and return response', async () => {
|
||||||
|
const requestBody = { name: 'Alice', email: 'alice@example.com' };
|
||||||
|
const mockResponse = { id: 1, ...requestBody };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.post<{ id: number; name: string; email: string }>(
|
||||||
|
'/api/users',
|
||||||
|
requestBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockResponse);
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle POST with undefined body', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await api.post('/api/users', undefined);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT requests', () => {
|
||||||
|
test('should send JSON body and return response', async () => {
|
||||||
|
const requestBody = { id: 1, name: 'Updated' };
|
||||||
|
const mockResponse = { ...requestBody };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.put<{ id: number; name: string }>('/api/users/1', requestBody);
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockResponse);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users/1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE requests', () => {
|
||||||
|
test('should return data and status on successful deletion', async () => {
|
||||||
|
const mockData = { message: 'Deleted successfully' };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.delete<{ message: string }>('/api/users/1');
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockData);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users/1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
test('should throw ApiError on non-OK response', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: async () => ({ error: 'Resource not found' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(api.get('/api/not-found')).rejects.toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include status code in ApiError', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/error');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include message in ApiError', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/forbidden');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).message).toBe('Request failed: Forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include response object in ApiError', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response;
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/unauthorized');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).response).toBe(mockResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have correct error name', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/bad-request');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).name).toBe('ApiError');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headers', () => {
|
||||||
|
test('should accept custom headers (replaces defaults)', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await api.get('/api/test', {
|
||||||
|
headers: { 'X-Custom-Header': 'custom-value' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: {
|
||||||
|
'X-Custom-Header': 'custom-value',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow overriding default Content-Type', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await api.get('/api/test', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty response handling', () => {
|
||||||
|
test('should handle empty JSON response', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
json: async () => null,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.get<null>('/api/empty');
|
||||||
|
|
||||||
|
expect(result.data).toBeNull();
|
||||||
|
expect(result.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* HTTP API client with error handling
|
||||||
|
*
|
||||||
|
* Provides a typed wrapper around fetch for JSON APIs.
|
||||||
|
* Automatically handles JSON serialization and error responses.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { api } from '$shared/api';
|
||||||
|
*
|
||||||
|
* // GET request
|
||||||
|
* const users = await api.get<User[]>('/api/users');
|
||||||
|
*
|
||||||
|
* // POST request
|
||||||
|
* const newUser = await api.post<User>('/api/users', { name: 'Alice' });
|
||||||
|
*
|
||||||
|
* // Error handling
|
||||||
|
* try {
|
||||||
|
* const data = await api.get('/api/data');
|
||||||
|
* } catch (error) {
|
||||||
|
* if (error instanceof ApiError) {
|
||||||
|
* console.error(error.status, error.message);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
import type { ApiResponse } from '$shared/types/common';
|
import type { ApiResponse } from '$shared/types/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for API failures
|
||||||
|
*
|
||||||
|
* Includes HTTP status code and the original Response object
|
||||||
|
* for debugging and error handling.
|
||||||
|
*/
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
/**
|
||||||
|
* Creates a new API error
|
||||||
|
* @param status - HTTP status code
|
||||||
|
* @param message - Error message
|
||||||
|
* @param response - Original fetch Response object
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
|
/** HTTP status code */
|
||||||
public status: number,
|
public status: number,
|
||||||
message: string,
|
message: string,
|
||||||
|
/** Original Response object for inspection */
|
||||||
public response?: Response,
|
public response?: Response,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -11,6 +52,16 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal request handler
|
||||||
|
*
|
||||||
|
* Performs fetch with JSON headers and throws ApiError on failure.
|
||||||
|
*
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param options - Fetch options (method, headers, body, etc.)
|
||||||
|
* @returns Response data and status code
|
||||||
|
* @throws ApiError when response is not OK
|
||||||
|
*/
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
@@ -39,9 +90,28 @@ async function request<T>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API client methods
|
||||||
|
*
|
||||||
|
* Provides typed methods for common HTTP verbs.
|
||||||
|
* All methods return ApiResponse with data and status.
|
||||||
|
*/
|
||||||
export const api = {
|
export const api = {
|
||||||
|
/**
|
||||||
|
* Performs a GET request
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a POST request with JSON body
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param body - Request body (will be JSON stringified)
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -49,6 +119,13 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a PUT request with JSON body
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param body - Request body (will be JSON stringified)
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -56,5 +133,11 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a DELETE request
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query client instance
|
* TanStack Query client instance
|
||||||
|
*
|
||||||
|
* Configured for optimal caching and refetching behavior.
|
||||||
|
* Used by all font stores for data fetching and caching.
|
||||||
|
*
|
||||||
|
* Cache behavior:
|
||||||
|
* - Data stays fresh for 5 minutes (staleTime)
|
||||||
|
* - Unused data is garbage collected after 10 minutes (gcTime)
|
||||||
|
* - No refetch on window focus (reduces unnecessary network requests)
|
||||||
|
* - 3 retries with exponential backoff on failure
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
/**
|
/** Data remains fresh for 5 minutes after fetch */
|
||||||
* Default staleTime: 5 minutes
|
|
||||||
*/
|
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
/**
|
/** Unused cache entries are removed after 10 minutes */
|
||||||
* Default gcTime: 10 minutes
|
|
||||||
*/
|
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
|
/** Don't refetch when window regains focus */
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
/** Refetch on mount if data is stale */
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
|
/** Retry failed requests up to 3 times */
|
||||||
retry: 3,
|
retry: 3,
|
||||||
/**
|
/**
|
||||||
* Exponential backoff
|
* Exponential backoff for retries
|
||||||
|
* 1s, 2s, 4s, 8s... capped at 30s
|
||||||
*/
|
*/
|
||||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Shared library
|
||||||
|
*
|
||||||
|
* Reusable utilities, helpers, and providers for the application.
|
||||||
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type CharacterComparison,
|
type CharacterComparison,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { setContext } from 'svelte';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import type {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* The Lucide icon component
|
* Lucide icon component
|
||||||
*/
|
*/
|
||||||
icon: Component;
|
icon: Component;
|
||||||
/**
|
/**
|
||||||
* CSS classes to apply to the icon
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
/**
|
/**
|
||||||
* Additional icon-specific attributes
|
* Additional attributes
|
||||||
*/
|
*/
|
||||||
attrs?: Record<string, unknown>;
|
attrs?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,17 +15,22 @@ import { setContext } from 'svelte';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
/**
|
/**
|
||||||
* Initial viewport width for the responsive context (default: 1280)
|
* Initial viewport width
|
||||||
|
* @default 1280
|
||||||
*/
|
*/
|
||||||
initialWidth?: number;
|
initialWidth?: number;
|
||||||
/**
|
/**
|
||||||
* Initial viewport height for the responsive context (default: 720)
|
* Initial viewport height
|
||||||
|
* @default 720
|
||||||
*/
|
*/
|
||||||
initialHeight?: number;
|
initialHeight?: number;
|
||||||
/**
|
/**
|
||||||
* Tooltip provider options
|
* Tooltip delay duration
|
||||||
*/
|
*/
|
||||||
tooltipDelayDuration?: number;
|
tooltipDelayDuration?: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,56 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Build query string from URL parameters
|
* Builds URL query strings from parameter objects
|
||||||
*
|
*
|
||||||
* Generic, type-safe function to build properly encoded query strings
|
* Creates properly encoded query strings from typed parameter objects.
|
||||||
* from URL parameters. Supports primitives, arrays, and optional values.
|
* Handles primitives, arrays, and omits null/undefined values.
|
||||||
*
|
|
||||||
* @param params - Object containing query parameters
|
|
||||||
* @returns Encoded query string (empty string if no parameters)
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
|
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
|
||||||
* // Returns: "category=serif&subsets=latin&subsets=latin-ext"
|
* // Returns: "?category=serif&subsets=latin%2Clatin-ext"
|
||||||
*
|
*
|
||||||
* buildQueryString({ limit: 50, page: 1 })
|
* buildQueryString({ limit: 50, page: 1 })
|
||||||
* // Returns: "limit=50&page=1"
|
* // Returns: "?limit=50&page=1"
|
||||||
*
|
*
|
||||||
* buildQueryString({})
|
* buildQueryString({})
|
||||||
* // Returns: ""
|
* // Returns: ""
|
||||||
*
|
*
|
||||||
* buildQueryString({ search: 'hello world', active: true })
|
* buildQueryString({ search: 'hello world', active: true })
|
||||||
* // Returns: "search=hello%20world&active=true"
|
* // Returns: "?search=hello%20world&active=true"
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query parameter value type
|
* Supported query parameter value types
|
||||||
* Supports primitives, arrays, and excludes null/undefined
|
|
||||||
*/
|
*/
|
||||||
export type QueryParamValue = string | number | boolean | string[] | number[];
|
export type QueryParamValue = string | number | boolean | string[] | number[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query parameters object
|
* Query parameters object with optional values
|
||||||
*/
|
*/
|
||||||
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
|
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build query string from URL parameters
|
* Builds a URL query string from a parameters object
|
||||||
*
|
*
|
||||||
* Handles:
|
* Handles:
|
||||||
* - Primitive values (string, number, boolean)
|
* - Primitive values (string, number, boolean) - converted to strings
|
||||||
* - Arrays (multiple values with same key)
|
* - Arrays - comma-separated values
|
||||||
* - Optional values (excludes undefined/null)
|
* - null/undefined - omitted from output
|
||||||
* - Proper URL encoding
|
* - Special characters - URL encoded
|
||||||
*
|
|
||||||
* Edge cases:
|
|
||||||
* - Empty object → empty string
|
|
||||||
* - No parameters → empty string
|
|
||||||
* - Nested objects → flattens to string representation
|
|
||||||
* - Special characters → proper encoding
|
|
||||||
*
|
*
|
||||||
* @param params - Object containing query parameters
|
* @param params - Object containing query parameters
|
||||||
* @returns Encoded query string (with "?" prefix if non-empty)
|
* @returns Encoded query string with "?" prefix, or empty string if no params
|
||||||
*/
|
*/
|
||||||
export function buildQueryString(params: QueryParams): string {
|
export function buildQueryString(params: QueryParams): string {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
@@ -61,12 +51,14 @@ export function buildQueryString(params: QueryParams): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle arrays (multiple values with same key)
|
// Handle arrays (comma-separated values)
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) {
|
const joined = value
|
||||||
if (item !== undefined && item !== null) {
|
.filter(item => item !== undefined && item !== null)
|
||||||
searchParams.append(key, String(item));
|
.map(String)
|
||||||
}
|
.join(',');
|
||||||
|
if (joined) {
|
||||||
|
searchParams.append(key, joined);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle primitives
|
// Handle primitives
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Clamp a number within a range.
|
* Clamps a number within a specified range
|
||||||
* @param value The number to clamp.
|
*
|
||||||
* @param min minimum value
|
* Ensures a value falls between minimum and maximum bounds.
|
||||||
* @param max maximum value
|
* Values below min return min, values above max return max.
|
||||||
* @returns The clamped number.
|
*
|
||||||
|
* @param value - The number to clamp
|
||||||
|
* @param min - Minimum allowed value (inclusive)
|
||||||
|
* @param max - Maximum allowed value (inclusive)
|
||||||
|
* @returns The clamped number
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* clampNumber(5, 0, 10); // 5
|
||||||
|
* clampNumber(-5, 0, 10); // 0
|
||||||
|
* clampNumber(15, 0, 10); // 10
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function clampNumber(value: number, min: number, max: number): number {
|
export function clampNumber(value: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* DEBOUNCE UTILITY
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Creates a debounced function that delays execution until after wait milliseconds
|
|
||||||
* have elapsed since the last time it was invoked.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const debouncedSearch = debounce((query: string) => {
|
|
||||||
* console.log('Searching for:', query);
|
|
||||||
* }, 300);
|
|
||||||
*
|
|
||||||
* debouncedSearch('hello');
|
|
||||||
* debouncedSearch('hello world'); // Only this will execute after 300ms
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a debounced version of a function
|
* Creates a debounced version of a function
|
||||||
*
|
*
|
||||||
|
* Delays function execution until after `wait` milliseconds have elapsed
|
||||||
|
* since the last invocation. Useful for rate-limiting expensive operations
|
||||||
|
* like API calls or expensive DOM updates.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const search = debounce((query: string) => {
|
||||||
|
* console.log('Searching for:', query);
|
||||||
|
* }, 300);
|
||||||
|
*
|
||||||
|
* search('a');
|
||||||
|
* search('ab');
|
||||||
|
* search('abc'); // Only this triggers the function after 300ms
|
||||||
|
* ```
|
||||||
|
*
|
||||||
* @param fn - The function to debounce
|
* @param fn - The function to debounce
|
||||||
* @param wait - The delay in milliseconds
|
* @param wait - The delay in milliseconds
|
||||||
* @returns A debounced function that will execute after the specified delay
|
* @returns A debounced function that executes after the delay
|
||||||
*/
|
*/
|
||||||
export function debounce<T extends (...args: any[]) => any>(
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
fn: T,
|
fn: T,
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Get the number of decimal places in a number
|
* Counts the number of decimal places in a number
|
||||||
*
|
*
|
||||||
* For example:
|
* Returns the length of the decimal portion of a number.
|
||||||
* - 1 -> 0
|
* Used to determine precision for rounding operations.
|
||||||
* - 0.1 -> 1
|
|
||||||
* - 0.01 -> 2
|
|
||||||
* - 0.05 -> 2
|
|
||||||
*
|
*
|
||||||
* @param step - The step number to analyze
|
* @param step - The number to analyze
|
||||||
* @returns The number of decimal places
|
* @returns The number of decimal places (0 for integers)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* getDecimalPlaces(1); // 0
|
||||||
|
* getDecimalPlaces(0.1); // 1
|
||||||
|
* getDecimalPlaces(0.01); // 2
|
||||||
|
* getDecimalPlaces(0.005); // 3
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function getDecimalPlaces(step: number): number {
|
export function getDecimalPlaces(step: number): number {
|
||||||
const str = step.toString();
|
const str = step.toString();
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Shared utility functions
|
* Shared utility functions
|
||||||
|
*
|
||||||
|
* Provides common utilities for:
|
||||||
|
* - Number manipulation (clamping, precision, decimal places)
|
||||||
|
* - Function execution control (debounce, throttle)
|
||||||
|
* - Array operations (split by predicate)
|
||||||
|
* - URL handling (query string building)
|
||||||
|
* - DOM interactions (smooth scrolling)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { getDecimalPlaces } from '$shared/lib/utils';
|
import { getDecimalPlaces } from '$shared/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Round a value to the precision of the given step
|
* Rounds a value to match the precision of a given step
|
||||||
*
|
*
|
||||||
* This fixes floating-point precision errors that occur with decimal steps.
|
* Fixes floating-point precision errors that occur with decimal arithmetic.
|
||||||
* For example, with step=0.05, adding it repeatedly can produce values like
|
* For example, repeatedly adding 0.05 can produce 1.3499999999999999
|
||||||
* 1.3499999999999999 instead of 1.35.
|
* instead of 1.35 due to IEEE 754 floating-point representation.
|
||||||
*
|
*
|
||||||
* We use toFixed() to round to the appropriate decimal places instead of
|
* Uses toFixed() instead of Math.round() for correct decimal rounding.
|
||||||
* Math.round(value / step) * step, which doesn't always work correctly
|
|
||||||
* due to floating-point arithmetic errors.
|
|
||||||
*
|
*
|
||||||
* @param value - The value to round
|
* @param value - The value to round
|
||||||
* @param step - The step to round to (defaults to 1)
|
* @param step - The step size to match precision of (default: 1)
|
||||||
* @returns The rounded value
|
* @returns The rounded value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* roundToStepPrecision(1.3499999999999999, 0.05); // 1.35
|
||||||
|
* roundToStepPrecision(1.2345, 0.01); // 1.23
|
||||||
|
* roundToStepPrecision(1.2345, 0.1); // 1.2
|
||||||
|
* roundToStepPrecision(1.5, 1); // 2
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function roundToStepPrecision(value: number, step: number = 1): number {
|
export function roundToStepPrecision(value: number, step: number = 1): number {
|
||||||
if (step <= 0) {
|
if (step <= 0) {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Smoothly scrolls to the target element when an anchor element is clicked.
|
* Svelte action for smooth anchor scrolling
|
||||||
* @param node - The anchor element to listen for clicks on.
|
*
|
||||||
|
* Intercepts anchor link clicks to smoothly scroll to the target element
|
||||||
|
* instead of jumping instantly. Updates URL hash without causing scroll.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <a href="#section" use:smoothScroll>Go to Section</a>
|
||||||
|
* <div id="section">Section Content</div>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param node - The anchor element to attach to
|
||||||
|
* @returns Action object with destroy method
|
||||||
*/
|
*/
|
||||||
export function smoothScroll(node: HTMLAnchorElement) {
|
export function smoothScroll(node: HTMLAnchorElement) {
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
@@ -17,7 +28,7 @@ export function smoothScroll(node: HTMLAnchorElement) {
|
|||||||
block: 'start',
|
block: 'start',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update URL hash without jumping
|
// Update URL hash without triggering scroll
|
||||||
history.pushState(null, '', hash);
|
history.pushState(null, '', hash);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Splits an array into two arrays based on a callback function.
|
* Splits an array into two groups based on a predicate
|
||||||
* @param array The array to split.
|
*
|
||||||
* @param callback The callback function to determine which array to push each item to.
|
* Partitions an array into pass/fail groups using a callback function.
|
||||||
* @returns - An array containing two arrays, the first array contains items that passed the callback, the second array contains items that failed the callback.
|
* More efficient than calling filter() twice.
|
||||||
|
*
|
||||||
|
* @param array - The array to split
|
||||||
|
* @param callback - Predicate function (true = first array, false = second)
|
||||||
|
* @returns Tuple of [passing items, failing items]
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const numbers = [1, 2, 3, 4, 5, 6];
|
||||||
|
* const [even, odd] = splitArray(numbers, n => n % 2 === 0);
|
||||||
|
* // even: [2, 4, 6]
|
||||||
|
* // odd: [1, 3, 5]
|
||||||
|
*
|
||||||
|
* const users = [
|
||||||
|
* { name: 'Alice', active: true },
|
||||||
|
* { name: 'Bob', active: false }
|
||||||
|
* ];
|
||||||
|
* const [active, inactive] = splitArray(users, u => u.active);
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
||||||
return array.reduce<[T[], T[]]>(
|
return array.reduce<[T[], T[]]>(
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Throttle function execution to a maximum frequency.
|
* Throttles a function to limit execution frequency
|
||||||
*
|
*
|
||||||
* @param fn Function to throttle.
|
* Ensures a function executes at most once per `wait` milliseconds.
|
||||||
* @param wait Maximum time between function calls.
|
* Unlike debounce, throttled functions execute on the leading edge
|
||||||
* @returns Throttled function.
|
* and trailing edge if called repeatedly.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const logScroll = throttle(() => {
|
||||||
|
* console.log('Scroll position:', window.scrollY);
|
||||||
|
* }, 100);
|
||||||
|
*
|
||||||
|
* window.addEventListener('scroll', logScroll);
|
||||||
|
* // Will log at most once every 100ms
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param fn - Function to throttle
|
||||||
|
* @param wait - Minimum time between executions in milliseconds
|
||||||
|
* @returns Throttled function
|
||||||
*/
|
*/
|
||||||
export function throttle<T extends (...args: any[]) => any>(
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
fn: T,
|
fn: T,
|
||||||
@@ -20,7 +34,7 @@ export function throttle<T extends (...args: any[]) => any>(
|
|||||||
lastCall = now;
|
lastCall = now;
|
||||||
fn(...args);
|
fn(...args);
|
||||||
} else {
|
} else {
|
||||||
// Schedule for end of wait period
|
// Schedule for end of wait period (trailing edge)
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
lastCall = Date.now();
|
lastCall = Date.now();
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Standard API response wrapper
|
||||||
|
*
|
||||||
|
* Encapsulates response data with HTTP status code
|
||||||
|
* for consistent error handling across API calls.
|
||||||
|
*
|
||||||
|
* @template T - Type of the response data
|
||||||
|
*/
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
|
/** Response payload data */
|
||||||
data: T;
|
data: T;
|
||||||
|
/** HTTP status code */
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user