Compare commits
2 Commits
f12047052e
...
481419afc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 481419afc1 | |||
| 895181dc8c |
78
.gitea/workflows/ci.yml
Normal file
78
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typescript:
|
||||||
|
name: TypeScript Lint + Typecheck + Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: MosaicIQ
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run tsc --noEmit
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
|
|
||||||
|
rust:
|
||||||
|
name: Rust Format + Clippy + Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: MosaicIQ/src-tauri
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Tauri system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
libxdo-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache Cargo artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
MosaicIQ/src-tauri/target
|
||||||
|
key: rust-${{ runner.os }}-${{ hashFiles('MosaicIQ/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: rust-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --all-features
|
||||||
3
MosaicIQ/src-tauri/rust-toolchain.toml
Normal file
3
MosaicIQ/src-tauri/rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.93.0"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import {
|
import {
|
||||||
buildFilingsCommand,
|
buildFilingsCommand,
|
||||||
|
buildFilingsFormTypeCommand,
|
||||||
buildFilingsPageSizeCommand,
|
buildFilingsPageSizeCommand,
|
||||||
buildFilingsPaginationCommand,
|
buildFilingsPaginationCommand,
|
||||||
buildFilingsSearchCommand,
|
buildFilingsSearchCommand,
|
||||||
@@ -94,4 +95,56 @@ describe('FilingsPanel', () => {
|
|||||||
'/filings AAPL',
|
'/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 React from 'react';
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import type { FilingsPanelData } from '../../types/financial';
|
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 = 1;
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
@@ -11,6 +17,7 @@ export interface FilingsCommandState {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
|
formType?: CommonFormType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilingsPanelProps {
|
interface FilingsPanelProps {
|
||||||
@@ -32,9 +39,10 @@ export const buildFilingsCommand = ({
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
query,
|
query,
|
||||||
|
formType,
|
||||||
}: FilingsCommandState) => {
|
}: FilingsCommandState) => {
|
||||||
const normalizedSymbol = symbol.trim().toUpperCase();
|
const normalizedSymbol = symbol.trim().toUpperCase();
|
||||||
const normalizedQuery = query?.trim();
|
const normalizedQuery = formType ?? query?.trim();
|
||||||
const tokens = ['/filings', normalizedSymbol];
|
const tokens = ['/filings', normalizedSymbol];
|
||||||
|
|
||||||
if (page > DEFAULT_PAGE) {
|
if (page > DEFAULT_PAGE) {
|
||||||
@@ -85,15 +93,28 @@ export const buildFilingsSearchCommand = (
|
|||||||
query,
|
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> = ({
|
export const FilingsPanel: React.FC<FilingsPanelProps> = ({
|
||||||
data,
|
data,
|
||||||
onRunCommand,
|
onRunCommand,
|
||||||
}) => {
|
}) => {
|
||||||
const [queryDraft, setQueryDraft] = React.useState(data.query ?? '');
|
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(() => {
|
React.useEffect(() => {
|
||||||
setQueryDraft(data.query ?? '');
|
const next = data.query ?? '';
|
||||||
|
setQueryDraft(isCommonFormType(next) ? '' : next);
|
||||||
}, [data.query, data.symbol]);
|
}, [data.query, data.symbol]);
|
||||||
|
|
||||||
const runCommand = React.useCallback(
|
const runCommand = React.useCallback(
|
||||||
@@ -141,6 +162,14 @@ export const FilingsPanel: React.FC<FilingsPanelProps> = ({
|
|||||||
runCommand(buildFilingsSearchCommand(data, undefined));
|
runCommand(buildFilingsSearchCommand(data, undefined));
|
||||||
}, [data, normalizedCurrentQuery, queryDraft, runCommand]);
|
}, [data, normalizedCurrentQuery, queryDraft, runCommand]);
|
||||||
|
|
||||||
|
const toggleFormType = React.useCallback(
|
||||||
|
(formType: CommonFormType) => {
|
||||||
|
const next = toggleFilingTypeFilter(activeFormType, formType);
|
||||||
|
runCommand(buildFilingsFormTypeCommand(data, next));
|
||||||
|
},
|
||||||
|
[activeFormType, data, runCommand],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="filings-panel py-4 overflow-auto">
|
<div className="filings-panel py-4 overflow-auto">
|
||||||
<header className="mb-4">
|
<header className="mb-4">
|
||||||
@@ -167,6 +196,23 @@ export const FilingsPanel: React.FC<FilingsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<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">
|
<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">
|
<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