diff --git a/MosaicIQ/src/components/Panels/FilingsPanel.test.tsx b/MosaicIQ/src/components/Panels/FilingsPanel.test.tsx index 630879a..8681f9e 100644 --- a/MosaicIQ/src/components/Panels/FilingsPanel.test.tsx +++ b/MosaicIQ/src/components/Panels/FilingsPanel.test.tsx @@ -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(); + 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(); + 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'); + }); }); diff --git a/MosaicIQ/src/components/Panels/FilingsPanel.tsx b/MosaicIQ/src/components/Panels/FilingsPanel.tsx index e516560..421285c 100644 --- a/MosaicIQ/src/components/Panels/FilingsPanel.tsx +++ b/MosaicIQ/src/components/Panels/FilingsPanel.tsx @@ -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 = ({ 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 = ({ 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 ( @@ -167,6 +196,23 @@ export const FilingsPanel: React.FC = ({ + + {COMMON_FORM_TYPE_OPTIONS.map((formType) => ( + toggleFormType(formType)} + > + {formType} + + ))} + + diff --git a/MosaicIQ/src/hooks/useQueryHistory.test.ts b/MosaicIQ/src/hooks/useQueryHistory.test.ts new file mode 100644 index 0000000..4d99fd5 --- /dev/null +++ b/MosaicIQ/src/hooks/useQueryHistory.test.ts @@ -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); + }); +}); diff --git a/MosaicIQ/src/hooks/useQueryHistory.ts b/MosaicIQ/src/hooks/useQueryHistory.ts new file mode 100644 index 0000000..3465d05 --- /dev/null +++ b/MosaicIQ/src/hooks/useQueryHistory.ts @@ -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(); + 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([]); + const [isLoaded, setIsLoaded] = useState(false); + const historyRef = useRef([]); + + 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, + }; +}; diff --git a/MosaicIQ/src/lib/searchQuery.test.ts b/MosaicIQ/src/lib/searchQuery.test.ts new file mode 100644 index 0000000..2998b99 --- /dev/null +++ b/MosaicIQ/src/lib/searchQuery.test.ts @@ -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'); + }); +}); diff --git a/MosaicIQ/src/lib/searchQuery.ts b/MosaicIQ/src/lib/searchQuery.ts new file mode 100644 index 0000000..523e4bb --- /dev/null +++ b/MosaicIQ/src/lib/searchQuery.ts @@ -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 { + items: T[]; + totalResults: number; + page: number; + pageSize: number; + query?: string; + formTypeFilter?: CommonFormType | null; +} + +export const filterFilingItemsByFormType = ( + 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.`; +};