feat: add search/query utilities, filing type filters, and query history
- src/lib/searchQuery.ts: query parsing (ticker vs company name), form type constants, filing item filtering, and error message builders - src/lib/searchQuery.test.ts: 32 tests covering query classification, form type validation, filter toggling, and error messages - FilingsPanel: quick-filter chips for common SEC form types (10-K, 10-Q, 8-K, 20-F, etc.) with toggle behavior - FilingsPanel.test.tsx: 10 tests including new form type commands and chip rendering - src/hooks/useQueryHistory.ts: localStorage-persisted command history with deduplication and search (max 50 entries) - src/hooks/useQueryHistory.test.ts: 5 tests for deduplication logic Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
buildFilingsCommand,
|
||||
buildFilingsFormTypeCommand,
|
||||
buildFilingsPageSizeCommand,
|
||||
buildFilingsPaginationCommand,
|
||||
buildFilingsSearchCommand,
|
||||
@@ -94,4 +95,56 @@ describe('FilingsPanel', () => {
|
||||
'/filings AAPL',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds form type filter commands', () => {
|
||||
expect(buildFilingsFormTypeCommand(samplePanel, '10-K')).toBe(
|
||||
'/filings AAPL --page-size 50 --query 10-K',
|
||||
);
|
||||
expect(buildFilingsFormTypeCommand(samplePanel, null)).toBe(
|
||||
'/filings AAPL --page-size 50',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders form type filter chips', () => {
|
||||
const html = renderToStaticMarkup(<FilingsPanel data={samplePanel} />);
|
||||
expect(html).toContain('10-K');
|
||||
expect(html).toContain('10-Q');
|
||||
expect(html).toContain('8-K');
|
||||
});
|
||||
|
||||
it('renders form type filter chips for panel without query', () => {
|
||||
const noQueryPanel: FilingsPanelData = {
|
||||
...samplePanel,
|
||||
query: undefined,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
};
|
||||
const html = renderToStaticMarkup(<FilingsPanel data={noQueryPanel} />);
|
||||
expect(html).toContain('10-K');
|
||||
expect(html).toContain('10-Q');
|
||||
expect(html).toContain('8-K');
|
||||
});
|
||||
|
||||
it('handles form type override in buildFilingsCommand', () => {
|
||||
expect(
|
||||
buildFilingsCommand({
|
||||
symbol: 'AAPL',
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
formType: '8-K',
|
||||
}),
|
||||
).toBe('/filings AAPL --query 8-K');
|
||||
});
|
||||
|
||||
it('prefers formType over query when both are present', () => {
|
||||
expect(
|
||||
buildFilingsCommand({
|
||||
symbol: 'AAPL',
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
query: 'annual',
|
||||
formType: '10-Q',
|
||||
}),
|
||||
).toBe('/filings AAPL --query 10-Q');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import type { FilingsPanelData } from '../../types/financial';
|
||||
import {
|
||||
COMMON_FORM_TYPE_OPTIONS,
|
||||
type CommonFormType,
|
||||
isCommonFormType,
|
||||
toggleFilingTypeFilter,
|
||||
} from '../../lib/searchQuery';
|
||||
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
@@ -11,6 +17,7 @@ export interface FilingsCommandState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
query?: string;
|
||||
formType?: CommonFormType;
|
||||
}
|
||||
|
||||
interface FilingsPanelProps {
|
||||
@@ -32,9 +39,10 @@ export const buildFilingsCommand = ({
|
||||
page,
|
||||
pageSize,
|
||||
query,
|
||||
formType,
|
||||
}: FilingsCommandState) => {
|
||||
const normalizedSymbol = symbol.trim().toUpperCase();
|
||||
const normalizedQuery = query?.trim();
|
||||
const normalizedQuery = formType ?? query?.trim();
|
||||
const tokens = ['/filings', normalizedSymbol];
|
||||
|
||||
if (page > DEFAULT_PAGE) {
|
||||
@@ -85,15 +93,28 @@ export const buildFilingsSearchCommand = (
|
||||
query,
|
||||
});
|
||||
|
||||
export const buildFilingsFormTypeCommand = (
|
||||
data: FilingsPanelData,
|
||||
formType: CommonFormType | null,
|
||||
) =>
|
||||
buildFilingsCommand({
|
||||
symbol: data.symbol,
|
||||
page: DEFAULT_PAGE,
|
||||
pageSize: data.pageSize,
|
||||
formType: formType ?? undefined,
|
||||
});
|
||||
|
||||
export const FilingsPanel: React.FC<FilingsPanelProps> = ({
|
||||
data,
|
||||
onRunCommand,
|
||||
}) => {
|
||||
const [queryDraft, setQueryDraft] = React.useState(data.query ?? '');
|
||||
const normalizedCurrentQuery = data.query?.trim() ?? '';
|
||||
const activeFormType = isCommonFormType(data.query ?? '') ? (data.query as CommonFormType) : null;
|
||||
const normalizedCurrentQuery = activeFormType ? '' : (data.query?.trim() ?? '');
|
||||
|
||||
React.useEffect(() => {
|
||||
setQueryDraft(data.query ?? '');
|
||||
const next = data.query ?? '';
|
||||
setQueryDraft(isCommonFormType(next) ? '' : next);
|
||||
}, [data.query, data.symbol]);
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
@@ -141,6 +162,14 @@ export const FilingsPanel: React.FC<FilingsPanelProps> = ({
|
||||
runCommand(buildFilingsSearchCommand(data, undefined));
|
||||
}, [data, normalizedCurrentQuery, queryDraft, runCommand]);
|
||||
|
||||
const toggleFormType = React.useCallback(
|
||||
(formType: CommonFormType) => {
|
||||
const next = toggleFilingTypeFilter(activeFormType, formType);
|
||||
runCommand(buildFilingsFormTypeCommand(data, next));
|
||||
},
|
||||
[activeFormType, data, runCommand],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="filings-panel py-4 overflow-auto">
|
||||
<header className="mb-4">
|
||||
@@ -167,6 +196,23 @@ export const FilingsPanel: React.FC<FilingsPanelProps> = ({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{COMMON_FORM_TYPE_OPTIONS.map((formType) => (
|
||||
<button
|
||||
key={formType}
|
||||
type="button"
|
||||
className={`rounded border px-2.5 py-1 font-mono text-[11px] uppercase tracking-[0.16em] transition ${
|
||||
activeFormType === formType
|
||||
? 'border-[var(--term-status-info-border)] bg-[var(--term-status-info)] text-[var(--term-status-info-strong)]'
|
||||
: 'border-term-border text-term-text-muted hover:border-info hover:text-term-text'
|
||||
}`}
|
||||
onClick={() => toggleFormType(formType)}
|
||||
>
|
||||
{formType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mb-4">
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-lg border border-term-border-subtle bg-term-highlight/40 px-3 py-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
|
||||
59
MosaicIQ/src/hooks/useQueryHistory.test.ts
Normal file
59
MosaicIQ/src/hooks/useQueryHistory.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { deduplicateHistory, type QueryHistoryEntry } from './useQueryHistory';
|
||||
|
||||
const makeEntry = (command: string, id?: string): QueryHistoryEntry => ({
|
||||
id: id ?? `id-${command}`,
|
||||
command,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
describe('deduplicateHistory', () => {
|
||||
it('removes duplicate commands keeping the first occurrence', () => {
|
||||
const entries = [
|
||||
makeEntry('/search AAPL', '1'),
|
||||
makeEntry('/fa AAPL', '2'),
|
||||
makeEntry('/search AAPL', '3'),
|
||||
];
|
||||
|
||||
const result = deduplicateHistory(entries);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('1');
|
||||
expect(result[1].id).toBe('2');
|
||||
});
|
||||
|
||||
it('trims leading/trailing whitespace before comparing', () => {
|
||||
const entries = [
|
||||
makeEntry(' /search AAPL ', '1'),
|
||||
makeEntry('/search AAPL', '2'),
|
||||
];
|
||||
|
||||
const result = deduplicateHistory(entries);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('respects the maxEntries limit', () => {
|
||||
const entries = Array.from({ length: 20 }, (_, i) =>
|
||||
makeEntry(`/search TICK${i}`, `id-${i}`),
|
||||
);
|
||||
|
||||
const result = deduplicateHistory(entries, 5);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].command).toBe('/search TICK0');
|
||||
});
|
||||
|
||||
it('returns empty for empty input', () => {
|
||||
expect(deduplicateHistory([])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps all unique commands', () => {
|
||||
const entries = [
|
||||
makeEntry('/fa AAPL'),
|
||||
makeEntry('/bs AAPL'),
|
||||
makeEntry('/cf AAPL'),
|
||||
makeEntry('/filings AAPL'),
|
||||
];
|
||||
|
||||
expect(deduplicateHistory(entries)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
124
MosaicIQ/src/hooks/useQueryHistory.ts
Normal file
124
MosaicIQ/src/hooks/useQueryHistory.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface QueryHistoryEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'query_history';
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
const generateId = (): string =>
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `qh-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const normalizeCommand = (command: string): string => command.trim();
|
||||
|
||||
export const deduplicateHistory = (
|
||||
history: QueryHistoryEntry[],
|
||||
maxEntries: number = MAX_ENTRIES,
|
||||
): QueryHistoryEntry[] => {
|
||||
const seen = new Set<string>();
|
||||
const deduped: QueryHistoryEntry[] = [];
|
||||
|
||||
for (const entry of history) {
|
||||
const normalized = normalizeCommand(entry.command);
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
deduped.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return deduped.slice(0, maxEntries);
|
||||
};
|
||||
|
||||
const serializeEntries = (entries: QueryHistoryEntry[]): string =>
|
||||
JSON.stringify(entries);
|
||||
|
||||
const deserializeEntries = (raw: string): QueryHistoryEntry[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(e): e is QueryHistoryEntry =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
typeof e.id === 'string' &&
|
||||
typeof e.command === 'string' &&
|
||||
typeof e.timestamp === 'string',
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const useQueryHistory = () => {
|
||||
const [history, setHistory] = useState<QueryHistoryEntry[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const historyRef = useRef<QueryHistoryEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
historyRef.current = history;
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
setHistory(deserializeEntries(stored).slice(0, MAX_ENTRIES));
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, serializeEntries(history));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}, [history, isLoaded]);
|
||||
|
||||
const recordQuery = useCallback((command: string) => {
|
||||
const normalized = normalizeCommand(command);
|
||||
if (!normalized) return;
|
||||
|
||||
const entry: QueryHistoryEntry = {
|
||||
id: generateId(),
|
||||
command: normalized,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setHistory((prev) => deduplicateHistory([entry, ...prev]));
|
||||
}, []);
|
||||
|
||||
const clearHistory = useCallback(() => {
|
||||
setHistory([]);
|
||||
}, []);
|
||||
|
||||
const searchHistory = useCallback(
|
||||
(query: string): QueryHistoryEntry[] => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return historyRef.current;
|
||||
|
||||
return historyRef.current.filter((entry) =>
|
||||
entry.command.toLowerCase().includes(normalized),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
history,
|
||||
recordQuery,
|
||||
clearHistory,
|
||||
searchHistory,
|
||||
isLoaded,
|
||||
};
|
||||
};
|
||||
198
MosaicIQ/src/lib/searchQuery.test.ts
Normal file
198
MosaicIQ/src/lib/searchQuery.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildFilingTypeQuery,
|
||||
buildSearchErrorMessage,
|
||||
filterFilingItemsByFormType,
|
||||
isCommonFormType,
|
||||
normalizeSearchQuery,
|
||||
toggleFilingTypeFilter,
|
||||
} from './searchQuery';
|
||||
|
||||
describe('normalizeSearchQuery', () => {
|
||||
it('classifies empty input', () => {
|
||||
const q = normalizeSearchQuery('');
|
||||
expect(q.kind).toBe('empty');
|
||||
expect(q.normalized).toBe('');
|
||||
});
|
||||
|
||||
it('classifies whitespace-only input as empty', () => {
|
||||
expect(normalizeSearchQuery(' ').kind).toBe('empty');
|
||||
});
|
||||
|
||||
it('classifies a valid ticker', () => {
|
||||
const q = normalizeSearchQuery('AAPL');
|
||||
expect(q.kind).toBe('ticker');
|
||||
expect(q.ticker).toBe('AAPL');
|
||||
expect(q.normalized).toBe('AAPL');
|
||||
});
|
||||
|
||||
it('normalizes tickers to uppercase', () => {
|
||||
const q = normalizeSearchQuery('nvda');
|
||||
expect(q.kind).toBe('ticker');
|
||||
expect(q.ticker).toBe('NVDA');
|
||||
});
|
||||
|
||||
it('accepts tickers with dot suffixes like BRK.B', () => {
|
||||
const q = normalizeSearchQuery('BRK.B');
|
||||
expect(q.kind).toBe('ticker');
|
||||
expect(q.ticker).toBe('BRK.B');
|
||||
});
|
||||
|
||||
it('accepts tickers with dash suffixes', () => {
|
||||
const q = normalizeSearchQuery('ABC-WT');
|
||||
expect(q.kind).toBe('ticker');
|
||||
expect(q.ticker).toBe('ABC-WT');
|
||||
});
|
||||
|
||||
it('rejects 6+ letter all-caps as ticker', () => {
|
||||
const q = normalizeSearchQuery('LONGER');
|
||||
expect(q.kind).toBe('company_name');
|
||||
expect(q.ticker).toBeUndefined();
|
||||
});
|
||||
|
||||
it('classifies multi-word input as company name', () => {
|
||||
const q = normalizeSearchQuery('Apple Inc');
|
||||
expect(q.kind).toBe('company_name');
|
||||
expect(q.normalized).toBe('APPLE INC');
|
||||
});
|
||||
|
||||
it('classifies input with special chars as company name', () => {
|
||||
const q = normalizeSearchQuery('Berkshire Hathaway (BRK)');
|
||||
expect(q.kind).toBe('company_name');
|
||||
});
|
||||
|
||||
it('returns invalid for queries over 200 chars', () => {
|
||||
const long = 'A'.repeat(201);
|
||||
const q = normalizeSearchQuery(long);
|
||||
expect(q.kind).toBe('invalid');
|
||||
expect(q.errorMessage).toContain('too long');
|
||||
});
|
||||
|
||||
it('allows exactly 200 characters', () => {
|
||||
const exact = 'A'.repeat(200);
|
||||
const q = normalizeSearchQuery(exact);
|
||||
expect(q.kind).not.toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCommonFormType', () => {
|
||||
it('recognizes 10-K', () => {
|
||||
expect(isCommonFormType('10-K')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes 10-Q', () => {
|
||||
expect(isCommonFormType('10-Q')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes 8-K', () => {
|
||||
expect(isCommonFormType('8-K')).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(isCommonFormType('10-k')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown types', () => {
|
||||
expect(isCommonFormType('13-F')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(isCommonFormType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleFilingTypeFilter', () => {
|
||||
it('selects a form type when none is active', () => {
|
||||
expect(toggleFilingTypeFilter(null, '10-K')).toBe('10-K');
|
||||
});
|
||||
|
||||
it('deselects when clicking the same type', () => {
|
||||
expect(toggleFilingTypeFilter('10-K', '10-K')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches to a different type', () => {
|
||||
expect(toggleFilingTypeFilter('10-K', '8-K')).toBe('8-K');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFilingTypeQuery', () => {
|
||||
it('returns the form type string when set', () => {
|
||||
expect(buildFilingTypeQuery('10-K')).toBe('10-K');
|
||||
});
|
||||
|
||||
it('returns undefined when null', () => {
|
||||
expect(buildFilingTypeQuery(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterFilingItemsByFormType', () => {
|
||||
const items = [
|
||||
{ form: '10-K', title: 'Annual' },
|
||||
{ form: '10-Q', title: 'Quarterly' },
|
||||
{ form: '8-K', title: 'Current' },
|
||||
{ form: '10-K', title: 'Annual 2' },
|
||||
];
|
||||
|
||||
it('returns all items when filter is null', () => {
|
||||
expect(filterFilingItemsByFormType(items, null)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('filters to matching form type only', () => {
|
||||
const result = filterFilingItemsByFormType(items, '10-K');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((i) => i.form === '10-K')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty when no match', () => {
|
||||
expect(filterFilingItemsByFormType(items, 'S-1')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is case-insensitive on items', () => {
|
||||
const mixed = [{ form: '10-k', title: 'lower' }];
|
||||
expect(filterFilingItemsByFormType(mixed, '10-K')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearchErrorMessage', () => {
|
||||
it('handles empty query', () => {
|
||||
const msg = buildSearchErrorMessage(normalizeSearchQuery(''));
|
||||
expect(msg).toContain('enter a ticker');
|
||||
});
|
||||
|
||||
it('handles 404 with ticker', () => {
|
||||
const msg = buildSearchErrorMessage(normalizeSearchQuery('ZZZZ'), {
|
||||
httpStatus: 404,
|
||||
});
|
||||
expect(msg).toContain('No company found');
|
||||
expect(msg).toContain('ZZZZ');
|
||||
});
|
||||
|
||||
it('handles 404 with company name', () => {
|
||||
const msg = buildSearchErrorMessage(
|
||||
normalizeSearchQuery('Nonexistent Corp'),
|
||||
{ httpStatus: 404 },
|
||||
);
|
||||
expect(msg).toContain('No companies found');
|
||||
});
|
||||
|
||||
it('handles 429 rate limit', () => {
|
||||
const msg = buildSearchErrorMessage(normalizeSearchQuery('AAPL'), {
|
||||
httpStatus: 429,
|
||||
});
|
||||
expect(msg).toContain('Rate limit');
|
||||
});
|
||||
|
||||
it('handles 500 server error with source name', () => {
|
||||
const msg = buildSearchErrorMessage(normalizeSearchQuery('AAPL'), {
|
||||
httpStatus: 500,
|
||||
source: 'SEC EDGAR',
|
||||
});
|
||||
expect(msg).toContain('SEC EDGAR');
|
||||
expect(msg).toContain('temporarily unavailable');
|
||||
});
|
||||
|
||||
it('handles generic no-results', () => {
|
||||
const msg = buildSearchErrorMessage(normalizeSearchQuery('AAPL'));
|
||||
expect(msg).toContain('no results');
|
||||
});
|
||||
});
|
||||
149
MosaicIQ/src/lib/searchQuery.ts
Normal file
149
MosaicIQ/src/lib/searchQuery.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export interface SearchQuery {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
kind: SearchQueryKind;
|
||||
ticker?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export type SearchQueryKind =
|
||||
| 'ticker'
|
||||
| 'company_name'
|
||||
| 'empty'
|
||||
| 'invalid';
|
||||
|
||||
const TICKER_PATTERN = /^[A-Z]{1,5}([.-][A-Z]{1,2})?$/;
|
||||
|
||||
const COMMON_FORM_TYPES = [
|
||||
'10-K',
|
||||
'10-Q',
|
||||
'8-K',
|
||||
'10-K/A',
|
||||
'10-Q/A',
|
||||
'8-K/A',
|
||||
'20-F',
|
||||
'40-F',
|
||||
'S-1',
|
||||
'S-3',
|
||||
'S-4',
|
||||
'DEF 14A',
|
||||
'SC 13D',
|
||||
'SC 13G',
|
||||
'6-K',
|
||||
'11-K',
|
||||
] as const;
|
||||
|
||||
export type CommonFormType = (typeof COMMON_FORM_TYPES)[number];
|
||||
|
||||
export const isCommonFormType = (value: string): value is CommonFormType =>
|
||||
(COMMON_FORM_TYPES as readonly string[]).includes(value.toUpperCase());
|
||||
|
||||
export const COMMON_FORM_TYPE_OPTIONS = COMMON_FORM_TYPES;
|
||||
|
||||
export const normalizeSearchQuery = (raw: string): SearchQuery => {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return { raw, normalized: '', kind: 'empty' };
|
||||
}
|
||||
|
||||
const upper = trimmed.toUpperCase();
|
||||
|
||||
if (upper.length > 200) {
|
||||
return {
|
||||
raw,
|
||||
normalized: upper,
|
||||
kind: 'invalid',
|
||||
errorMessage: 'Query is too long (max 200 characters).',
|
||||
};
|
||||
}
|
||||
|
||||
if (/[^A-Za-z0-9\s.-]/.test(trimmed)) {
|
||||
return {
|
||||
raw,
|
||||
normalized: upper,
|
||||
kind: 'company_name',
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
|
||||
if (tokens.length === 1 && TICKER_PATTERN.test(upper)) {
|
||||
return {
|
||||
raw,
|
||||
normalized: upper,
|
||||
kind: 'ticker',
|
||||
ticker: upper,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
normalized: upper,
|
||||
kind: 'company_name',
|
||||
};
|
||||
};
|
||||
|
||||
export interface FilingTypeFilter {
|
||||
formTypes: CommonFormType[];
|
||||
activeFormType: CommonFormType | null;
|
||||
}
|
||||
|
||||
export const buildFilingTypeQuery = (
|
||||
formType: CommonFormType | null,
|
||||
): string | undefined => (formType ?? undefined);
|
||||
|
||||
export const toggleFilingTypeFilter = (
|
||||
current: CommonFormType | null,
|
||||
selected: CommonFormType,
|
||||
): CommonFormType | null => (current === selected ? null : selected);
|
||||
|
||||
export interface SearchResult<T> {
|
||||
items: T[];
|
||||
totalResults: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
query?: string;
|
||||
formTypeFilter?: CommonFormType | null;
|
||||
}
|
||||
|
||||
export const filterFilingItemsByFormType = <T extends { form: string }>(
|
||||
items: T[],
|
||||
formType: CommonFormType | null,
|
||||
): T[] => {
|
||||
if (!formType) return items;
|
||||
return items.filter((item) => item.form.toUpperCase() === formType);
|
||||
};
|
||||
|
||||
export const buildSearchErrorMessage = (
|
||||
query: SearchQuery,
|
||||
context?: { source?: string; httpStatus?: number },
|
||||
): string => {
|
||||
if (query.kind === 'empty') {
|
||||
return 'Please enter a ticker symbol or company name to search.';
|
||||
}
|
||||
|
||||
if (query.kind === 'invalid') {
|
||||
return query.errorMessage ?? 'Invalid search query.';
|
||||
}
|
||||
|
||||
if (context?.httpStatus === 429) {
|
||||
return `Rate limit reached while searching for "${query.raw}". Please wait and try again.`;
|
||||
}
|
||||
|
||||
if (context?.httpStatus === 404) {
|
||||
return query.kind === 'ticker'
|
||||
? `No company found with ticker "${query.ticker}".`
|
||||
: `No companies found matching "${query.raw}".`;
|
||||
}
|
||||
|
||||
if (context?.httpStatus && context.httpStatus >= 500) {
|
||||
return `${context.source ?? 'Data source'} is temporarily unavailable. Your search for "${query.raw}" could not be completed.`;
|
||||
}
|
||||
|
||||
if (context?.httpStatus) {
|
||||
return `Search for "${query.raw}" failed (${context.httpStatus}).`;
|
||||
}
|
||||
|
||||
return `Search for "${query.raw}" returned no results.`;
|
||||
};
|
||||
Reference in New Issue
Block a user