Add company overview skeleton and cache
This commit is contained in:
@@ -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() {
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{analysis ? (
|
||||
{!analysis && loading ? (
|
||||
<CompanyAnalysisSkeleton />
|
||||
) : analysis ? (
|
||||
<>
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
|
||||
<CompanyOverviewCard
|
||||
|
||||
141
components/analysis/company-analysis-skeleton.tsx
Normal file
141
components/analysis/company-analysis-skeleton.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
|
||||
function SkeletonLine(props: { className: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`rounded-full bg-[color:var(--panel-soft)] motion-safe:animate-pulse ${props.className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard(props: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Panel title={props.title} subtitle={props.subtitle} className={props.className}>
|
||||
{props.children}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyAnalysisSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" data-testid="analysis-overview-skeleton" aria-live="polite" aria-busy="true">
|
||||
<span className="sr-only">Loading company overview</span>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
|
||||
<Panel className="h-full pt-2">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<SkeletonLine className="h-8 w-3/4" />
|
||||
<SkeletonLine className="h-3 w-1/2" />
|
||||
</div>
|
||||
<div className="border-t border-[color:var(--line-weak)] py-4">
|
||||
<div className="space-y-3">
|
||||
<SkeletonLine className="h-3 w-28" />
|
||||
<SkeletonLine className="h-4 w-full" />
|
||||
<SkeletonLine className="h-4 w-[92%]" />
|
||||
<SkeletonLine className="h-4 w-[84%]" />
|
||||
<SkeletonLine className="h-4 w-[66%]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<SkeletonCard title="Price chart" className="pt-2">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<div key={index} className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r last:border-r-0">
|
||||
<SkeletonLine className="h-3 w-24" />
|
||||
<SkeletonLine className="mt-3 h-7 w-28" />
|
||||
<SkeletonLine className="mt-3 h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<SkeletonLine className="h-[288px] w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonCard>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<SkeletonCard title="Company profile facts" className="pt-2">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<div key={index} className="grid grid-cols-[18%_32%_18%_32%] gap-3 border-t border-[color:var(--line-weak)] py-2">
|
||||
<SkeletonLine className="h-3 w-16" />
|
||||
<SkeletonLine className="h-4 w-20" />
|
||||
<SkeletonLine className="h-3 w-16" />
|
||||
<SkeletonLine className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SkeletonCard>
|
||||
|
||||
<SkeletonCard title="Valuation" className="pt-2">
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<div key={index} className="grid grid-cols-[18%_32%_18%_32%] gap-3 border-t border-[color:var(--line-weak)] py-2">
|
||||
<SkeletonLine className="h-3 w-20" />
|
||||
<SkeletonLine className="h-4 w-24" />
|
||||
<SkeletonLine className="h-3 w-20" />
|
||||
<SkeletonLine className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SkeletonCard>
|
||||
</section>
|
||||
|
||||
<SkeletonCard title="Bull vs Bear" subtitle="The highest-level reasons investors may lean in or lean out right now." className="pt-2">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<section key={index} className="border-t border-[color:var(--line-weak)] pt-5">
|
||||
<SkeletonLine className="h-6 w-28" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{Array.from({ length: 3 }, (_, bulletIndex) => (
|
||||
<div key={bulletIndex} className="border-t border-[color:var(--line-weak)] pt-3">
|
||||
<SkeletonLine className="h-4 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</SkeletonCard>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(280px,0.72fr)_minmax(0,1.28fr)]">
|
||||
<SkeletonCard title="Past 7 Days" className="pt-2">
|
||||
<div className="space-y-3">
|
||||
<SkeletonLine className="h-4 w-full" />
|
||||
<SkeletonLine className="h-4 w-[88%]" />
|
||||
<div className="space-y-2 pt-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<SkeletonLine key={index} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonCard>
|
||||
|
||||
<SkeletonCard title="Recent Developments" subtitle="SEC-first event cards sourced from filings and attached analysis." className="pt-2">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<article key={index} className="border-t border-[color:var(--line-weak)] pt-4">
|
||||
<SkeletonLine className="h-3 w-28" />
|
||||
<SkeletonLine className="mt-3 h-5 w-3/4" />
|
||||
<SkeletonLine className="mt-3 h-4 w-full" />
|
||||
<SkeletonLine className="mt-2 h-4 w-[90%]" />
|
||||
<SkeletonLine className="mt-4 h-3 w-24" />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</SkeletonCard>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
drizzle/0012_company_overview_cache.sql
Normal file
15
drizzle/0012_company_overview_cache.sql
Normal file
@@ -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`);
|
||||
127
e2e/analysis.spec.ts
Normal file
127
e2e/analysis.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
@@ -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' } : {})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Filing['filing_type']> = 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 }) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
251
lib/server/company-analysis.test.ts
Normal file
251
lib/server/company-analysis.test.ts
Normal file
@@ -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<Parameters<typeof __companyAnalysisInternals.buildCompanyAnalysisSourceSignature>[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);
|
||||
});
|
||||
});
|
||||
336
lib/server/company-analysis.ts
Normal file
336
lib/server/company-analysis.ts
Normal file
@@ -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<Filing['filing_type']>(['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<CompanyAnalysisLocalInputs> {
|
||||
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<CompanyAnalysis> {
|
||||
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<CompanyAnalysisLocalInputs>;
|
||||
getCachedOverview?: typeof getCompanyOverviewCache;
|
||||
upsertCachedOverview?: typeof upsertCompanyOverviewCache;
|
||||
buildOverview?: (input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
localInputs: CompanyAnalysisLocalInputs;
|
||||
}) => Promise<CompanyAnalysis>;
|
||||
};
|
||||
|
||||
export async function getCompanyAnalysisPayload(
|
||||
input: GetCompanyAnalysisPayloadOptions,
|
||||
deps?: GetCompanyAnalysisPayloadDeps
|
||||
): Promise<CompanyAnalysis> {
|
||||
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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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\` (
|
||||
|
||||
@@ -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<Record<string, unknown>>().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,
|
||||
|
||||
64
lib/server/prices.test.ts
Normal file
64
lib/server/prices.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<T> = {
|
||||
expiresAt: number;
|
||||
value: T;
|
||||
};
|
||||
|
||||
const quoteCache = new Map<string, CacheEntry<number>>();
|
||||
const priceHistoryCache = new Map<string, CacheEntry<Array<{ date: string; close: number }>>>();
|
||||
|
||||
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<number> {
|
||||
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<number> {
|
||||
});
|
||||
|
||||
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<number> {
|
||||
|
||||
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<number | null> {
|
||||
@@ -145,6 +174,10 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[]
|
||||
|
||||
export async function getPriceHistory(ticker: string): Promise<Array<{ date: string; close: number }>> {
|
||||
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<Array<{ date: str
|
||||
.filter((entry): entry is { date: string; close: number } => 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<Array<{ date: str
|
||||
|
||||
const totalWeeks = 20 * 52;
|
||||
|
||||
return Array.from({ length: totalWeeks }, (_, index) => {
|
||||
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<Array<{ date: str
|
||||
close: Number(close.toFixed(2))
|
||||
};
|
||||
});
|
||||
|
||||
priceHistoryCache.set(normalizedTicker, {
|
||||
value: syntheticHistory,
|
||||
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
return syntheticHistory;
|
||||
}
|
||||
|
||||
export const __pricesInternals = {
|
||||
PRICE_HISTORY_CACHE_TTL_MS,
|
||||
QUOTE_CACHE_TTL_MS,
|
||||
resetCaches() {
|
||||
quoteCache.clear();
|
||||
priceHistoryCache.clear();
|
||||
}
|
||||
};
|
||||
|
||||
202
lib/server/repos/company-overview-cache.test.ts
Normal file
202
lib/server/repos/company-overview-cache.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
|
||||
const TEST_USER_ID = 'overview-cache-user';
|
||||
|
||||
let tempDir: string | null = null;
|
||||
let sqliteClient: Database | null = null;
|
||||
let overviewCacheRepo: typeof import('./company-overview-cache') | null = null;
|
||||
|
||||
function resetDbSingletons() {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
__fiscalSqliteClient?: Database;
|
||||
__fiscalDrizzleDb?: unknown;
|
||||
};
|
||||
|
||||
globalState.__fiscalSqliteClient?.close();
|
||||
globalState.__fiscalSqliteClient = undefined;
|
||||
globalState.__fiscalDrizzleDb = undefined;
|
||||
}
|
||||
|
||||
function applyMigration(client: Database, fileName: string) {
|
||||
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function ensureUser(client: Database) {
|
||||
const now = Date.now();
|
||||
client.exec(`
|
||||
INSERT OR REPLACE INTO user (id, name, email, emailVerified, image, createdAt, updatedAt, role, banned, banReason, banExpires)
|
||||
VALUES ('${TEST_USER_ID}', 'Overview Cache User', 'overview-cache@example.com', 1, NULL, ${now}, ${now}, NULL, 0, NULL, NULL);
|
||||
`);
|
||||
}
|
||||
|
||||
function clearCache(client: Database) {
|
||||
client.exec('DELETE FROM company_overview_cache;');
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('company overview cache repo', () => {
|
||||
beforeAll(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'fiscal-overview-cache-'));
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
102
lib/server/repos/company-overview-cache.ts
Normal file
102
lib/server/repos/company-overview-cache.ts
Normal file
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user