2 Commits

Author SHA1 Message Date
481419afc1 ci: add CI pipeline for Rust and TypeScript tests
Some checks failed
CI / TypeScript Lint + Typecheck + Test (push) Has been cancelled
CI / Rust Format + Clippy + Test (push) Has been cancelled
- Gitea Actions workflow with parallel TypeScript and Rust jobs
- TypeScript: bun install, lint, typecheck, test
- Rust: fmt check, clippy, test with Tauri system deps
- Add rust-toolchain.toml to pin Rust 1.93.0 for reproducibility

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-01 14:26:50 -04:00
895181dc8c 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>
2026-05-01 14:25:21 -04:00
8 changed files with 713 additions and 3 deletions

78
.gitea/workflows/ci.yml Normal file
View 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

View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.93.0"
components = ["rustfmt", "clippy"]

View File

@@ -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');
});
});

View File

@@ -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">

View 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);
});
});

View 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,
};
};

View 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');
});
});

View 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.`;
};