diff --git a/app/analysis/page.tsx b/app/analysis/page.tsx index e4f5cc3..5908b0a 100644 --- a/app/analysis/page.tsx +++ b/app/analysis/page.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { AppShell } from '@/components/shell/app-shell'; +import { CompanyAnalysisSkeleton } from '@/components/analysis/company-analysis-skeleton'; import { AnalysisToolbar } from '@/components/analysis/analysis-toolbar'; import { BullBearPanel } from '@/components/analysis/bull-bear-panel'; import { CompanyOverviewCard } from '@/components/analysis/company-overview-card'; @@ -55,21 +56,28 @@ function AnalysisPageContent() { setTicker(normalized); }, [searchParams]); - const loadAnalysis = useCallback(async (symbol: string) => { - const options = companyAnalysisQueryOptions(symbol); + const loadAnalysis = useCallback(async (symbol: string, options?: { refresh?: boolean }) => { + const queryOptions = companyAnalysisQueryOptions(symbol, options); - if (!queryClient.getQueryData(options.queryKey)) { + if (!queryClient.getQueryData(queryOptions.queryKey)) { setLoading(true); } setError(null); try { - const response = await queryClient.fetchQuery(options); + const response = await queryClient.fetchQuery(queryOptions); setAnalysis(response.analysis); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to load company overview'); - setAnalysis(null); + setAnalysis((current) => { + const normalizedTicker = symbol.trim().toUpperCase(); + if (options?.refresh && current?.company.ticker === normalizedTicker) { + return current; + } + + return null; + }); } finally { setLoading(false); } @@ -116,7 +124,7 @@ function AnalysisPageContent() { onRefresh={() => { const normalizedTicker = activeTicker.trim().toUpperCase(); void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) }); - void loadAnalysis(normalizedTicker); + void loadAnalysis(normalizedTicker, { refresh: true }); }} quickLinks={quickLinks} onLinkPrefetch={() => prefetchResearchTicker(activeTicker)} @@ -128,7 +136,9 @@ function AnalysisPageContent() { ) : null} - {analysis ? ( + {!analysis && loading ? ( + + ) : analysis ? ( <> + ); +} + +function SkeletonCard(props: { + title?: string; + subtitle?: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {props.children} + + ); +} + +export function CompanyAnalysisSkeleton() { + return ( + + Loading company overview + + + + + + + + + + + + + + + + + + + + + + + + {Array.from({ length: 3 }, (_, index) => ( + + + + + + ))} + + + + + + + + + + + + {Array.from({ length: 4 }, (_, index) => ( + + + + + + + ))} + + + + + + {Array.from({ length: 4 }, (_, index) => ( + + + + + + + ))} + + + + + + + {Array.from({ length: 2 }, (_, index) => ( + + + + {Array.from({ length: 3 }, (_, bulletIndex) => ( + + + + ))} + + + ))} + + + + + + + + + + {Array.from({ length: 3 }, (_, index) => ( + + ))} + + + + + + + {Array.from({ length: 4 }, (_, index) => ( + + + + + + + + ))} + + + + + ); +} diff --git a/drizzle/0012_company_overview_cache.sql b/drizzle/0012_company_overview_cache.sql new file mode 100644 index 0000000..b8d4fbb --- /dev/null +++ b/drizzle/0012_company_overview_cache.sql @@ -0,0 +1,15 @@ +CREATE TABLE `company_overview_cache` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `ticker` text NOT NULL, + `cache_version` integer NOT NULL, + `source_signature` text NOT NULL, + `payload` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `company_overview_cache_uidx` ON `company_overview_cache` (`user_id`,`ticker`); +--> statement-breakpoint +CREATE INDEX `company_overview_cache_lookup_idx` ON `company_overview_cache` (`user_id`,`ticker`,`updated_at`); diff --git a/e2e/analysis.spec.ts b/e2e/analysis.spec.ts new file mode 100644 index 0000000..bff27b5 --- /dev/null +++ b/e2e/analysis.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test'; + +const PASSWORD = 'Sup3rSecure!123'; + +function toSlug(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} + +async function signUp(page: Page, testInfo: TestInfo) { + const email = `playwright-analysis-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`; + + await page.goto('/auth/signup'); + await page.locator('input[autocomplete="name"]').fill('Playwright Analysis User'); + await page.locator('input[autocomplete="email"]').fill(email); + await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); + await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/$/, { timeout: 30_000 }); +} + +test('shows the overview skeleton while analysis is loading', async ({ page }, testInfo) => { + await signUp(page, testInfo); + + await page.route('**/api/analysis/company**', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 700)); + + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + analysis: { + company: { + ticker: 'MSFT', + companyName: 'Microsoft Corporation', + sector: 'Technology', + category: null, + tags: [], + cik: '0000789019' + }, + quote: 425.12, + position: null, + priceHistory: [ + { date: '2025-01-01T00:00:00.000Z', close: 380 }, + { date: '2026-01-01T00:00:00.000Z', close: 425.12 } + ], + benchmarkHistory: [ + { date: '2025-01-01T00:00:00.000Z', close: 5000 }, + { date: '2026-01-01T00:00:00.000Z', close: 5400 } + ], + financials: [], + filings: [], + aiReports: [], + coverage: null, + journalPreview: [], + recentAiReports: [], + latestFilingSummary: null, + keyMetrics: { + referenceDate: null, + revenue: null, + netIncome: null, + totalAssets: null, + cash: null, + debt: null, + netMargin: null + }, + companyProfile: { + description: 'Microsoft builds cloud and software products worldwide.', + exchange: 'NASDAQ', + industry: 'Software', + country: 'United States', + website: 'https://www.microsoft.com', + fiscalYearEnd: '06/30', + employeeCount: 220000, + source: 'sec_derived' + }, + valuationSnapshot: { + sharesOutstanding: 7430000000, + marketCap: 3150000000000, + enterpriseValue: 3200000000000, + trailingPe: 35, + evToRevenue: 12, + evToEbitda: null, + source: 'derived' + }, + bullBear: { + source: 'memo_fallback', + bull: ['Azure and Copilot demand remain durable.'], + bear: ['Valuation leaves less room for execution misses.'], + updatedAt: '2026-03-13T00:00:00.000Z' + }, + recentDevelopments: { + status: 'ready', + items: [{ + id: 'msft-1', + kind: '8-K', + title: 'Microsoft filed an 8-K', + url: 'https://www.sec.gov/Archives/test.htm', + source: 'SEC filings', + publishedAt: '2026-03-10', + summary: 'The company disclosed a current report with updated commercial details.', + accessionNumber: '0000000000-26-000001' + }], + weeklySnapshot: { + summary: 'The week centered on filing-driven updates.', + highlights: ['An 8-K added current commercial context.'], + itemCount: 1, + startDate: '2026-03-07', + endDate: '2026-03-13', + updatedAt: '2026-03-13T00:00:00.000Z', + source: 'heuristic' + } + } + } + }) + }); + }); + + await page.goto('/analysis?ticker=MSFT'); + + await expect(page.getByTestId('analysis-overview-skeleton')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible(); + await expect(page.getByText('Bull vs Bear')).toBeVisible(); +}); diff --git a/lib/api.ts b/lib/api.ts index 3026a0b..440b10f 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -559,10 +559,11 @@ export async function getSearchAnswer(input: { }, 'Unable to generate cited answer'); } -export async function getCompanyAnalysis(ticker: string) { +export async function getCompanyAnalysis(ticker: string, options?: { refresh?: boolean }) { const result = await client.api.analysis.company.get({ $query: { - ticker: ticker.trim().toUpperCase() + ticker: ticker.trim().toUpperCase(), + ...(options?.refresh ? { refresh: 'true' } : {}) } }); diff --git a/lib/query/options.ts b/lib/query/options.ts index ca7fad6..cf0cd49 100644 --- a/lib/query/options.ts +++ b/lib/query/options.ts @@ -27,12 +27,12 @@ import type { ResearchArtifactSource } from '@/lib/types'; -export function companyAnalysisQueryOptions(ticker: string) { +export function companyAnalysisQueryOptions(ticker: string, options?: { refresh?: boolean }) { const normalizedTicker = ticker.trim().toUpperCase(); return queryOptions({ queryKey: queryKeys.companyAnalysis(normalizedTicker), - queryFn: () => getCompanyAnalysis(normalizedTicker), + queryFn: () => getCompanyAnalysis(normalizedTicker, options), staleTime: 120_000 }); } diff --git a/lib/server/api/app.ts b/lib/server/api/app.ts index 7d13ad6..d8240c2 100644 --- a/lib/server/api/app.ts +++ b/lib/server/api/app.ts @@ -70,12 +70,6 @@ import { updateWatchlistReviewByTicker, upsertWatchlistItemRecord } from '@/lib/server/repos/watchlist'; -import { getPriceHistory, getQuote } from '@/lib/server/prices'; -import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis'; -import { getRecentDevelopments } from '@/lib/server/recent-developments'; -import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile'; -import { getCompanyDescription } from '@/lib/server/sec-description'; -import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile'; import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search'; import { enqueueTask, @@ -86,6 +80,7 @@ import { listRecentTasks, updateTaskNotification } from '@/lib/server/tasks'; +import { getCompanyAnalysisPayload } from '@/lib/server/company-analysis'; const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; const FINANCIAL_FORMS: ReadonlySet = new Set(['10-K', '10-Q']); @@ -1390,145 +1385,20 @@ export const app = new Elysia({ prefix: '/api' }) if (!ticker) { return jsonError('ticker is required'); } - - const [filings, holding, watchlistItem, liveQuote, priceHistory, benchmarkHistory, journalPreview, memo, secProfile] = await Promise.all([ - listFilingsRecords({ ticker, limit: 40 }), - getHoldingByTicker(session.user.id, ticker), - getWatchlistItemByTicker(session.user.id, ticker), - getQuote(ticker), - getPriceHistory(ticker), - getPriceHistory('^GSPC'), - listResearchJournalEntries(session.user.id, ticker, 6), - getResearchMemoByTicker(session.user.id, ticker), - getSecCompanyProfile(ticker) - ]); - const redactedFilings = filings - .map(redactInternalFilingAnalysisFields) - .map(withFinancialMetricsPolicy); - - const latestFiling = redactedFilings[0] ?? null; - const companyName = latestFiling?.company_name - ?? secProfile?.companyName - ?? holding?.company_name - ?? watchlistItem?.company_name - ?? ticker; - - const financials = redactedFilings - .filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type)) - .map((entry) => ({ - filingDate: entry.filing_date, - filingType: entry.filing_type, - revenue: entry.metrics?.revenue ?? null, - netIncome: entry.metrics?.netIncome ?? null, - totalAssets: entry.metrics?.totalAssets ?? null, - cash: entry.metrics?.cash ?? null, - debt: entry.metrics?.debt ?? null - })); - - const aiReports = redactedFilings - .filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights) - .slice(0, 8) - .map((entry) => ({ - accessionNumber: entry.accession_number, - filingDate: entry.filing_date, - filingType: entry.filing_type, - provider: entry.analysis?.provider ?? 'unknown', - model: entry.analysis?.model ?? 'unknown', - summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? '' - })); - const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null; - const referenceMetrics = latestMetricsFiling?.metrics ?? null; - const keyMetrics = { - referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null, - revenue: referenceMetrics?.revenue ?? null, - netIncome: referenceMetrics?.netIncome ?? null, - totalAssets: referenceMetrics?.totalAssets ?? null, - cash: referenceMetrics?.cash ?? null, - debt: referenceMetrics?.debt ?? null, - netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null - ? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100 - : null - }; - const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null; - const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([ - getCompanyDescription(annualFiling), - getYahooCompanyDescription(ticker), - getRecentDevelopments(ticker, { filings: redactedFilings }) - ]); - const description = yahooDescription ?? secDescription; - const latestFilingSummary = latestFiling - ? { - accessionNumber: latestFiling.accession_number, - filingDate: latestFiling.filing_date, - filingType: latestFiling.filing_type, - filingUrl: latestFiling.filing_url, - submissionUrl: latestFiling.submission_url ?? null, - summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null, - hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights) - } - : null; - const companyProfile = toCompanyProfile(secProfile, description); - const valuationSnapshot = deriveValuationSnapshot({ - quote: liveQuote, - sharesOutstanding: secProfile?.sharesOutstanding ?? null, - revenue: keyMetrics.revenue, - cash: keyMetrics.cash, - debt: keyMetrics.debt, - netIncome: keyMetrics.netIncome - }); - const synthesis = await synthesizeCompanyOverview({ + const refresh = asBoolean(query.refresh, false); + const analysis = await getCompanyAnalysisPayload({ + userId: session.user.id, ticker, - companyName, - description, - memo, - latestFilingSummary, - recentAiReports: aiReports.slice(0, 5), - recentDevelopments: synthesizedDevelopments.items + refresh }); - const recentDevelopments = { - ...synthesizedDevelopments, - weeklySnapshot: synthesis.weeklySnapshot, - status: synthesizedDevelopments.items.length > 0 - ? synthesis.weeklySnapshot ? 'ready' : 'partial' - : synthesis.weeklySnapshot ? 'partial' : 'unavailable' - } as const; return Response.json({ - analysis: { - company: { - ticker, - companyName, - sector: watchlistItem?.sector ?? null, - category: watchlistItem?.category ?? null, - tags: watchlistItem?.tags ?? [], - cik: latestFiling?.cik ?? null - }, - quote: liveQuote, - position: holding, - priceHistory, - benchmarkHistory, - financials, - filings: redactedFilings.slice(0, 20), - aiReports, - coverage: watchlistItem - ? { - ...watchlistItem, - latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null - } - : null, - journalPreview, - recentAiReports: aiReports.slice(0, 5), - latestFilingSummary, - keyMetrics, - companyProfile, - valuationSnapshot, - bullBear: synthesis.bullBear, - recentDevelopments - } + analysis }); }, { query: t.Object({ - ticker: t.String({ minLength: 1 }) + ticker: t.String({ minLength: 1 }), + refresh: t.Optional(t.String()) }) }) .get('/financials/company', async ({ query }) => { diff --git a/lib/server/api/task-workflow-hybrid.e2e.test.ts b/lib/server/api/task-workflow-hybrid.e2e.test.ts index 03fcc7d..72a2b4f 100644 --- a/lib/server/api/task-workflow-hybrid.e2e.test.ts +++ b/lib/server/api/task-workflow-hybrid.e2e.test.ts @@ -127,7 +127,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) { '0008_research_workspace.sql', '0009_task_notification_context.sql', '0010_taxonomy_surface_sidecar.sql', - '0011_remove_legacy_xbrl_defaults.sql' + '0011_remove_legacy_xbrl_defaults.sql', + '0012_company_overview_cache.sql' ]; for (const file of migrationFiles) { @@ -165,6 +166,7 @@ function clearProjectionTables(client: { exec: (query: string) => void }) { client.exec('DELETE FROM holding;'); client.exec('DELETE FROM watchlist_item;'); client.exec('DELETE FROM portfolio_insight;'); + client.exec('DELETE FROM company_overview_cache;'); client.exec('DELETE FROM filing;'); } @@ -246,6 +248,73 @@ async function jsonRequest( }; } +function buildCachedAnalysisPayload(input: { + ticker: string; + companyName: string; + bull?: string[]; +}) { + return { + company: { + ticker: input.ticker, + companyName: input.companyName, + sector: null, + category: null, + tags: [], + cik: null + }, + quote: 100, + position: null, + priceHistory: [], + benchmarkHistory: [], + financials: [], + filings: [], + aiReports: [], + coverage: null, + journalPreview: [], + recentAiReports: [], + latestFilingSummary: null, + keyMetrics: { + referenceDate: null, + revenue: null, + netIncome: null, + totalAssets: null, + cash: null, + debt: null, + netMargin: null + }, + companyProfile: { + description: null, + exchange: null, + industry: null, + country: null, + website: null, + fiscalYearEnd: null, + employeeCount: null, + source: 'unavailable' + }, + valuationSnapshot: { + sharesOutstanding: null, + marketCap: null, + enterpriseValue: null, + trailingPe: null, + evToRevenue: null, + evToEbitda: null, + source: 'unavailable' + }, + bullBear: { + source: input.bull && input.bull.length > 0 ? 'memo_fallback' : 'unavailable', + bull: input.bull ?? [], + bear: [], + updatedAt: new Date().toISOString() + }, + recentDevelopments: { + status: 'unavailable', + items: [], + weeklySnapshot: null + } + }; +} + if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { describe('task workflow hybrid migration e2e', () => { beforeAll(async () => { @@ -472,7 +541,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { ticker: 'NFLX', accessionNumber: '0000000000-26-000777', filingType: '10-K', - filingDate: '2026-02-15', + filingDate: '2026-03-10', companyName: 'Netflix, Inc.', metrics: { revenue: 41000000000, @@ -575,6 +644,157 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') { }).entries).toHaveLength(0); }); + it('serves cached analysis until refresh is requested', async () => { + if (!sqliteClient) { + throw new Error('sqlite client not initialized'); + } + + seedFilingRecord(sqliteClient, { + ticker: 'CACH', + accessionNumber: '0000000000-26-000901', + filingType: '10-K', + filingDate: '2026-02-20', + companyName: 'Live Corp' + }); + const filingRow = sqliteClient.query(` + SELECT created_at, updated_at + FROM filing + WHERE ticker = 'CACH' + ORDER BY id DESC + LIMIT 1 + `).get() as { created_at: string; updated_at: string } | null; + if (!filingRow) { + throw new Error('cached filing row not found'); + } + + const { __companyAnalysisInternals } = await import('../company-analysis'); + const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'CACH', + localInputs: { + filings: [{ + id: 1, + ticker: 'CACH', + filing_type: '10-K', + filing_date: '2026-02-20', + accession_number: '0000000000-26-000901', + cik: '0000000000', + company_name: 'Live Corp', + filing_url: 'https://www.sec.gov/Archives/0000000000-26-000901.htm', + submission_url: 'https://www.sec.gov/submissions/0000000000-26-000901.json', + primary_document: '0000000000-26-000901.htm', + metrics: null, + analysis: null, + created_at: filingRow.created_at, + updated_at: filingRow.updated_at + }], + holding: null, + watchlistItem: null, + journalPreview: [], + memo: null + } + }); + const now = new Date().toISOString(); + + sqliteClient.query(` + INSERT INTO company_overview_cache ( + user_id, + ticker, + cache_version, + source_signature, + payload, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + TEST_USER_ID, + 'CACH', + 1, + sourceSignature, + JSON.stringify(buildCachedAnalysisPayload({ + ticker: 'CACH', + companyName: 'Cached Corp' + })), + now, + now + ); + + const cached = await jsonRequest('GET', '/api/analysis/company?ticker=CACH'); + expect(cached.response.status).toBe(200); + expect((cached.json as { + analysis: { company: { companyName: string } }; + }).analysis.company.companyName).toBe('Cached Corp'); + + const refreshed = await jsonRequest('GET', '/api/analysis/company?ticker=CACH&refresh=true'); + expect(refreshed.response.status).toBe(200); + expect((refreshed.json as { + analysis: { company: { companyName: string } }; + }).analysis.company.companyName).toBe('Live Corp'); + }); + + it('invalidates cached analysis when the memo changes', async () => { + if (!sqliteClient) { + throw new Error('sqlite client not initialized'); + } + + seedFilingRecord(sqliteClient, { + ticker: 'MEMO', + accessionNumber: '0000000000-26-000902', + filingType: '10-K', + filingDate: '2026-02-20', + companyName: 'Memo Corp' + }); + + sqliteClient.query(` + INSERT INTO research_memo ( + user_id, + organization_id, + ticker, + rating, + conviction, + time_horizon_months, + packet_title, + packet_subtitle, + thesis_markdown, + variant_view_markdown, + catalysts_markdown, + risks_markdown, + disconfirming_evidence_markdown, + next_actions_markdown, + created_at, + updated_at + ) VALUES (?, NULL, ?, 'buy', 'high', 24, NULL, NULL, ?, '', '', '', '', '', ?, ?) + `).run( + TEST_USER_ID, + 'MEMO', + 'Legacy thesis still holds.', + '2026-03-13T00:00:00.000Z', + '2026-03-13T00:00:00.000Z' + ); + + const first = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO'); + expect(first.response.status).toBe(200); + expect((first.json as { + analysis: { bullBear: { bull: string[] } }; + }).analysis.bullBear.bull.join(' ')).toContain('Legacy thesis'); + + sqliteClient.query(` + UPDATE research_memo + SET thesis_markdown = ?, updated_at = ? + WHERE user_id = ? AND ticker = ? + `).run( + 'Updated thesis drives the next refresh.', + '2026-03-13T01:00:00.000Z', + TEST_USER_ID, + 'MEMO' + ); + + const second = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO'); + expect(second.response.status).toBe(200); + expect((second.json as { + analysis: { bullBear: { bull: string[] } }; + }).analysis.bullBear.bull.join(' ')).toContain('Updated thesis'); + }); + it('persists nullable holding company names and allows later enrichment', async () => { const created = await jsonRequest('POST', '/api/portfolio/holdings', { ticker: 'ORCL', diff --git a/lib/server/company-analysis.test.ts b/lib/server/company-analysis.test.ts new file mode 100644 index 0000000..3867864 --- /dev/null +++ b/lib/server/company-analysis.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, mock } from 'bun:test'; +import type { + CompanyAnalysis, + Filing, + ResearchMemo +} from '@/lib/types'; +import { + __companyAnalysisInternals, + getCompanyAnalysisPayload +} from './company-analysis'; + +function buildFiling(updatedAt = '2026-03-10T00:00:00.000Z'): Filing { + return { + id: 1, + ticker: 'MSFT', + filing_type: '10-K', + filing_date: '2026-02-01', + accession_number: '0000000000-26-000001', + cik: '0000789019', + company_name: 'Microsoft Corporation', + filing_url: 'https://www.sec.gov/Archives/test.htm', + submission_url: 'https://www.sec.gov/submissions/test.json', + primary_document: 'test.htm', + metrics: null, + analysis: null, + created_at: updatedAt, + updated_at: updatedAt + }; +} + +function buildMemo(updatedAt = '2026-03-10T00:00:00.000Z', thesis = 'Azure remains durable.'): ResearchMemo { + return { + id: 1, + user_id: 'user-1', + organization_id: null, + ticker: 'MSFT', + rating: 'buy', + conviction: 'high', + time_horizon_months: 24, + packet_title: null, + packet_subtitle: null, + thesis_markdown: thesis, + variant_view_markdown: '', + catalysts_markdown: '', + risks_markdown: '', + disconfirming_evidence_markdown: '', + next_actions_markdown: '', + created_at: updatedAt, + updated_at: updatedAt + }; +} + +function buildLocalInputs(overrides: Partial[0]['localInputs']> = {}) { + return { + filings: [buildFiling()], + holding: null, + watchlistItem: null, + journalPreview: [], + memo: null, + ...overrides + }; +} + +function buildAnalysisPayload(companyName: string): CompanyAnalysis { + return { + company: { + ticker: 'MSFT', + companyName, + sector: null, + category: null, + tags: [], + cik: null + }, + quote: 100, + position: null, + priceHistory: [], + benchmarkHistory: [], + financials: [], + filings: [], + aiReports: [], + coverage: null, + journalPreview: [], + recentAiReports: [], + latestFilingSummary: null, + keyMetrics: { + referenceDate: null, + revenue: null, + netIncome: null, + totalAssets: null, + cash: null, + debt: null, + netMargin: null + }, + companyProfile: { + description: null, + exchange: null, + industry: null, + country: null, + website: null, + fiscalYearEnd: null, + employeeCount: null, + source: 'unavailable' + }, + valuationSnapshot: { + sharesOutstanding: null, + marketCap: null, + enterpriseValue: null, + trailingPe: null, + evToRevenue: null, + evToEbitda: null, + source: 'unavailable' + }, + bullBear: { + source: 'unavailable', + bull: [], + bear: [], + updatedAt: null + }, + recentDevelopments: { + status: 'unavailable', + items: [], + weeklySnapshot: null + } + }; +} + +function buildCacheRecord(payload: CompanyAnalysis, sourceSignature: string) { + return { + id: 1, + user_id: 'user-1', + ticker: 'MSFT', + cache_version: 1, + source_signature: sourceSignature, + payload, + created_at: '2026-03-13T11:55:00.000Z', + updated_at: '2026-03-13T11:55:00.000Z' + }; +} + +describe('company analysis cache orchestration', () => { + it('changes the source signature when local inputs change', () => { + const base = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs: buildLocalInputs() + }); + const memoChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs: buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z') }) + }); + const filingChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs: buildLocalInputs({ filings: [buildFiling('2026-03-11T00:00:00.000Z')] }) + }); + const journalChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs: buildLocalInputs({ + journalPreview: [{ + id: 7, + user_id: 'user-1', + ticker: 'MSFT', + accession_number: null, + entry_type: 'note', + title: 'Updated note', + body_markdown: 'Body', + metadata: null, + created_at: '2026-03-01T00:00:00.000Z', + updated_at: '2026-03-11T00:00:00.000Z' + }] + }) + }); + + expect(memoChanged).not.toBe(base); + expect(filingChanged).not.toBe(base); + expect(journalChanged).not.toBe(base); + }); + + it('returns a fresh cached payload when signature and ttl match', async () => { + const localInputs = buildLocalInputs(); + const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs + }); + const buildOverview = mock(async () => buildAnalysisPayload('Built Corp')); + const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature)); + + const analysis = await getCompanyAnalysisPayload({ + userId: 'user-1', + ticker: 'MSFT', + now: new Date('2026-03-13T12:00:00.000Z') + }, { + getLocalInputs: async () => localInputs, + getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature), + buildOverview, + upsertCachedOverview + }); + + expect(analysis.company.companyName).toBe('Cached Corp'); + expect(buildOverview).not.toHaveBeenCalled(); + expect(upsertCachedOverview).not.toHaveBeenCalled(); + }); + + it('bypasses the cache when refresh is requested', async () => { + const localInputs = buildLocalInputs(); + const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs + }); + const buildOverview = mock(async () => buildAnalysisPayload('Refreshed Corp')); + const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Refreshed Corp'), sourceSignature)); + + const analysis = await getCompanyAnalysisPayload({ + userId: 'user-1', + ticker: 'MSFT', + refresh: true, + now: new Date('2026-03-13T12:00:00.000Z') + }, { + getLocalInputs: async () => localInputs, + getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature), + buildOverview, + upsertCachedOverview + }); + + expect(analysis.company.companyName).toBe('Refreshed Corp'); + expect(buildOverview).toHaveBeenCalledTimes(1); + expect(upsertCachedOverview).toHaveBeenCalledTimes(1); + }); + + it('rebuilds when local inputs invalidate the cached signature', async () => { + const cachedInputs = buildLocalInputs({ memo: buildMemo('2026-03-10T00:00:00.000Z', 'Old memo') }); + const freshInputs = buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z', 'New memo') }); + const staleSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({ + ticker: 'MSFT', + localInputs: cachedInputs + }); + const buildOverview = mock(async () => buildAnalysisPayload('Rebuilt Corp')); + + const analysis = await getCompanyAnalysisPayload({ + userId: 'user-1', + ticker: 'MSFT', + now: new Date('2026-03-13T12:00:00.000Z') + }, { + getLocalInputs: async () => freshInputs, + getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), staleSignature), + buildOverview, + upsertCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Rebuilt Corp'), staleSignature) + }); + + expect(analysis.company.companyName).toBe('Rebuilt Corp'); + expect(buildOverview).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/server/company-analysis.ts b/lib/server/company-analysis.ts new file mode 100644 index 0000000..cfc5556 --- /dev/null +++ b/lib/server/company-analysis.ts @@ -0,0 +1,336 @@ +import { createHash } from 'node:crypto'; +import type { + CompanyAiReport, + CompanyAnalysis, + Filing, + Holding, + ResearchJournalEntry, + ResearchMemo, + WatchlistItem +} from '@/lib/types'; +import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis'; +import { getPriceHistory, getQuote } from '@/lib/server/prices'; +import { getRecentDevelopments } from '@/lib/server/recent-developments'; +import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile'; +import { getCompanyDescription } from '@/lib/server/sec-description'; +import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile'; +import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction'; +import { listFilingsRecords } from '@/lib/server/repos/filings'; +import { getHoldingByTicker } from '@/lib/server/repos/holdings'; +import { getResearchMemoByTicker } from '@/lib/server/repos/research-library'; +import { listResearchJournalEntries } from '@/lib/server/repos/research-journal'; +import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist'; +import { + CURRENT_COMPANY_OVERVIEW_CACHE_VERSION, + getCompanyOverviewCache, + upsertCompanyOverviewCache +} from '@/lib/server/repos/company-overview-cache'; + +const FINANCIAL_FORMS = new Set(['10-K', '10-Q']); +const COMPANY_OVERVIEW_CACHE_TTL_MS = 1000 * 60 * 15; + +export type CompanyAnalysisLocalInputs = { + filings: Filing[]; + holding: Holding | null; + watchlistItem: WatchlistItem | null; + journalPreview: ResearchJournalEntry[]; + memo: ResearchMemo | null; +}; + +function withFinancialMetricsPolicy(filing: Filing): Filing { + if (FINANCIAL_FORMS.has(filing.filing_type)) { + return filing; + } + + return { + ...filing, + metrics: null + }; +} + +export function buildCompanyAnalysisSourceSignature(input: { + ticker: string; + localInputs: CompanyAnalysisLocalInputs; + cacheVersion?: number; +}) { + const payload = { + ticker: input.ticker.trim().toUpperCase(), + cacheVersion: input.cacheVersion ?? CURRENT_COMPANY_OVERVIEW_CACHE_VERSION, + filings: input.localInputs.filings.map((filing) => ({ + accessionNumber: filing.accession_number, + updatedAt: filing.updated_at + })), + memoUpdatedAt: input.localInputs.memo?.updated_at ?? null, + watchlistUpdatedAt: input.localInputs.watchlistItem?.updated_at ?? null, + holdingUpdatedAt: input.localInputs.holding?.updated_at ?? null, + journalPreview: input.localInputs.journalPreview.map((entry) => ({ + id: entry.id, + updatedAt: entry.updated_at + })) + }; + + return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +export function isCompanyOverviewCacheFresh(input: { + updatedAt: string; + sourceSignature: string; + expectedSourceSignature: string; + cacheVersion: number; + refresh?: boolean; + ttlMs?: number; + now?: Date; +}) { + if (input.refresh) { + return false; + } + + if (input.cacheVersion !== CURRENT_COMPANY_OVERVIEW_CACHE_VERSION) { + return false; + } + + if (input.sourceSignature !== input.expectedSourceSignature) { + return false; + } + + const now = input.now ?? new Date(); + const updatedAt = Date.parse(input.updatedAt); + if (!Number.isFinite(updatedAt)) { + return false; + } + + return now.getTime() - updatedAt <= (input.ttlMs ?? COMPANY_OVERVIEW_CACHE_TTL_MS); +} + +async function getCompanyAnalysisLocalInputs(input: { userId: string; ticker: string }): Promise { + const ticker = input.ticker.trim().toUpperCase(); + + const [filings, holding, watchlistItem, journalPreview, memo] = await Promise.all([ + listFilingsRecords({ ticker, limit: 40 }), + getHoldingByTicker(input.userId, ticker), + getWatchlistItemByTicker(input.userId, ticker), + listResearchJournalEntries(input.userId, ticker, 6), + getResearchMemoByTicker(input.userId, ticker) + ]); + + return { + filings, + holding, + watchlistItem, + journalPreview, + memo + }; +} + +async function buildCompanyAnalysisPayload(input: { + userId: string; + ticker: string; + localInputs: CompanyAnalysisLocalInputs; +}): Promise { + const ticker = input.ticker.trim().toUpperCase(); + const redactedFilings = input.localInputs.filings + .map(redactInternalFilingAnalysisFields) + .map(withFinancialMetricsPolicy); + + const [liveQuote, priceHistory, benchmarkHistory, secProfile] = await Promise.all([ + getQuote(ticker), + getPriceHistory(ticker), + getPriceHistory('^GSPC'), + getSecCompanyProfile(ticker) + ]); + + const latestFiling = redactedFilings[0] ?? null; + const companyName = latestFiling?.company_name + ?? secProfile?.companyName + ?? input.localInputs.holding?.company_name + ?? input.localInputs.watchlistItem?.company_name + ?? ticker; + + const financials = redactedFilings + .filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type)) + .map((entry) => ({ + filingDate: entry.filing_date, + filingType: entry.filing_type, + revenue: entry.metrics?.revenue ?? null, + netIncome: entry.metrics?.netIncome ?? null, + totalAssets: entry.metrics?.totalAssets ?? null, + cash: entry.metrics?.cash ?? null, + debt: entry.metrics?.debt ?? null + })); + + const aiReports: CompanyAiReport[] = redactedFilings + .filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights) + .slice(0, 8) + .map((entry) => ({ + accessionNumber: entry.accession_number, + filingDate: entry.filing_date, + filingType: entry.filing_type, + provider: entry.analysis?.provider ?? 'unknown', + model: entry.analysis?.model ?? 'unknown', + summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? '' + })); + + const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null; + const referenceMetrics = latestMetricsFiling?.metrics ?? null; + const keyMetrics = { + referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null, + revenue: referenceMetrics?.revenue ?? null, + netIncome: referenceMetrics?.netIncome ?? null, + totalAssets: referenceMetrics?.totalAssets ?? null, + cash: referenceMetrics?.cash ?? null, + debt: referenceMetrics?.debt ?? null, + netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null + ? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100 + : null + }; + + const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null; + const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([ + getCompanyDescription(annualFiling), + getYahooCompanyDescription(ticker), + getRecentDevelopments(ticker, { filings: redactedFilings }) + ]); + + const description = yahooDescription ?? secDescription; + const latestFilingSummary = latestFiling + ? { + accessionNumber: latestFiling.accession_number, + filingDate: latestFiling.filing_date, + filingType: latestFiling.filing_type, + filingUrl: latestFiling.filing_url, + submissionUrl: latestFiling.submission_url ?? null, + summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null, + hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights) + } + : null; + const companyProfile = toCompanyProfile(secProfile, description); + const valuationSnapshot = deriveValuationSnapshot({ + quote: liveQuote, + sharesOutstanding: secProfile?.sharesOutstanding ?? null, + revenue: keyMetrics.revenue, + cash: keyMetrics.cash, + debt: keyMetrics.debt, + netIncome: keyMetrics.netIncome + }); + const synthesis = await synthesizeCompanyOverview({ + ticker, + companyName, + description, + memo: input.localInputs.memo, + latestFilingSummary, + recentAiReports: aiReports.slice(0, 5), + recentDevelopments: synthesizedDevelopments.items + }); + const recentDevelopments = { + ...synthesizedDevelopments, + weeklySnapshot: synthesis.weeklySnapshot, + status: synthesizedDevelopments.items.length > 0 + ? synthesis.weeklySnapshot ? 'ready' : 'partial' + : synthesis.weeklySnapshot ? 'partial' : 'unavailable' + } as const; + + return { + company: { + ticker, + companyName, + sector: input.localInputs.watchlistItem?.sector ?? null, + category: input.localInputs.watchlistItem?.category ?? null, + tags: input.localInputs.watchlistItem?.tags ?? [], + cik: latestFiling?.cik ?? null + }, + quote: liveQuote, + position: input.localInputs.holding, + priceHistory, + benchmarkHistory, + financials, + filings: redactedFilings.slice(0, 20), + aiReports, + coverage: input.localInputs.watchlistItem + ? { + ...input.localInputs.watchlistItem, + latest_filing_date: latestFiling?.filing_date ?? input.localInputs.watchlistItem.latest_filing_date ?? null + } + : null, + journalPreview: input.localInputs.journalPreview, + recentAiReports: aiReports.slice(0, 5), + latestFilingSummary, + keyMetrics, + companyProfile, + valuationSnapshot, + bullBear: synthesis.bullBear, + recentDevelopments + }; +} + +type GetCompanyAnalysisPayloadOptions = { + userId: string; + ticker: string; + refresh?: boolean; + now?: Date; +}; + +type GetCompanyAnalysisPayloadDeps = { + getLocalInputs?: (input: { userId: string; ticker: string }) => Promise; + getCachedOverview?: typeof getCompanyOverviewCache; + upsertCachedOverview?: typeof upsertCompanyOverviewCache; + buildOverview?: (input: { + userId: string; + ticker: string; + localInputs: CompanyAnalysisLocalInputs; + }) => Promise; +}; + +export async function getCompanyAnalysisPayload( + input: GetCompanyAnalysisPayloadOptions, + deps?: GetCompanyAnalysisPayloadDeps +): Promise { + const ticker = input.ticker.trim().toUpperCase(); + const now = input.now ?? new Date(); + const localInputs = await (deps?.getLocalInputs ?? getCompanyAnalysisLocalInputs)({ + userId: input.userId, + ticker + }); + const sourceSignature = buildCompanyAnalysisSourceSignature({ + ticker, + localInputs + }); + const cached = await (deps?.getCachedOverview ?? getCompanyOverviewCache)({ + userId: input.userId, + ticker + }); + + if (cached && isCompanyOverviewCacheFresh({ + updatedAt: cached.updated_at, + sourceSignature: cached.source_signature, + expectedSourceSignature: sourceSignature, + cacheVersion: cached.cache_version, + refresh: input.refresh, + now + })) { + return cached.payload; + } + + const analysis = await (deps?.buildOverview ?? buildCompanyAnalysisPayload)({ + userId: input.userId, + ticker, + localInputs + }); + + await (deps?.upsertCachedOverview ?? upsertCompanyOverviewCache)({ + userId: input.userId, + ticker, + sourceSignature, + payload: analysis + }); + + return analysis; +} + +export const __companyAnalysisInternals = { + COMPANY_OVERVIEW_CACHE_TTL_MS, + buildCompanyAnalysisPayload, + buildCompanyAnalysisSourceSignature, + getCompanyAnalysisLocalInputs, + isCompanyOverviewCacheFresh, + withFinancialMetricsPolicy +}; diff --git a/lib/server/db/index.test.ts b/lib/server/db/index.test.ts index 1fdf216..1983ae8 100644 --- a/lib/server/db/index.test.ts +++ b/lib/server/db/index.test.ts @@ -47,6 +47,7 @@ describe('sqlite schema compatibility bootstrap', () => { expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true); expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true); expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true); + expect(__dbInternals.hasTable(client, 'company_overview_cache')).toBe(true); __dbInternals.loadSqliteExtensions(client); __dbInternals.ensureSearchVirtualTables(client); diff --git a/lib/server/db/index.ts b/lib/server/db/index.ts index 3a0d4c2..597ba76 100644 --- a/lib/server/db/index.ts +++ b/lib/server/db/index.ts @@ -471,6 +471,10 @@ function ensureLocalSqliteSchema(client: Database) { applySqlFile(client, '0007_company_financial_bundles.sql'); } + if (!hasTable(client, 'company_overview_cache')) { + applySqlFile(client, '0012_company_overview_cache.sql'); + } + if (!hasTable(client, 'research_journal_entry')) { client.exec(` CREATE TABLE IF NOT EXISTS \`research_journal_entry\` ( diff --git a/lib/server/db/schema.ts b/lib/server/db/schema.ts index f4a5a8b..44b4e1f 100644 --- a/lib/server/db/schema.ts +++ b/lib/server/db/schema.ts @@ -607,6 +607,20 @@ export const companyFinancialBundle = sqliteTable('company_financial_bundle', { companyFinancialBundleTickerIndex: index('company_financial_bundle_ticker_idx').on(table.ticker, table.updated_at) })); +export const companyOverviewCache = sqliteTable('company_overview_cache', { + id: integer('id').primaryKey({ autoIncrement: true }), + user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + ticker: text('ticker').notNull(), + cache_version: integer('cache_version').notNull(), + source_signature: text('source_signature').notNull(), + payload: text('payload', { mode: 'json' }).$type>().notNull(), + created_at: text('created_at').notNull(), + updated_at: text('updated_at').notNull() +}, (table) => ({ + companyOverviewCacheUnique: uniqueIndex('company_overview_cache_uidx').on(table.user_id, table.ticker), + companyOverviewCacheLookupIndex: index('company_overview_cache_lookup_idx').on(table.user_id, table.ticker, table.updated_at) +})); + export const filingLink = sqliteTable('filing_link', { id: integer('id').primaryKey({ autoIncrement: true }), filing_id: integer('filing_id').notNull().references(() => filing.id, { onDelete: 'cascade' }), @@ -831,6 +845,7 @@ export const appSchema = { filingTaxonomyFact, filingTaxonomyMetricValidation, companyFinancialBundle, + companyOverviewCache, filingLink, taskRun, taskStageEvent, diff --git a/lib/server/prices.test.ts b/lib/server/prices.test.ts new file mode 100644 index 0000000..b71582b --- /dev/null +++ b/lib/server/prices.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { __pricesInternals, getPriceHistory, getQuote } from './prices'; + +describe('price caching', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + __pricesInternals.resetCaches(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + __pricesInternals.resetCaches(); + }); + + it('reuses the cached quote within the ttl window', async () => { + const fetchMock = mock(async () => Response.json({ + chart: { + result: [ + { + meta: { + regularMarketPrice: 123.45 + } + } + ] + } + })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const first = await getQuote('MSFT'); + const second = await getQuote('MSFT'); + + expect(first).toBe(123.45); + expect(second).toBe(123.45); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('reuses cached price history within the ttl window', async () => { + const fetchMock = mock(async () => Response.json({ + chart: { + result: [ + { + timestamp: [1735689600, 1736294400], + indicators: { + quote: [ + { + close: [100, 105] + } + ] + } + } + ] + } + })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const first = await getPriceHistory('MSFT'); + const second = await getPriceHistory('MSFT'); + + expect(first).toHaveLength(2); + expect(second).toEqual(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/server/prices.ts b/lib/server/prices.ts index 6232f23..6ec1cd5 100644 --- a/lib/server/prices.ts +++ b/lib/server/prices.ts @@ -1,4 +1,14 @@ const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart'; +const QUOTE_CACHE_TTL_MS = 1000 * 60; +const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15; + +type CacheEntry = { + expiresAt: number; + value: T; +}; + +const quoteCache = new Map>(); +const priceHistoryCache = new Map>>(); function buildYahooChartUrl(ticker: string, params: string) { return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`; @@ -17,7 +27,12 @@ function fallbackQuote(ticker: string) { export async function getQuote(ticker: string): Promise { const normalizedTicker = ticker.trim().toUpperCase(); + const cached = quoteCache.get(normalizedTicker); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + let quote = fallbackQuote(normalizedTicker); try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), { headers: { @@ -27,7 +42,11 @@ export async function getQuote(ticker: string): Promise { }); if (!response.ok) { - return fallbackQuote(normalizedTicker); + quoteCache.set(normalizedTicker, { + value: quote, + expiresAt: Date.now() + QUOTE_CACHE_TTL_MS + }); + return quote; } const payload = await response.json() as { @@ -38,13 +57,23 @@ export async function getQuote(ticker: string): Promise { const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice; if (typeof price !== 'number' || !Number.isFinite(price)) { - return fallbackQuote(normalizedTicker); + quoteCache.set(normalizedTicker, { + value: quote, + expiresAt: Date.now() + QUOTE_CACHE_TTL_MS + }); + return quote; } - - return price; + quote = price; } catch { - return fallbackQuote(normalizedTicker); + // fall through to cached fallback } + + quoteCache.set(normalizedTicker, { + value: quote, + expiresAt: Date.now() + QUOTE_CACHE_TTL_MS + }); + + return quote; } export async function getQuoteOrNull(ticker: string): Promise { @@ -145,6 +174,10 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[] export async function getPriceHistory(ticker: string): Promise> { const normalizedTicker = ticker.trim().toUpperCase(); + const cached = priceHistoryCache.get(normalizedTicker); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), { @@ -190,6 +223,10 @@ export async function getPriceHistory(ticker: string): Promise entry !== null); if (points.length > 0) { + priceHistoryCache.set(normalizedTicker, { + value: points, + expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS + }); return points; } } catch { @@ -201,7 +238,7 @@ export async function getPriceHistory(ticker: string): Promise { + const syntheticHistory = Array.from({ length: totalWeeks }, (_, index) => { const step = (totalWeeks - 1) - index; const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString(); const wave = Math.sin(index / 8) * 0.06; @@ -213,4 +250,20 @@ export async function getPriceHistory(ticker: string): Promise { + beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'fiscal-overview-cache-')); + const env = process.env as Record; + env.DATABASE_URL = `file:${join(tempDir, 'repo.sqlite')}`; + env.NODE_ENV = 'test'; + + resetDbSingletons(); + + sqliteClient = new Database(join(tempDir, 'repo.sqlite'), { create: true }); + sqliteClient.exec('PRAGMA foreign_keys = ON;'); + applyMigration(sqliteClient, '0000_cold_silver_centurion.sql'); + applyMigration(sqliteClient, '0012_company_overview_cache.sql'); + ensureUser(sqliteClient); + + const globalState = globalThis as typeof globalThis & { + __fiscalSqliteClient?: Database; + __fiscalDrizzleDb?: unknown; + }; + globalState.__fiscalSqliteClient = sqliteClient; + globalState.__fiscalDrizzleDb = undefined; + + overviewCacheRepo = await import('./company-overview-cache'); + }); + + afterAll(() => { + sqliteClient?.close(); + resetDbSingletons(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + if (!sqliteClient) { + throw new Error('sqlite client not initialized'); + } + + clearCache(sqliteClient); + }); + + it('upserts and reloads cached overview payloads', async () => { + if (!overviewCacheRepo) { + throw new Error('overview cache repo not initialized'); + } + + const first = await overviewCacheRepo.upsertCompanyOverviewCache({ + userId: TEST_USER_ID, + ticker: 'msft', + sourceSignature: 'sig-1', + payload: buildAnalysisPayload('Microsoft Corporation') + }); + + const loaded = await overviewCacheRepo.getCompanyOverviewCache({ + userId: TEST_USER_ID, + ticker: 'MSFT' + }); + + expect(first.ticker).toBe('MSFT'); + expect(loaded?.source_signature).toBe('sig-1'); + expect(loaded?.payload.company.companyName).toBe('Microsoft Corporation'); + }); + + it('updates existing cached rows in place', async () => { + if (!overviewCacheRepo) { + throw new Error('overview cache repo not initialized'); + } + + const first = await overviewCacheRepo.upsertCompanyOverviewCache({ + userId: TEST_USER_ID, + ticker: 'MSFT', + sourceSignature: 'sig-1', + payload: buildAnalysisPayload('Old Name') + }); + const second = await overviewCacheRepo.upsertCompanyOverviewCache({ + userId: TEST_USER_ID, + ticker: 'MSFT', + sourceSignature: 'sig-2', + payload: buildAnalysisPayload('New Name') + }); + + const loaded = await overviewCacheRepo.getCompanyOverviewCache({ + userId: TEST_USER_ID, + ticker: 'MSFT' + }); + + expect(second.id).toBe(first.id); + expect(loaded?.source_signature).toBe('sig-2'); + expect(loaded?.payload.company.companyName).toBe('New Name'); + }); +}); diff --git a/lib/server/repos/company-overview-cache.ts b/lib/server/repos/company-overview-cache.ts new file mode 100644 index 0000000..3707d7d --- /dev/null +++ b/lib/server/repos/company-overview-cache.ts @@ -0,0 +1,102 @@ +import { and, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import type { CompanyAnalysis } from '@/lib/types'; +import { getSqliteClient } from '@/lib/server/db'; +import { companyOverviewCache, schema } from '@/lib/server/db/schema'; + +export const CURRENT_COMPANY_OVERVIEW_CACHE_VERSION = 1; + +export type CompanyOverviewCacheRecord = { + id: number; + user_id: string; + ticker: string; + cache_version: number; + source_signature: string; + payload: CompanyAnalysis; + created_at: string; + updated_at: string; +}; + +function toRecord(row: typeof companyOverviewCache.$inferSelect): CompanyOverviewCacheRecord { + return { + id: row.id, + user_id: row.user_id, + ticker: row.ticker, + cache_version: row.cache_version, + source_signature: row.source_signature, + payload: row.payload as CompanyAnalysis, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +function getDb() { + return drizzle(getSqliteClient(), { schema }); +} + +export async function getCompanyOverviewCache(input: { userId: string; ticker: string }) { + const normalizedTicker = input.ticker.trim().toUpperCase(); + if (!normalizedTicker) { + return null; + } + + const [row] = await getDb() + .select() + .from(companyOverviewCache) + .where(and( + eq(companyOverviewCache.user_id, input.userId), + eq(companyOverviewCache.ticker, normalizedTicker) + )) + .limit(1); + + return row ? toRecord(row) : null; +} + +export async function upsertCompanyOverviewCache(input: { + userId: string; + ticker: string; + sourceSignature: string; + payload: CompanyAnalysis; +}) { + const now = new Date().toISOString(); + const normalizedTicker = input.ticker.trim().toUpperCase(); + + const [saved] = await getDb() + .insert(companyOverviewCache) + .values({ + user_id: input.userId, + ticker: normalizedTicker, + cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION, + source_signature: input.sourceSignature, + payload: input.payload as unknown as Record, + created_at: now, + updated_at: now + }) + .onConflictDoUpdate({ + target: [companyOverviewCache.user_id, companyOverviewCache.ticker], + set: { + cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION, + source_signature: input.sourceSignature, + payload: input.payload as unknown as Record, + updated_at: now + } + }) + .returning(); + + return toRecord(saved); +} + +export async function deleteCompanyOverviewCache(input: { userId: string; ticker: string }) { + const normalizedTicker = input.ticker.trim().toUpperCase(); + + return await getDb() + .delete(companyOverviewCache) + .where(and( + eq(companyOverviewCache.user_id, input.userId), + eq(companyOverviewCache.ticker, normalizedTicker) + )); +} + +export const __companyOverviewCacheInternals = { + CACHE_VERSION: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION +}; diff --git a/lib/server/repos/tasks.test.ts b/lib/server/repos/tasks.test.ts index 4940512..e1c1b32 100644 --- a/lib/server/repos/tasks.test.ts +++ b/lib/server/repos/tasks.test.ts @@ -67,7 +67,8 @@ describe('task repos', () => { '0006_coverage_journal_tracking.sql', '0007_company_financial_bundles.sql', '0008_research_workspace.sql', - '0009_task_notification_context.sql' + '0009_task_notification_context.sql', + '0012_company_overview_cache.sql' ]) { applyMigration(sqliteClient, file); }