Compare commits
56 Commits
t3code/exp
...
fix/p0-fin
| Author | SHA1 | Date | |
|---|---|---|---|
| 4313058d65 | |||
| edf1cfb421 | |||
| 24aa8e33d4 | |||
| ed4420b8db | |||
| a7f7be50b4 | |||
| 7a42d73a48 | |||
| 529437c760 | |||
| 5b68333a07 | |||
| 69b45f35e3 | |||
| 0d6c684227 | |||
| 61b072d31f | |||
| ac3b036c93 | |||
| b735b864d2 | |||
| f5730597f4 | |||
| f9bf1adb07 | |||
| 72bf64aeec | |||
| 0394f4e795 | |||
| c4b3a9105f | |||
| 30977dc15f | |||
| a46777619a | |||
| b1c9c0ef08 | |||
| 61282ec380 | |||
| 17f0613c7f | |||
| 54172f9e8b | |||
| 1052bdfa85 | |||
| e5141238fb | |||
| 58bf80189d | |||
| 34fa020eca | |||
| a3d4c97f4e | |||
| 5998066524 | |||
| 01199d489a | |||
| 8a8c4f7177 | |||
| 9d926e9710 | |||
| c222179170 | |||
| 1efbffa347 | |||
| 7a7a78340f | |||
| ba385586bc | |||
| b9a1d8ba40 | |||
| b39bc9eccd | |||
| df26299bdf | |||
| 1b545cfffd | |||
| 33ce48f53c | |||
| c274f4d55b | |||
| 58061af006 | |||
| f2c25fb9c6 | |||
| fa2de3e259 | |||
| fae8c54121 | |||
| 12a9741eca | |||
| 9f972305e6 | |||
| 1a18ac825d | |||
| c3f3c3d5a9 | |||
| 2f7933f4a3 | |||
| 21246df434 | |||
| 7a70545f09 | |||
| e20aba998b | |||
| 62bacdf104 |
@@ -1,6 +1,8 @@
|
||||
# Build output and local caches
|
||||
.next
|
||||
.cache
|
||||
.swc
|
||||
.workflow-data
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
@@ -18,3 +20,13 @@ data
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitea
|
||||
|
||||
# Test and local tooling artifacts
|
||||
.playwright-cli
|
||||
e2e
|
||||
output
|
||||
|
||||
# Docs and generated metadata
|
||||
README.md
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -29,6 +29,8 @@ WORKFLOW_TARGET_WORLD=local
|
||||
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
|
||||
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
|
||||
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
|
||||
RUN_WORKFLOW_SETUP_ON_START=true
|
||||
RUN_DB_MIGRATIONS_ON_START=true
|
||||
|
||||
# Optional local-world fallback for rollback/testing
|
||||
WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
||||
|
||||
44
.gitea/workflows/taxonomy-sidecar.yml
Normal file
44
.gitea/workflows/taxonomy-sidecar.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Taxonomy Sidecar
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- codex/**
|
||||
|
||||
concurrency:
|
||||
group: taxonomy-sidecar-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
taxonomy-sidecar:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.5"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: bun x tsc --noEmit
|
||||
|
||||
- name: Rust unit tests
|
||||
run: cargo test --manifest-path rust/Cargo.toml -p fiscal-xbrl-core
|
||||
|
||||
- name: Taxonomy tests
|
||||
run: bun test lib/server/taxonomy/engine.test.ts lib/server/financial-taxonomy.test.ts
|
||||
|
||||
- name: Build Rust sidecar
|
||||
run: cargo build --manifest-path rust/Cargo.toml -p fiscal-xbrl-cli
|
||||
44
.github/workflows/taxonomy-sidecar.yml
vendored
Normal file
44
.github/workflows/taxonomy-sidecar.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Taxonomy Sidecar
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- codex/**
|
||||
|
||||
concurrency:
|
||||
group: taxonomy-sidecar-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
taxonomy-sidecar:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.5"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: bun x tsc --noEmit
|
||||
|
||||
- name: Rust unit tests
|
||||
run: cargo test --manifest-path rust/Cargo.toml -p fiscal-xbrl-core
|
||||
|
||||
- name: Taxonomy tests
|
||||
run: bun test lib/server/taxonomy/engine.test.ts lib/server/financial-taxonomy.test.ts
|
||||
|
||||
- name: Build Rust sidecar
|
||||
run: cargo build --manifest-path rust/Cargo.toml -p fiscal-xbrl-cli
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -13,6 +13,7 @@ build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
lib/generated/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -37,6 +38,9 @@ out/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Generated code
|
||||
lib/generated/
|
||||
|
||||
# Local app runtime state
|
||||
data/*.json
|
||||
data/*.sqlite
|
||||
@@ -44,6 +48,9 @@ data/*.sqlite-shm
|
||||
data/*.sqlite-wal
|
||||
.workflow-data/
|
||||
output/
|
||||
rust/target/
|
||||
rust/vendor/crabrl/.git-vendor/
|
||||
bin/fiscal-xbrl
|
||||
|
||||
# Local automation/test artifacts
|
||||
.playwright-cli/
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,7 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM oven/bun:1.3.5-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
bun install --frozen-lockfile
|
||||
|
||||
FROM rust:1.93-alpine AS rust-builder
|
||||
WORKDIR /app
|
||||
COPY rust ./rust
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/rust/target \
|
||||
cargo build --manifest-path rust/Cargo.toml --release --bin fiscal-xbrl
|
||||
|
||||
FROM deps AS builder
|
||||
ARG NEXT_PUBLIC_API_URL=
|
||||
@@ -11,8 +21,11 @@ ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
||||
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
|
||||
ENV RUN_WORKFLOW_SETUP_ON_START=true
|
||||
ENV RUN_DB_MIGRATIONS_ON_START=true
|
||||
COPY . .
|
||||
RUN mkdir -p public /app/.workflow-data && bun run build
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
mkdir -p public /app/.workflow-data && bun run build
|
||||
|
||||
FROM oven/bun:1.3.5-alpine AS runner
|
||||
WORKDIR /app
|
||||
@@ -23,23 +36,30 @@ ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV WORKFLOW_TARGET_WORLD=@workflow/world-postgres
|
||||
ENV WORKFLOW_LOCAL_DATA_DIR=/app/.workflow-data
|
||||
ENV RUN_WORKFLOW_SETUP_ON_START=true
|
||||
ENV RUN_DB_MIGRATIONS_ON_START=true
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/lib ./lib
|
||||
COPY --from=builder /app/contracts ./contracts
|
||||
COPY --from=builder /app/rust/taxonomy ./rust/taxonomy
|
||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/package.json ./package.json
|
||||
COPY --from=deps /app/bun.lock ./bun.lock
|
||||
COPY --from=rust-builder /app/rust/target/release/fiscal-xbrl ./bin/fiscal-xbrl
|
||||
|
||||
RUN mkdir -p /app/data /app/.workflow-data
|
||||
RUN mkdir -p /app/data /app/.workflow-data /app/bin /app/.cache/xbrl && chmod +x /app/bin/fiscal-xbrl
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV FISCAL_XBRL_BIN=/app/bin/fiscal-xbrl
|
||||
ENV FISCAL_XBRL_CACHE_DIR=/app/.cache/xbrl
|
||||
ENV XBRL_ENGINE_TIMEOUT_MS=45000
|
||||
|
||||
CMD ["sh", "-c", "if [ \"$WORKFLOW_TARGET_WORLD\" = \"@workflow/world-postgres\" ]; then ./node_modules/.bin/workflow-postgres-setup; fi && ./node_modules/.bin/drizzle-kit migrate --config /app/drizzle.config.ts && bun server.js"]
|
||||
CMD ["sh", "-c", "if [ ! -x \"${FISCAL_XBRL_BIN:-/app/bin/fiscal-xbrl}\" ]; then echo \"Missing Rust XBRL sidecar at ${FISCAL_XBRL_BIN:-/app/bin/fiscal-xbrl}\" >&2; exit 1; fi; bun run bootstrap:prod && bun server.js"]
|
||||
|
||||
140
E2E_UX_REPORT_2026-03-14.md
Normal file
140
E2E_UX_REPORT_2026-03-14.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# E2E and UX Report
|
||||
|
||||
Date: 2026-03-14
|
||||
Repo: `fiscal clone`
|
||||
Command run: `bun run test:e2e`
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The full Playwright suite did not pass. Result: 4 passed, 6 failed, 1 did not run.
|
||||
|
||||
The most important product issues surfaced by the run and manual pass were:
|
||||
|
||||
1. The overview workflow can land in a non-recovering loading state with no actionable guidance.
|
||||
2. Auth/session reliability appears unstable under realistic navigation and parallel activity.
|
||||
3. Filing sync creates noisy, duplicated notifications that follow the user across pages.
|
||||
4. The filings workflow communicates one filter state while showing data from multiple tickers.
|
||||
|
||||
## Automated E2E Outcome
|
||||
|
||||
Artifacts are under `output/playwright/`.
|
||||
|
||||
Failed specs:
|
||||
|
||||
- `e2e/analysis.spec.ts`: `shows the overview skeleton while analysis is loading`
|
||||
- Failure: timed out waiting for `Microsoft Corporation`.
|
||||
- Observed state: app chrome rendered, but the page remained on `Loading company overview`.
|
||||
- Artifacts:
|
||||
- `output/playwright/test-results/analysis-shows-the-overvie-6b9b1-n-while-analysis-is-loading/error-context.md`
|
||||
- `output/playwright/test-results/analysis-shows-the-overvie-6b9b1-n-while-analysis-is-loading/test-failed-1.png`
|
||||
|
||||
- `e2e/financials.spec.ts`: `renders the standardized operating expense tree and inspector details`
|
||||
- Failure: timed out waiting for `Normalization Summary`.
|
||||
- Observed state: page stayed in a loading shell in the failing run.
|
||||
- Artifacts:
|
||||
- `output/playwright/test-results/financials-renders-the-sta-7a51f--tree-and-inspector-details/error-context.md`
|
||||
- `output/playwright/test-results/financials-renders-the-sta-7a51f--tree-and-inspector-details/test-failed-1.png`
|
||||
|
||||
- `e2e/financials.spec.ts`: `shows not meaningful expense breakdown rows for bank pack filings`
|
||||
- Failure: sign-up flow never reached `Command Center`.
|
||||
- Observed state: browser ended on `Secure Sign In`.
|
||||
- Inference: auth/session creation or post-signup redirect is intermittently failing.
|
||||
- Artifacts:
|
||||
- `output/playwright/test-results/financials-shows-not-meani-c9d8d--rows-for-bank-pack-filings/error-context.md`
|
||||
- `output/playwright/test-results/financials-shows-not-meani-c9d8d--rows-for-bank-pack-filings/test-failed-1.png`
|
||||
|
||||
- `e2e/graphing.spec.ts`: `supports graphing compare controls and partial failures`
|
||||
- Failure: sign-up flow never reached `Command Center`.
|
||||
- Observed state: browser ended on `Secure Sign In`.
|
||||
|
||||
- `e2e/graphing.spec.ts`: `distinguishes not meaningful metrics from missing data in the latest values table`
|
||||
- Failure: sign-up flow never reached `Command Center`.
|
||||
- Observed state: browser ended on `Secure Sign In`.
|
||||
|
||||
- `e2e/research-mvp.spec.ts`: `supports the core coverage-to-research workflow`
|
||||
- Failure: strict-mode locator conflict on `NVDA status`.
|
||||
- Observed state: the page exposed two matching controls for the same accessible name.
|
||||
- Inference: duplicated DOM or duplicated accessible controls in the coverage row.
|
||||
- Artifacts:
|
||||
- `output/playwright/test-results/research-mvp-supports-the-core-coverage-to-research-workflow/error-context.md`
|
||||
- `output/playwright/test-results/research-mvp-supports-the-core-coverage-to-research-workflow/test-failed-1.png`
|
||||
|
||||
Runtime notes from the run:
|
||||
|
||||
- `sqlite-vec` fell back to table-backed storage repeatedly.
|
||||
- workflow-local reported `concurrency limit (1) reached`.
|
||||
- Live server logs repeatedly failed background filing workflows with:
|
||||
- `SQLiteError: table filing_taxonomy_snapshot has no column named parser_engine`
|
||||
- Inference: the local/e2e database schema is behind the code path used by filing taxonomy persistence.
|
||||
- The suite completed in about 1.2 minutes before failing.
|
||||
|
||||
## Manual Workflow Findings
|
||||
|
||||
### Critical
|
||||
|
||||
- Overview page does not distinguish loading vs failure vs empty data.
|
||||
- Visiting `/analysis` rendered the full page shell but stayed on `Loading company overview`.
|
||||
- The user gets no timeout, error explanation, retry guidance, or indication that background filing tasks failed.
|
||||
- The local server logs showed repeated filing workflow failures caused by a schema mismatch on `filing_taxonomy_snapshot.parser_engine`, which likely contributes directly to this broken state.
|
||||
- This is a broken primary workflow, not just a cosmetic state issue.
|
||||
|
||||
- Auth confidence is too low.
|
||||
- Three failed specs fell back to `Secure Sign In` immediately after supposed account creation.
|
||||
- A user-facing version of this would feel like silent account creation failure or dropped session state.
|
||||
|
||||
- Filings filter messaging is misleading.
|
||||
- The page header said `100 records loaded for NVDA`, but the ledger visibly contained both `NVDA` and `MSFT` rows.
|
||||
- This breaks user trust because the UI claims a scoped result set while showing cross-ticker data.
|
||||
|
||||
### High
|
||||
|
||||
- Notification spam degrades every workflow.
|
||||
- Coverage creation and navigation produced stacked `Filing sync` toasts.
|
||||
- Multiple toasts showed nearly identical states and remained visible across pages.
|
||||
- Because failed background jobs keep retrying or re-reporting progress, the user sees noise instead of one durable status surface.
|
||||
- The notifications panel becomes ambient noise instead of actionable feedback.
|
||||
|
||||
- Coverage actions can trigger expensive side effects without clear consent.
|
||||
- Adding one coverage record immediately started sync-related background activity.
|
||||
- There is no up-front explanation of what will be fetched, how long it may take, or whether it can be deferred.
|
||||
|
||||
- Research workspace has high cognitive load on first entry.
|
||||
- Filters, note capture, uploads, memo editing, and packet review all appear at once.
|
||||
- The page is powerful, but the first-time path is unclear and there is no obvious recommended sequence.
|
||||
|
||||
### Medium
|
||||
|
||||
- Empty states on Financials and Graphing are under-instructive.
|
||||
- Financials: `No rows available`, `No trend data`, and zeroed normalization metrics do not tell the user whether they need to sync, change ticker, or wait.
|
||||
- Graphing: `No chart data available` appears after opening a prefilled compare set, which makes the default state feel broken.
|
||||
|
||||
- Upload interaction in Research is confusing.
|
||||
- The page exposes two controls with the label `Upload file`, one active and one disabled.
|
||||
- That is confusing visually and poor for keyboard/screen-reader users.
|
||||
|
||||
- Navigation context is inconsistent.
|
||||
- Some links carry ticker context and others drop back to untickered routes such as `/analysis`.
|
||||
- The app often compensates by defaulting to `MSFT`, but that is implicit and can feel arbitrary.
|
||||
|
||||
- Filings table density is too high by default.
|
||||
- Loading 100 records into a single ledger creates a long scan path before the user can find the next action.
|
||||
- Pagination, grouped sections, or stronger filter summaries would reduce cognitive load.
|
||||
|
||||
### Low
|
||||
|
||||
- `favicon.ico` returns 404.
|
||||
- The boot splash and auth handoff add a short delay before the real screen appears, but do not communicate whether the app is loading, redirecting, or checking session state.
|
||||
|
||||
## Suggested Fix Order
|
||||
|
||||
1. Stabilize post-signup session creation and redirect behavior.
|
||||
2. Make `/analysis` fail loudly and recoverably instead of hanging in a loader.
|
||||
3. Fix filings filter truthfulness so the page never claims `NVDA` while rendering mixed results.
|
||||
4. Deduplicate or batch filing sync notifications.
|
||||
5. Add next-step empty states to Financials and Graphing.
|
||||
6. Reduce first-run complexity in Research with a guided progression or collapsed sections.
|
||||
7. Remove duplicate accessible controls and labels in Coverage and Research upload flows.
|
||||
|
||||
## Notes
|
||||
|
||||
The manual pass used the repo's e2e server entrypoint: `bun run e2e:webserver`.
|
||||
13
README.md
13
README.md
@@ -81,10 +81,16 @@ On container startup, the app applies Drizzle migrations automatically before la
|
||||
The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow world data in Postgres volume `workflow_postgres_data`.
|
||||
Container startup runs:
|
||||
1. `workflow-postgres-setup` (idempotent Workflow world bootstrap)
|
||||
2. Drizzle migrations for SQLite app tables
|
||||
2. Programmatic Drizzle migrations for SQLite app tables
|
||||
3. Next.js server boot
|
||||
|
||||
Docker images use Bun (`oven/bun:1.3.5-alpine`) for build and runtime.
|
||||
Docker builds use BuildKit cache mounts for Bun downloads and `.next/cache`, so repeated server-side builds can reuse dependency and Next/Turbopack caches on the same builder.
|
||||
|
||||
Optional runtime toggles:
|
||||
|
||||
- `RUN_WORKFLOW_SETUP_ON_START=true` keeps `workflow-postgres-setup` enabled at container boot.
|
||||
- `RUN_DB_MIGRATIONS_ON_START=true` keeps SQLite migrations enabled at container boot.
|
||||
|
||||
## Coolify deployment
|
||||
|
||||
@@ -107,6 +113,9 @@ Operational constraints for Coolify:
|
||||
- Ensure both named volumes are persisted (`fiscal_sqlite_data`, `workflow_postgres_data`).
|
||||
- Keep `WORKFLOW_POSTGRES_URL` explicit so Workflow does not fall back to `DATABASE_URL` (SQLite).
|
||||
- The app `/api/health` probes Workflow backend connectivity and returns non-200 when Workflow world is unavailable.
|
||||
- Keep `Include Source Commit in Build` disabled so Docker layer cache stays reusable between commits.
|
||||
- Keep Docker cleanup threshold-based rather than aggressive, otherwise Coolify will discard build cache.
|
||||
- Keep repeated builds pinned to the same builder/server when possible so Docker layer cache and BuildKit cache mounts remain warm.
|
||||
|
||||
Emergency rollback path:
|
||||
|
||||
@@ -140,6 +149,8 @@ WORKFLOW_TARGET_WORLD=local
|
||||
WORKFLOW_POSTGRES_URL=postgres://workflow:workflow@workflow-postgres:5432/workflow
|
||||
WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10
|
||||
WORKFLOW_POSTGRES_JOB_PREFIX=fiscal_
|
||||
RUN_WORKFLOW_SETUP_ON_START=true
|
||||
RUN_DB_MIGRATIONS_ON_START=true
|
||||
|
||||
# Optional local-world fallback
|
||||
WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
||||
|
||||
24
agents.md
Normal file
24
agents.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Task Completion Requirements
|
||||
|
||||
- All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed.
|
||||
- NEVER run `bun test`. Always use `bun run test` (runs Vitest).
|
||||
|
||||
## Project Snapshot
|
||||
|
||||
Neon Code is a comprehensive portoflio management and equity research platform. It helps analysts manage their portfolio, organize research and view individual stocks.
|
||||
|
||||
This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
|
||||
|
||||
## Core Priorities
|
||||
|
||||
1. Performance first.
|
||||
2. Reliability first.
|
||||
3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
|
||||
|
||||
If a tradeoff is required, choose correctness and robustness over short-term convenience.
|
||||
|
||||
## Maintainability
|
||||
|
||||
Long term maintainability is a core priority. If you add new functionality, first check if there are shared logic that can be extracted to a separate module. Duplicate logic across mulitple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.
|
||||
@@ -1,155 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import {
|
||||
BrainCircuit,
|
||||
ChartNoAxesCombined,
|
||||
NotebookPen,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
SquarePen,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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';
|
||||
import { CompanyProfileFactsTable } from '@/components/analysis/company-profile-facts-table';
|
||||
import { PriceHistoryCard } from '@/components/analysis/price-history-card';
|
||||
import { RecentDevelopmentsSection } from '@/components/analysis/recent-developments-section';
|
||||
import { ValuationFactsTable } from '@/components/analysis/valuation-facts-table';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
createResearchJournalEntry,
|
||||
deleteResearchJournalEntry,
|
||||
updateResearchJournalEntry
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
asNumber,
|
||||
formatCurrency,
|
||||
formatCurrencyByScale,
|
||||
formatPercent,
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
researchJournalQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type {
|
||||
CompanyAnalysis,
|
||||
ResearchJournalEntry
|
||||
} from '@/lib/types';
|
||||
|
||||
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
|
||||
|
||||
type FinancialSeriesPoint = {
|
||||
filingDate: string;
|
||||
filingType: '10-K' | '10-Q';
|
||||
periodLabel: 'Quarter End' | 'Fiscal Year End';
|
||||
revenue: number | null;
|
||||
netIncome: number | null;
|
||||
assets: number | null;
|
||||
netMargin: number | null;
|
||||
};
|
||||
|
||||
type JournalFormState = {
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
accessionNumber: string;
|
||||
};
|
||||
|
||||
const EMPTY_JOURNAL_FORM: JournalFormState = {
|
||||
title: '',
|
||||
bodyMarkdown: '',
|
||||
accessionNumber: ''
|
||||
};
|
||||
|
||||
const FINANCIAL_PERIOD_FILTER_OPTIONS: Array<{ value: FinancialPeriodFilter; label: string }> = [
|
||||
{ value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' },
|
||||
{ value: 'quarterlyOnly', label: 'Quarterly only' },
|
||||
{ value: 'fiscalYearEndOnly', label: 'Fiscal Year End only' }
|
||||
];
|
||||
|
||||
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||
{ value: 'thousands', label: 'Thousands (K)' },
|
||||
{ value: 'millions', label: 'Millions (M)' },
|
||||
{ value: 'billions', label: 'Billions (B)' }
|
||||
];
|
||||
|
||||
const CHART_TEXT = '#e8fff8';
|
||||
const CHART_MUTED = '#b4ced9';
|
||||
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
|
||||
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
|
||||
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
return format(new Date(value), 'MMM yyyy');
|
||||
}
|
||||
|
||||
function formatLongDate(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy · HH:mm');
|
||||
}
|
||||
|
||||
function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
if (numerator === null || denominator === null || denominator === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (numerator / denominator) * 100;
|
||||
}
|
||||
import { companyAnalysisQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
|
||||
function normalizeTickerInput(value: string | null) {
|
||||
const normalized = value?.trim().toUpperCase() ?? '';
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isFinancialSnapshotForm(
|
||||
filingType: CompanyAnalysis['filings'][number]['filing_type']
|
||||
): filingType is '10-K' | '10-Q' {
|
||||
return filingType === '10-K' || filingType === '10-Q';
|
||||
}
|
||||
|
||||
function includesFinancialPeriod(filingType: '10-K' | '10-Q', filter: FinancialPeriodFilter) {
|
||||
if (filter === 'quarterlyOnly') {
|
||||
return filingType === '10-Q';
|
||||
}
|
||||
|
||||
if (filter === 'fiscalYearEndOnly') {
|
||||
return filingType === '10-K';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function asScaledFinancialCurrency(
|
||||
value: number | null | undefined,
|
||||
scale: NumberScaleUnit
|
||||
) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return formatCurrencyByScale(value, scale);
|
||||
}
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading analysis desk...</div>}>
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading overview...</div>}>
|
||||
<AnalysisPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -159,20 +37,14 @@ function AnalysisPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? 'MSFT';
|
||||
|
||||
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||
const [ticker, setTicker] = useState(initialTicker);
|
||||
const [analysis, setAnalysis] = useState<CompanyAnalysis | null>(null);
|
||||
const [journalEntries, setJournalEntries] = useState<ResearchJournalEntry[]>([]);
|
||||
const [journalLoading, setJournalLoading] = useState(true);
|
||||
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
||||
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||
@@ -184,233 +56,79 @@ 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 analysis');
|
||||
setAnalysis(null);
|
||||
setError(err instanceof Error ? err.message : 'Unable to load company overview');
|
||||
setAnalysis((current) => {
|
||||
const normalizedTicker = symbol.trim().toUpperCase();
|
||||
if (options?.refresh && current?.company.ticker === normalizedTicker) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const loadJournal = useCallback(async (symbol: string) => {
|
||||
const options = researchJournalQueryOptions(symbol);
|
||||
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setJournalLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setJournalEntries(response.entries);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load research journal');
|
||||
setJournalEntries([]);
|
||||
} finally {
|
||||
setJournalLoading(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void Promise.all([
|
||||
loadAnalysis(ticker),
|
||||
loadJournal(ticker)
|
||||
]);
|
||||
void loadAnalysis(ticker);
|
||||
}
|
||||
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||
...point,
|
||||
label: formatShortDate(point.date)
|
||||
}));
|
||||
}, [analysis?.priceHistory]);
|
||||
|
||||
const financialSeries = useMemo<FinancialSeriesPoint[]>(() => {
|
||||
return (analysis?.financials ?? [])
|
||||
.filter((item): item is CompanyAnalysis['financials'][number] & { filingType: '10-K' | '10-Q' } => {
|
||||
return isFinancialSnapshotForm(item.filingType);
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate))
|
||||
.map((item) => ({
|
||||
filingDate: item.filingDate,
|
||||
filingType: item.filingType,
|
||||
periodLabel: item.filingType === '10-Q' ? 'Quarter End' : 'Fiscal Year End',
|
||||
revenue: item.revenue,
|
||||
netIncome: item.netIncome,
|
||||
assets: item.totalAssets,
|
||||
netMargin: ratioPercent(item.netIncome ?? null, item.revenue ?? null)
|
||||
}));
|
||||
}, [analysis?.financials]);
|
||||
|
||||
const filteredFinancialSeries = useMemo(() => {
|
||||
return financialSeries.filter((point) => includesFinancialPeriod(point.filingType, financialPeriodFilter));
|
||||
}, [financialSeries, financialPeriodFilter]);
|
||||
|
||||
const periodEndFilings = useMemo(() => {
|
||||
return (analysis?.filings ?? []).filter((filing) => isFinancialSnapshotForm(filing.filing_type));
|
||||
}, [analysis?.filings]);
|
||||
|
||||
const selectedFinancialScaleLabel = useMemo(() => {
|
||||
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
|
||||
}, [financialValueScale]);
|
||||
|
||||
const resetJournalForm = useCallback(() => {
|
||||
setEditingJournalId(null);
|
||||
setJournalForm(EMPTY_JOURNAL_FORM);
|
||||
}, []);
|
||||
}, [isPending, isAuthenticated, loadAnalysis, ticker]);
|
||||
|
||||
const activeTicker = analysis?.company.ticker ?? ticker;
|
||||
|
||||
const saveJournalEntry = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingJournalId === null) {
|
||||
await createResearchJournalEntry({
|
||||
ticker: normalizedTicker,
|
||||
entryType: journalForm.accessionNumber.trim() ? 'filing_note' : 'note',
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown,
|
||||
accessionNumber: journalForm.accessionNumber.trim() || undefined
|
||||
});
|
||||
} else {
|
||||
await updateResearchJournalEntry(editingJournalId, {
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
await Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
resetJournalForm();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
const beginEditJournalEntry = (entry: ResearchJournalEntry) => {
|
||||
setEditingJournalId(entry.id);
|
||||
setJournalForm({
|
||||
title: entry.title ?? '',
|
||||
bodyMarkdown: entry.body_markdown,
|
||||
accessionNumber: entry.accession_number ?? ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeJournalEntry = async (entry: ResearchJournalEntry) => {
|
||||
try {
|
||||
await deleteResearchJournalEntry(entry.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(entry.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(entry.ticker) });
|
||||
await Promise.all([
|
||||
loadAnalysis(entry.ticker),
|
||||
loadJournal(entry.ticker)
|
||||
]);
|
||||
if (editingJournalId === entry.id) {
|
||||
resetJournalForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to delete journal entry');
|
||||
}
|
||||
};
|
||||
const quickLinks = useMemo(() => ({
|
||||
research: `/research?ticker=${encodeURIComponent(activeTicker)}`,
|
||||
filings: `/filings?ticker=${encodeURIComponent(activeTicker)}`,
|
||||
financials: `/financials?ticker=${encodeURIComponent(activeTicker)}`,
|
||||
graphing: buildGraphingHref(activeTicker)
|
||||
}), [activeTicker]);
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading analysis desk...</div>;
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading overview...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Company Analysis"
|
||||
subtitle="Research a single ticker across pricing, 10-K/10-Q financials, qualitative filings, and generated AI reports."
|
||||
activeTicker={analysis?.company.ticker ?? ticker}
|
||||
actions={(
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
title="Company Overview"
|
||||
subtitle="A summary-first view of price, business context, valuation, recent developments, and key debate points."
|
||||
activeTicker={activeTicker}
|
||||
actions={null}
|
||||
>
|
||||
<Panel title="Search Company" subtitle="Enter a ticker symbol to load the latest analysis view.">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const normalized = tickerInput.trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
setTicker(normalized);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={tickerInput}
|
||||
aria-label="Analysis ticker"
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Search className="size-4" />
|
||||
Analyze
|
||||
</Button>
|
||||
{analysis ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/financials?ticker=${analysis.company.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${analysis.company.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</Panel>
|
||||
<AnalysisToolbar
|
||||
tickerInput={tickerInput}
|
||||
currentTicker={activeTicker}
|
||||
onTickerInputChange={setTickerInput}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const normalized = tickerInput.trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTicker(normalized);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void loadAnalysis(normalizedTicker, { refresh: true });
|
||||
}}
|
||||
quickLinks={quickLinks}
|
||||
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Panel>
|
||||
@@ -418,462 +136,41 @@ function AnalysisPageContent() {
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Panel title="Company">
|
||||
<p className="text-xl font-semibold text-[color:var(--terminal-bright)]">{analysis?.company.companyName ?? ticker}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{analysis?.company.ticker ?? ticker}</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{analysis?.company.sector ?? 'Sector unavailable'}</p>
|
||||
{analysis?.company.category ? (
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{analysis.company.category}</p>
|
||||
) : null}
|
||||
{analysis?.company.tags.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{analysis.company.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded border border-[color:var(--line-weak)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!analysis && loading ? (
|
||||
<CompanyAnalysisSkeleton />
|
||||
) : analysis ? (
|
||||
<>
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
|
||||
<CompanyOverviewCard
|
||||
analysis={analysis}
|
||||
/>
|
||||
<PriceHistoryCard
|
||||
loading={loading}
|
||||
priceHistory={analysis.priceHistory}
|
||||
benchmarkHistory={analysis.benchmarkHistory}
|
||||
quote={analysis.quote}
|
||||
position={analysis.position}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<CompanyProfileFactsTable analysis={analysis} />
|
||||
<ValuationFactsTable analysis={analysis} />
|
||||
</section>
|
||||
|
||||
<BullBearPanel
|
||||
bullBear={analysis.bullBear}
|
||||
researchHref={quickLinks.research}
|
||||
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
|
||||
/>
|
||||
|
||||
<RecentDevelopmentsSection recentDevelopments={analysis.recentDevelopments} />
|
||||
</>
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No overview is available for the selected ticker.</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Live Price">
|
||||
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(analysis?.quote ?? 0)}</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">CIK {analysis?.company.cik ?? 'n/a'}</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Position Value">
|
||||
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(analysis?.position?.market_value)}</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{analysis?.position ? `${asNumber(analysis.position.shares).toLocaleString()} shares` : 'Not held in portfolio'}</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Position P&L">
|
||||
<p className={`text-3xl font-semibold ${asNumber(analysis?.position?.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
|
||||
{formatCurrency(analysis?.position?.gain_loss)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{formatPercent(analysis?.position?.gain_loss_pct)}</p>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Panel title="Coverage Workflow" subtitle="Coverage metadata shared with the Coverage page.">
|
||||
{analysis?.coverage ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.status}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Priority</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.priority}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Last Reviewed</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.last_reviewed_at ? formatDateTime(analysis.coverage.last_reviewed_at) : 'No research review recorded yet'}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Latest Filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.latest_filing_date ? formatLongDate(analysis.coverage.latest_filing_date) : 'No filing history loaded yet'}</p>
|
||||
</div>
|
||||
<Link href="/watchlist" className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
Manage coverage
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">This company is not yet in your coverage list. Add it from Coverage to track workflow status and review cadence.</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Key Metrics" subtitle="Latest filing-level metrics used to anchor research.">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Revenue</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.revenue, financialValueScale)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Net Income</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.netIncome, financialValueScale)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Assets</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.totalAssets, financialValueScale)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Cash / Debt</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">
|
||||
{analysis?.keyMetrics.cash != null && analysis?.keyMetrics.debt != null
|
||||
? `${asScaledFinancialCurrency(analysis.keyMetrics.cash, financialValueScale)} / ${asScaledFinancialCurrency(analysis.keyMetrics.debt, financialValueScale)}`
|
||||
: 'n/a'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[color:var(--terminal-muted)]">
|
||||
Reference date: {analysis?.keyMetrics.referenceDate ? formatLongDate(analysis.keyMetrics.referenceDate) : 'No financial filing selected'}.
|
||||
{' '}Net margin: {analysis && analysis.keyMetrics.netMargin !== null ? formatPercent(analysis.keyMetrics.netMargin) : 'n/a'}.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Latest Filing Snapshot" subtitle="Most recent filing and whether an AI memo already exists.">
|
||||
{analysis?.latestFilingSummary ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">
|
||||
{analysis.latestFilingSummary.filingType} · {formatLongDate(analysis.latestFilingSummary.filingDate)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[color:var(--terminal-bright)]">
|
||||
{analysis.latestFilingSummary.summary ?? 'No AI summary stored yet for the most recent filing.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analysis.latestFilingSummary.hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(analysis.latestFilingSummary.accessionNumber)}`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open latest memo
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href={`/filings?ticker=${analysis.company.ticker}`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No filing snapshot available yet for this company.</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<Panel title="Price History" subtitle="Weekly close over the last year.">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading price history...</p>
|
||||
) : priceSeries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No price history available.</p>
|
||||
) : (
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={priceSeries}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
minTickGap={32}
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
axisLine={{ stroke: CHART_MUTED }}
|
||||
tickLine={{ stroke: CHART_MUTED }}
|
||||
tick={{ fill: CHART_MUTED }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
axisLine={{ stroke: CHART_MUTED }}
|
||||
tickLine={{ stroke: CHART_MUTED }}
|
||||
tick={{ fill: CHART_MUTED }}
|
||||
tickFormatter={(value: number) => `$${value.toFixed(0)}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '0.75rem'
|
||||
}}
|
||||
labelStyle={{ color: CHART_TEXT }}
|
||||
itemStyle={{ color: CHART_TEXT }}
|
||||
cursor={{ stroke: 'rgba(104, 255, 213, 0.35)', strokeWidth: 1 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="close" stroke="#68ffd5" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Financial Table"
|
||||
subtitle={`Quarter-end (10-Q) and fiscal-year-end (10-K) snapshots for revenue, net income, assets, and margin. Values shown in ${selectedFinancialScaleLabel}.`}
|
||||
actions={(
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{FINANCIAL_PERIOD_FILTER_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={option.value === financialPeriodFilter ? 'primary' : 'ghost'}
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => setFinancialPeriodFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={option.value === financialValueScale ? 'primary' : 'ghost'}
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => setFinancialValueScale(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading financials...</p>
|
||||
) : filteredFinancialSeries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No financial rows match the selected period filter.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table min-w-[820px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filed</th>
|
||||
<th>Period</th>
|
||||
<th>Form</th>
|
||||
<th>Revenue</th>
|
||||
<th>Net Income</th>
|
||||
<th>Assets</th>
|
||||
<th>Net Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFinancialSeries.map((point, index) => (
|
||||
<tr key={`${point.filingDate}-${point.filingType}-${index}`}>
|
||||
<td>{formatLongDate(point.filingDate)}</td>
|
||||
<td>{point.periodLabel}</td>
|
||||
<td>{point.filingType}</td>
|
||||
<td>{asScaledFinancialCurrency(point.revenue, financialValueScale)}</td>
|
||||
<td className={(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}>
|
||||
{asScaledFinancialCurrency(point.netIncome, financialValueScale)}
|
||||
</td>
|
||||
<td>{asScaledFinancialCurrency(point.assets, financialValueScale)}</td>
|
||||
<td>{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Filings"
|
||||
subtitle={`${periodEndFilings.length} quarter-end and fiscal-year-end SEC records loaded. Values shown in ${selectedFinancialScaleLabel}.`}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading filings...</p>
|
||||
) : periodEndFilings.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No 10-K or 10-Q filings available for this ticker.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table min-w-[860px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filed</th>
|
||||
<th>Period</th>
|
||||
<th>Type</th>
|
||||
<th>Revenue</th>
|
||||
<th>Net Income</th>
|
||||
<th>Assets</th>
|
||||
<th>Document</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{periodEndFilings.map((filing) => (
|
||||
<tr key={filing.accession_number}>
|
||||
<td>{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</td>
|
||||
<td>{filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</td>
|
||||
<td>{filing.filing_type}</td>
|
||||
<td>{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</td>
|
||||
<td>{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</td>
|
||||
<td>{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</td>
|
||||
<td>
|
||||
{filing.filing_url ? (
|
||||
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
SEC filing
|
||||
</a>
|
||||
) : (
|
||||
'n/a'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="AI Reports" subtitle="Generated filing analyses for this company.">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading AI reports...</p>
|
||||
) : !analysis || analysis.recentAiReports.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No AI reports generated yet. Run filing analysis from the filings stream.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{analysis.recentAiReports.map((report) => (
|
||||
<article key={report.accessionNumber} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{report.filingType} · {format(new Date(report.filingDate), 'MMM dd, yyyy')}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{report.provider} / {report.model}</h4>
|
||||
</div>
|
||||
<BrainCircuit className="size-4 text-[color:var(--accent)]" />
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-6 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{report.summary}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">{report.accessionNumber}</p>
|
||||
<Link
|
||||
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(report.accessionNumber)}`}
|
||||
onMouseEnter={() => prefetchReport(analysis.company.ticker, report.accessionNumber)}
|
||||
onFocus={() => prefetchReport(analysis.company.ticker, report.accessionNumber)}
|
||||
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open summary
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_1.3fr]">
|
||||
<Panel
|
||||
title={editingJournalId === null ? 'Research Journal' : 'Edit Journal Entry'}
|
||||
subtitle="Private markdown notes for this company. Linked filing notes update the coverage review timestamp."
|
||||
>
|
||||
<form onSubmit={saveJournalEntry} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Title</label>
|
||||
<Input
|
||||
value={journalForm.title}
|
||||
aria-label="Journal title"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
placeholder="Investment thesis checkpoint, risk note, follow-up..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Linked Filing (optional)</label>
|
||||
<Input
|
||||
value={journalForm.accessionNumber}
|
||||
aria-label="Journal linked filing"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))}
|
||||
placeholder="0000000000-26-000001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Body</label>
|
||||
<textarea
|
||||
value={journalForm.bodyMarkdown}
|
||||
aria-label="Journal body"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, bodyMarkdown: event.target.value }))}
|
||||
placeholder="Write your thesis update, questions, risks, and next steps..."
|
||||
className="min-h-[220px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<NotebookPen className="size-4" />
|
||||
{editingJournalId === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{editingJournalId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetJournalForm}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Journal Timeline" subtitle={`${journalEntries.length} stored entries for ${activeTicker}.`}>
|
||||
{journalLoading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading journal entries...</p>
|
||||
) : journalEntries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No journal notes yet. Start a thesis log or attach a filing note from the filings stream.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{journalEntries.map((entry) => {
|
||||
const canEdit = entry.entry_type !== 'status_change';
|
||||
|
||||
return (
|
||||
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
||||
{entry.title ?? 'Untitled entry'}
|
||||
</h4>
|
||||
</div>
|
||||
{entry.accession_number ? (
|
||||
<Link
|
||||
href={`/filings?ticker=${activeTicker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
|
||||
onFocus={() => prefetchResearchTicker(activeTicker)}
|
||||
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
|
||||
{canEdit ? (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEditJournalEntry(entry)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void removeJournalEntry(entry);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||
<ChartNoAxesCombined className="size-4" />
|
||||
Analysis scope: price + filings + ai synthesis + research journal
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { aiReportQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAiReportDetail } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { createResearchJournalEntry } from '@/lib/api';
|
||||
import { createResearchArtifact } from '@/lib/api';
|
||||
|
||||
function formatFilingDate(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -87,6 +87,7 @@ export default function AnalysisReportPage() {
|
||||
|
||||
const resolvedTicker = report?.ticker ?? tickerFromRoute;
|
||||
const analysisHref = resolvedTicker ? `/analysis?ticker=${encodeURIComponent(resolvedTicker)}` : '/analysis';
|
||||
const researchHref = resolvedTicker ? `/research?ticker=${encodeURIComponent(resolvedTicker)}` : '/research';
|
||||
const filingsHref = resolvedTicker ? `/filings?ticker=${encodeURIComponent(resolvedTicker)}` : '/filings';
|
||||
|
||||
return (
|
||||
@@ -95,7 +96,7 @@ export default function AnalysisReportPage() {
|
||||
subtitle={`Detailed filing analysis${resolvedTicker ? ` for ${resolvedTicker}` : ''}.`}
|
||||
activeTicker={resolvedTicker}
|
||||
breadcrumbs={[
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Overview', href: analysisHref },
|
||||
{ label: 'Reports', href: analysisHref },
|
||||
{ label: resolvedTicker || 'Summary' }
|
||||
]}
|
||||
@@ -135,6 +136,15 @@ export default function AnalysisReportPage() {
|
||||
<ArrowLeft className="size-3" />
|
||||
Back to filings
|
||||
</Link>
|
||||
<Link
|
||||
href={researchHref}
|
||||
onMouseEnter={() => prefetchResearchTicker(resolvedTicker)}
|
||||
onFocus={() => prefetchResearchTicker(resolvedTicker)}
|
||||
className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Open research
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -193,11 +203,14 @@ export default function AnalysisReportPage() {
|
||||
setJournalNotice(null);
|
||||
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
await createResearchArtifact({
|
||||
ticker: report.ticker,
|
||||
kind: 'ai_report',
|
||||
source: 'system',
|
||||
subtype: 'filing_analysis',
|
||||
accessionNumber: report.accessionNumber,
|
||||
entryType: 'filing_note',
|
||||
title: `${report.filingType} AI memo`,
|
||||
summary: report.summary,
|
||||
bodyMarkdown: [
|
||||
`Stored AI memo for ${report.companyName} (${report.ticker}).`,
|
||||
`Accession: ${report.accessionNumber}`,
|
||||
@@ -205,19 +218,21 @@ export default function AnalysisReportPage() {
|
||||
report.summary
|
||||
].join('\n')
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setJournalNotice('Saved to the company research journal.');
|
||||
setJournalNotice('Saved to the company research library.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to journal');
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to library');
|
||||
} finally {
|
||||
setSavingToJournal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
{savingToJournal ? 'Saving...' : 'Add to journal'}
|
||||
{savingToJournal ? 'Saving...' : 'Save to library'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { app } from '@/lib/server/api/app';
|
||||
|
||||
export const GET = app.fetch;
|
||||
export const POST = app.fetch;
|
||||
export const PATCH = app.fetch;
|
||||
export const PUT = app.fetch;
|
||||
export const DELETE = app.fetch;
|
||||
export const OPTIONS = app.fetch;
|
||||
export async function GET(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AuthShell } from '@/components/auth/auth-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthHandoff } from '@/hooks/use-auth-handoff';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
|
||||
function sanitizeNextPath(value: string | null) {
|
||||
@@ -25,7 +26,6 @@ export default function SignInPage() {
|
||||
}
|
||||
|
||||
function SignInPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
|
||||
const { data: rawSession, isPending } = authClient.useSession();
|
||||
@@ -34,18 +34,28 @@ function SignInPageContent() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<'password' | 'magic' | null>(null);
|
||||
const [awaitingSession, setAwaitingSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user?.id) {
|
||||
router.replace(nextPath);
|
||||
}
|
||||
}, [isPending, nextPath, router, session]);
|
||||
const handleHandoffTimeout = useCallback(() => {
|
||||
setAwaitingSession(false);
|
||||
setHandoffError('Authentication completed, but the session was not established on this device. Please sign in again.');
|
||||
}, []);
|
||||
|
||||
const { isHandingOff, statusText } = useAuthHandoff({
|
||||
nextPath,
|
||||
session,
|
||||
isPending,
|
||||
awaitingSession,
|
||||
onTimeout: handleHandoffTimeout
|
||||
});
|
||||
|
||||
const signInWithPassword = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setHandoffError(null);
|
||||
setMessage(null);
|
||||
setBusyAction('password');
|
||||
|
||||
@@ -62,7 +72,7 @@ function SignInPageContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(nextPath);
|
||||
setAwaitingSession(true);
|
||||
};
|
||||
|
||||
const signInWithMagicLink = async () => {
|
||||
@@ -73,6 +83,7 @@ function SignInPageContent() {
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setHandoffError(null);
|
||||
setMessage(null);
|
||||
setBusyAction('magic');
|
||||
|
||||
@@ -113,6 +124,7 @@ function SignInPageContent() {
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
disabled={busyAction !== null || isHandingOff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -124,14 +136,21 @@ function SignInPageContent() {
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
disabled={busyAction !== null || isHandingOff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
||||
{message ? <p className="text-sm text-[#9fffcf]">{message}</p> : null}
|
||||
{handoffError ? <p className="text-sm text-[#ff9f9f]">{handoffError}</p> : null}
|
||||
{message ? <p className="text-sm text-[color:var(--accent)]">{message}</p> : null}
|
||||
{statusText ? <p className="text-sm text-[color:var(--terminal-muted)]">{statusText}</p> : null}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={busyAction !== null}>
|
||||
{busyAction === 'password' ? 'Signing in...' : 'Sign in with password'}
|
||||
<Button type="submit" className="w-full" disabled={busyAction !== null || isHandingOff}>
|
||||
{busyAction === 'password'
|
||||
? 'Signing in...'
|
||||
: isHandingOff
|
||||
? 'Finishing sign-in...'
|
||||
: 'Sign in with password'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -140,10 +159,14 @@ function SignInPageContent() {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={busyAction !== null}
|
||||
disabled={busyAction !== null || isHandingOff}
|
||||
onClick={() => void signInWithMagicLink()}
|
||||
>
|
||||
{busyAction === 'magic' ? 'Sending link...' : 'Send magic link'}
|
||||
{busyAction === 'magic'
|
||||
? 'Sending link...'
|
||||
: isHandingOff
|
||||
? 'Finishing sign-in...'
|
||||
: 'Send magic link'}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AuthShell } from '@/components/auth/auth-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthHandoff } from '@/hooks/use-auth-handoff';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
|
||||
function sanitizeNextPath(value: string | null) {
|
||||
@@ -25,7 +26,6 @@ export default function SignUpPage() {
|
||||
}
|
||||
|
||||
function SignUpPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]);
|
||||
const { data: rawSession, isPending } = authClient.useSession();
|
||||
@@ -36,17 +36,27 @@ function SignUpPageContent() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [awaitingSession, setAwaitingSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user?.id) {
|
||||
router.replace(nextPath);
|
||||
}
|
||||
}, [isPending, nextPath, router, session]);
|
||||
const handleHandoffTimeout = useCallback(() => {
|
||||
setAwaitingSession(false);
|
||||
setHandoffError('Authentication completed, but the session was not established on this device. Please sign in again.');
|
||||
}, []);
|
||||
|
||||
const { isHandingOff, statusText } = useAuthHandoff({
|
||||
nextPath,
|
||||
session,
|
||||
isPending,
|
||||
awaitingSession,
|
||||
onTimeout: handleHandoffTimeout
|
||||
});
|
||||
|
||||
const signUp = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setHandoffError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
@@ -69,7 +79,7 @@ function SignUpPageContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(nextPath);
|
||||
setAwaitingSession(true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -94,6 +104,7 @@ function SignUpPageContent() {
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
disabled={busy || isHandingOff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,6 +116,7 @@ function SignUpPageContent() {
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
disabled={busy || isHandingOff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +129,7 @@ function SignUpPageContent() {
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={busy || isHandingOff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -129,13 +142,16 @@ function SignUpPageContent() {
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={busy || isHandingOff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
||||
{handoffError ? <p className="text-sm text-[#ff9f9f]">{handoffError}</p> : null}
|
||||
{statusText ? <p className="text-sm text-[color:var(--terminal-muted)]">{statusText}</p> : null}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={busy}>
|
||||
{busy ? 'Creating account...' : 'Create account'}
|
||||
<Button type="submit" className="w-full" disabled={busy || isHandingOff}>
|
||||
{busy ? 'Creating account...' : isHandingOff ? 'Finishing sign-in...' : 'Create account'}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Bot, Download, ExternalLink, NotebookPen, Search, TimerReset } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
createResearchJournalEntry,
|
||||
createResearchArtifact,
|
||||
queueFilingAnalysis,
|
||||
queueFilingSync
|
||||
} from '@/lib/api';
|
||||
@@ -28,6 +28,7 @@ const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: stri
|
||||
{ value: 'millions', label: 'Millions (M)' },
|
||||
{ value: 'billions', label: 'Billions (B)' }
|
||||
];
|
||||
const FILINGS_QUERY_LIMIT = 120;
|
||||
|
||||
export default function FilingsPage() {
|
||||
return (
|
||||
@@ -66,6 +67,15 @@ function parseTagsInput(input: string) {
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
function normalizeTickerParam(value: string | null) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function asScaledFinancialSnapshot(
|
||||
value: number | null | undefined,
|
||||
scale: NumberScaleUnit
|
||||
@@ -123,54 +133,39 @@ function FilingExternalLink({ href, label }: FilingExternalLinkProps) {
|
||||
function FilingsPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchReport } = useLinkPrefetch();
|
||||
const activeTickerFilter = useMemo(() => normalizeTickerParam(searchParams.get('ticker')), [searchParams]);
|
||||
const activeFilingsQueryKey = useMemo(
|
||||
() => queryKeys.filings(activeTickerFilter, FILINGS_QUERY_LIMIT),
|
||||
[activeTickerFilter]
|
||||
);
|
||||
const filingsQuery = useQuery({
|
||||
...filingsQueryOptions({ ticker: activeTickerFilter ?? undefined, limit: FILINGS_QUERY_LIMIT }),
|
||||
enabled: !isPending && isAuthenticated
|
||||
});
|
||||
|
||||
const [filings, setFilings] = useState<Filing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncTickerInput, setSyncTickerInput] = useState('');
|
||||
const [syncTickerInput, setSyncTickerInput] = useState(() => activeTickerFilter ?? '');
|
||||
const [syncCategoryInput, setSyncCategoryInput] = useState('');
|
||||
const [syncTagsInput, setSyncTagsInput] = useState('');
|
||||
const [filterTickerInput, setFilterTickerInput] = useState('');
|
||||
const [searchTicker, setSearchTicker] = useState('');
|
||||
const [filterTickerInput, setFilterTickerInput] = useState(() => activeTickerFilter ?? '');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ticker = searchParams.get('ticker');
|
||||
if (ticker) {
|
||||
const normalized = ticker.toUpperCase();
|
||||
setSyncTickerInput(normalized);
|
||||
setFilterTickerInput(normalized);
|
||||
setSearchTicker(normalized);
|
||||
}
|
||||
}, [searchParams]);
|
||||
setSyncTickerInput(activeTickerFilter ?? '');
|
||||
setFilterTickerInput(activeTickerFilter ?? '');
|
||||
}, [activeTickerFilter]);
|
||||
|
||||
const loadFilings = useCallback(async (ticker?: string) => {
|
||||
const options = filingsQueryOptions({ ticker, limit: 120 });
|
||||
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setFilings(response.filings);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to fetch filings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadFilings(searchTicker || undefined);
|
||||
}
|
||||
}, [isPending, isAuthenticated, searchTicker, loadFilings]);
|
||||
const filings = filingsQuery.data?.filings ?? [];
|
||||
const loading = filingsQuery.isPending;
|
||||
const filingsError = filingsQuery.error instanceof Error
|
||||
? filingsQuery.error.message
|
||||
: filingsQuery.error
|
||||
? 'Unable to fetch filings'
|
||||
: null;
|
||||
|
||||
const triggerSync = async () => {
|
||||
if (!syncTickerInput.trim()) {
|
||||
@@ -178,6 +173,7 @@ function FilingsPageContent() {
|
||||
}
|
||||
|
||||
try {
|
||||
setActionError(null);
|
||||
await queueFilingSync({
|
||||
ticker: syncTickerInput.trim().toUpperCase(),
|
||||
limit: 20,
|
||||
@@ -185,47 +181,73 @@ function FilingsPageContent() {
|
||||
tags: parseTagsInput(syncTagsInput)
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
||||
void queryClient.invalidateQueries({ queryKey: ['filings'] });
|
||||
await loadFilings(searchTicker || undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: activeFilingsQueryKey });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to queue filing sync');
|
||||
setActionError(err instanceof Error ? err.message : 'Failed to queue filing sync');
|
||||
}
|
||||
};
|
||||
|
||||
const triggerAnalysis = async (accessionNumber: string) => {
|
||||
try {
|
||||
setActionError(null);
|
||||
await queueFilingAnalysis(accessionNumber);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
||||
void queryClient.invalidateQueries({ queryKey: ['report'] });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to queue filing analysis');
|
||||
setActionError(err instanceof Error ? err.message : 'Failed to queue filing analysis');
|
||||
}
|
||||
};
|
||||
|
||||
const addToJournal = async (filing: Filing) => {
|
||||
const saveToLibrary = async (filing: Filing) => {
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
setActionError(null);
|
||||
await createResearchArtifact({
|
||||
ticker: filing.ticker,
|
||||
kind: 'filing',
|
||||
source: 'system',
|
||||
subtype: 'filing_snapshot',
|
||||
accessionNumber: filing.accession_number,
|
||||
entryType: 'filing_note',
|
||||
title: `${filing.filing_type} filing note`,
|
||||
title: `${filing.filing_type} filing snapshot`,
|
||||
summary: filing.analysis?.text ?? filing.analysis?.legacyInsights ?? `Captured filing ${filing.accession_number}.`,
|
||||
bodyMarkdown: [
|
||||
`Captured filing note for ${filing.company_name} (${filing.ticker}).`,
|
||||
`Filed: ${formatFilingDate(filing.filing_date)}`,
|
||||
`Accession: ${filing.accession_number}`,
|
||||
'',
|
||||
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
|
||||
].join('\n')
|
||||
].join('\n'),
|
||||
metadata: {
|
||||
filingType: filing.filing_type,
|
||||
filingDate: filing.filing_date,
|
||||
filingUrl: filing.filing_url,
|
||||
submissionUrl: filing.submission_url ?? null,
|
||||
primaryDocument: filing.primary_document ?? null
|
||||
}
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} journal.`);
|
||||
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} research library.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add filing to journal');
|
||||
setActionError(err instanceof Error ? err.message : 'Failed to save filing to library');
|
||||
}
|
||||
};
|
||||
|
||||
const replaceTickerFilter = (ticker: string | null) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (ticker) {
|
||||
nextParams.set('ticker', ticker);
|
||||
} else {
|
||||
nextParams.delete('ticker');
|
||||
}
|
||||
|
||||
const nextQuery = nextParams.toString();
|
||||
router.replace(nextQuery ? `/filings?${nextQuery}` : '/filings', { scroll: false });
|
||||
};
|
||||
|
||||
const groupedByTicker = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
@@ -248,19 +270,27 @@ function FilingsPageContent() {
|
||||
<AppShell
|
||||
title="Filings"
|
||||
subtitle="Sync SEC submissions, keep 10-K/10-Q financial snapshots, and analyze qualitative signals from other forms."
|
||||
activeTicker={searchTicker || null}
|
||||
activeTicker={activeTickerFilter}
|
||||
actions={(
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.filings(searchTicker || null, 120) });
|
||||
void loadFilings(searchTicker || undefined);
|
||||
}}
|
||||
>
|
||||
<TimerReset className="size-4" />
|
||||
Refresh table
|
||||
</Button>
|
||||
<>
|
||||
<Link
|
||||
href={`/search${activeTickerFilter ? `?ticker=${encodeURIComponent(activeTickerFilter)}` : ''}`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
Ask with RAG
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: activeFilingsQueryKey });
|
||||
}}
|
||||
>
|
||||
<TimerReset className="size-4" />
|
||||
Refresh table
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
|
||||
@@ -302,7 +332,9 @@ function FilingsPageContent() {
|
||||
className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSearchTicker(filterTickerInput.trim().toUpperCase());
|
||||
const nextTicker = normalizeTickerParam(filterTickerInput);
|
||||
setFilterTickerInput(nextTicker ?? '');
|
||||
replaceTickerFilter(nextTicker);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
@@ -321,7 +353,7 @@ function FilingsPageContent() {
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
setFilterTickerInput('');
|
||||
setSearchTicker('');
|
||||
replaceTickerFilter(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
@@ -332,9 +364,10 @@ function FilingsPageContent() {
|
||||
|
||||
<Panel
|
||||
title="Filing Ledger"
|
||||
subtitle={`${filings.length} records loaded${searchTicker ? ` for ${searchTicker}` : ''}. Values shown in ${selectedFinancialScaleLabel}.`}
|
||||
subtitle={`${filings.length} records loaded${activeTickerFilter ? ` for ${activeTickerFilter}` : ''}. Values shown in ${selectedFinancialScaleLabel}.`}
|
||||
variant="surface"
|
||||
actions={(
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<div className="flex w-full flex-wrap justify-start gap-2 sm:justify-end">
|
||||
{FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
@@ -349,7 +382,8 @@ function FilingsPageContent() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||
{actionError ? <p className="text-sm text-[#ffb5b5]">{actionError}</p> : null}
|
||||
{filingsError ? <p className="text-sm text-[#ffb5b5]">{filingsError}</p> : null}
|
||||
{actionNotice ? <p className="mt-2 text-sm text-[color:var(--accent)]">{actionNotice}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Fetching filings...</p>
|
||||
@@ -411,11 +445,11 @@ function FilingsPageContent() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
onClick={() => void saveToLibrary(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
Add to journal
|
||||
Save to library
|
||||
</Button>
|
||||
{hasAnalysis ? (
|
||||
<Link
|
||||
@@ -433,7 +467,7 @@ function FilingsPageContent() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<div className="data-table-wrap hidden lg:block">
|
||||
<table className="data-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -489,7 +523,7 @@ function FilingsPageContent() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
onClick={() => void saveToLibrary(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
101
app/globals.css
101
app/globals.css
@@ -3,20 +3,21 @@
|
||||
:root {
|
||||
--font-display: "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "Menlo", "SFMono-Regular", "Consolas", "Liberation Mono", monospace;
|
||||
--bg-0: #05080d;
|
||||
--bg-1: #08121a;
|
||||
--bg-2: #0b1f28;
|
||||
--panel: rgba(6, 17, 24, 0.8);
|
||||
--panel-soft: rgba(7, 22, 31, 0.62);
|
||||
--panel-bright: rgba(10, 33, 45, 0.9);
|
||||
--line-weak: rgba(126, 217, 255, 0.22);
|
||||
--line-strong: rgba(123, 255, 217, 0.75);
|
||||
--accent: #68ffd5;
|
||||
--accent-strong: #8cffeb;
|
||||
--danger: #ff7070;
|
||||
--danger-soft: rgba(122, 33, 33, 0.44);
|
||||
--terminal-bright: #e8fff8;
|
||||
--terminal-muted: #94b9c5;
|
||||
--bg-0: #121417;
|
||||
--bg-1: #181b20;
|
||||
--bg-2: #21252b;
|
||||
--panel: rgba(28, 31, 36, 0.84);
|
||||
--panel-soft: rgba(36, 39, 45, 0.72);
|
||||
--panel-bright: rgba(49, 53, 60, 0.94);
|
||||
--line-weak: rgba(196, 202, 211, 0.18);
|
||||
--line-strong: rgba(220, 226, 234, 0.34);
|
||||
--accent: #d9dee5;
|
||||
--accent-strong: #f4f7fb;
|
||||
--danger: #ff8e8e;
|
||||
--danger-soft: rgba(111, 46, 46, 0.42);
|
||||
--terminal-bright: #f3f5f7;
|
||||
--terminal-muted: #a1a9b3;
|
||||
--focus-ring: rgba(229, 231, 235, 0.14);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -29,14 +30,18 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
[data-sonner-toaster] {
|
||||
--normal-bg: rgba(7, 22, 31, 0.96);
|
||||
--normal-text: #e8fff8;
|
||||
--normal-border: rgba(123, 255, 217, 0.45);
|
||||
--success-bg: rgba(8, 58, 42, 0.96);
|
||||
--success-text: #d0ffe9;
|
||||
--success-border: rgba(104, 255, 213, 0.7);
|
||||
--error-bg: rgba(67, 22, 22, 0.96);
|
||||
--normal-bg: rgba(31, 34, 39, 0.96);
|
||||
--normal-text: #f3f5f7;
|
||||
--normal-border: rgba(220, 226, 234, 0.24);
|
||||
--success-bg: rgba(34, 44, 39, 0.96);
|
||||
--success-text: #e4efe7;
|
||||
--success-border: rgba(163, 191, 171, 0.55);
|
||||
--error-bg: rgba(67, 27, 27, 0.96);
|
||||
--error-text: #ffd6d6;
|
||||
--error-border: rgba(255, 112, 112, 0.8);
|
||||
}
|
||||
@@ -48,11 +53,12 @@ body {
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-display), sans-serif;
|
||||
color: var(--terminal-bright);
|
||||
background:
|
||||
radial-gradient(circle at 18% -10%, rgba(126, 217, 255, 0.25), transparent 35%),
|
||||
radial-gradient(circle at 84% 0%, rgba(104, 255, 213, 0.2), transparent 30%),
|
||||
radial-gradient(circle at 18% -10%, rgba(170, 178, 188, 0.16), transparent 35%),
|
||||
radial-gradient(circle at 84% 0%, rgba(121, 128, 138, 0.14), transparent 30%),
|
||||
linear-gradient(140deg, var(--bg-0), var(--bg-1) 50%, var(--bg-2));
|
||||
}
|
||||
|
||||
@@ -67,8 +73,8 @@ body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(126, 217, 255, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(126, 217, 255, 0.07) 1px, transparent 1px);
|
||||
linear-gradient(rgba(204, 210, 218, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(204, 210, 218, 0.05) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: radial-gradient(ellipse at center, black 20%, transparent 75%);
|
||||
pointer-events: none;
|
||||
@@ -78,8 +84,8 @@ body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(rgba(160, 255, 227, 0.15) 0.7px, transparent 0.7px);
|
||||
opacity: 0.24;
|
||||
background-image: radial-gradient(rgba(220, 226, 234, 0.1) 0.7px, transparent 0.7px);
|
||||
background-size: 4px 4px;
|
||||
}
|
||||
|
||||
@@ -92,17 +98,39 @@ body {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-surface {
|
||||
border: 1px solid var(--line-weak);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(40, 43, 49, 0.92), rgba(24, 27, 32, 0.78));
|
||||
}
|
||||
|
||||
.data-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line-weak);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(34, 37, 42, 0.9), rgba(20, 23, 27, 0.76));
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
border-bottom: 1px solid var(--line-weak);
|
||||
padding: 0.75rem 0.65rem;
|
||||
padding: 0.8rem 0.8rem;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@@ -114,7 +142,7 @@ body {
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: rgba(17, 47, 61, 0.45);
|
||||
background-color: rgba(63, 68, 76, 0.32);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@@ -140,3 +168,18 @@ body {
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 24% -4%, rgba(170, 178, 188, 0.14), transparent 36%),
|
||||
radial-gradient(circle at 82% 2%, rgba(121, 128, 138, 0.12), transparent 30%),
|
||||
linear-gradient(155deg, var(--bg-0), var(--bg-1) 54%, var(--bg-2));
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.65rem 0.55rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
692
app/graphing/page.tsx
Normal file
692
app/graphing/page.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { BarChart3, RefreshCcw, Search, X } from 'lucide-react';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
GRAPH_CADENCE_OPTIONS,
|
||||
GRAPH_CHART_OPTIONS,
|
||||
GRAPH_SCALE_OPTIONS,
|
||||
GRAPH_SURFACE_LABELS,
|
||||
buildGraphingHref,
|
||||
getGraphMetricDefinition,
|
||||
metricsForSurfaceAndCadence,
|
||||
normalizeGraphTickers,
|
||||
parseGraphingParams,
|
||||
resolveGraphMetric,
|
||||
serializeGraphingParams,
|
||||
type GraphChartKind,
|
||||
type GraphMetricDefinition,
|
||||
type GraphingUrlState
|
||||
} from '@/lib/graphing/catalog';
|
||||
import {
|
||||
buildGraphingComparisonData,
|
||||
type GraphingChartDatum,
|
||||
type GraphingFetchResult,
|
||||
type GraphingLatestValueRow,
|
||||
type GraphingSeriesPoint
|
||||
} from '@/lib/graphing/series';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import {
|
||||
formatCurrencyByScale,
|
||||
formatPercent,
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialUnit
|
||||
} from '@/lib/types';
|
||||
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CHART_COLORS = ['#d9dee5', '#c7cdd5', '#b5bcc5', '#a4acb6', '#939ca7'] as const;
|
||||
const CHART_MUTED = '#a1a9b3';
|
||||
const CHART_GRID = 'rgba(196, 202, 211, 0.18)';
|
||||
const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)';
|
||||
const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)';
|
||||
|
||||
type TooltipEntry = {
|
||||
dataKey?: string | number;
|
||||
color?: string;
|
||||
value?: number | string | null;
|
||||
payload?: GraphingChartDatum;
|
||||
};
|
||||
|
||||
function formatLongDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return format(parsed, 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return format(parsed, 'MMM yyyy');
|
||||
}
|
||||
|
||||
function formatMetricValue(
|
||||
value: number | null | undefined,
|
||||
unit: FinancialUnit,
|
||||
scale: NumberScaleUnit
|
||||
) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case 'currency':
|
||||
return formatCurrencyByScale(value, scale);
|
||||
case 'percent':
|
||||
return formatPercent(value * 100);
|
||||
case 'ratio':
|
||||
return `${value.toFixed(2)}x`;
|
||||
case 'shares':
|
||||
case 'count':
|
||||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatChangeValue(
|
||||
value: number | null,
|
||||
unit: FinancialUnit,
|
||||
scale: NumberScaleUnit
|
||||
) {
|
||||
if (value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
if (unit === 'percent') {
|
||||
const signed = value >= 0 ? '+' : '';
|
||||
return `${signed}${formatPercent(value * 100)}`;
|
||||
}
|
||||
|
||||
if (unit === 'ratio') {
|
||||
const signed = value >= 0 ? '+' : '';
|
||||
return `${signed}${value.toFixed(2)}x`;
|
||||
}
|
||||
|
||||
if (unit === 'currency') {
|
||||
const formatted = formatCurrencyByScale(Math.abs(value), scale);
|
||||
return value >= 0 ? `+${formatted}` : `-${formatted}`;
|
||||
}
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(Math.abs(value));
|
||||
return value >= 0 ? `+${formatted}` : `-${formatted}`;
|
||||
}
|
||||
|
||||
function tickerPillClass(disabled?: boolean) {
|
||||
return cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs',
|
||||
disabled
|
||||
? 'border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)]'
|
||||
: 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSection<T extends string>(props: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: Array<{ value: T; label: string }>;
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{props.label}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{props.options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-label={`${props.ariaLabel} ${option.label}`}
|
||||
variant={option.value === props.value ? 'primary' : 'ghost'}
|
||||
className="px-3 py-1.5 text-xs"
|
||||
onClick={() => props.onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonTooltip(props: {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
metric: GraphMetricDefinition;
|
||||
scale: NumberScaleUnit;
|
||||
}) {
|
||||
if (!props.active || !props.payload || props.payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const datum = props.payload[0]?.payload;
|
||||
if (!datum) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = props.payload
|
||||
.filter((entry) => typeof entry.dataKey === 'string')
|
||||
.map((entry) => {
|
||||
const ticker = entry.dataKey as string;
|
||||
const meta = datum[`meta__${ticker}`] as GraphingSeriesPoint | undefined;
|
||||
|
||||
return {
|
||||
ticker,
|
||||
color: entry.color ?? CHART_MUTED,
|
||||
value: typeof entry.value === 'number' ? entry.value : null,
|
||||
meta
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[220px] rounded-xl border px-3 py-3 text-sm shadow-[0_16px_40px_rgba(0,0,0,0.34)]"
|
||||
style={{
|
||||
backgroundColor: CHART_TOOLTIP_BG,
|
||||
borderColor: CHART_TOOLTIP_BORDER
|
||||
}}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{formatLongDate(entries[0]?.meta?.dateKey ?? null)}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.ticker} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:rgba(45,49,55,0.72)] px-2 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} aria-hidden="true" />
|
||||
<span className="font-medium text-[color:var(--terminal-bright)]">{entry.ticker}</span>
|
||||
</div>
|
||||
<span className="text-[color:var(--terminal-bright)]">
|
||||
{formatMetricValue(entry.value, props.metric.unit, props.scale)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.meta ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
|
||||
{entry.meta.filingType} · {entry.meta.periodLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>}>
|
||||
<GraphingPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphingPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
|
||||
const graphState = useMemo(() => parseGraphingParams(searchParams), [searchParams]);
|
||||
const canonicalQuery = useMemo(() => serializeGraphingParams(graphState), [graphState]);
|
||||
const currentQuery = searchParams.toString();
|
||||
|
||||
const [tickerInput, setTickerInput] = useState(graphState.tickers.join(', '));
|
||||
const [results, setResults] = useState<GraphingFetchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTickerInput(graphState.tickers.join(', '));
|
||||
}, [graphState.tickers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentQuery !== canonicalQuery) {
|
||||
router.replace(`/graphing?${canonicalQuery}`, { scroll: false });
|
||||
}
|
||||
}, [canonicalQuery, currentQuery, router]);
|
||||
|
||||
const replaceGraphState = useCallback((patch: Partial<GraphingUrlState>) => {
|
||||
const nextSurface = patch.surface ?? graphState.surface;
|
||||
const nextCadence = patch.cadence ?? graphState.cadence;
|
||||
const nextTickers = patch.tickers && patch.tickers.length > 0 ? patch.tickers : graphState.tickers;
|
||||
const nextMetric = resolveGraphMetric(
|
||||
nextSurface,
|
||||
nextCadence,
|
||||
patch.metric ?? graphState.metric
|
||||
);
|
||||
|
||||
const nextState: GraphingUrlState = {
|
||||
tickers: nextTickers,
|
||||
surface: nextSurface,
|
||||
cadence: nextCadence,
|
||||
metric: nextMetric,
|
||||
chart: patch.chart ?? graphState.chart,
|
||||
scale: patch.scale ?? graphState.scale
|
||||
};
|
||||
|
||||
router.replace(`/graphing?${serializeGraphingParams(nextState)}`, { scroll: false });
|
||||
}, [graphState, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadComparisonSet() {
|
||||
setLoading(true);
|
||||
|
||||
const settled = await Promise.allSettled(graphState.tickers.map(async (ticker) => {
|
||||
const response = await queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
|
||||
ticker,
|
||||
surfaceKind: graphState.surface,
|
||||
cadence: graphState.cadence,
|
||||
includeDimensions: false,
|
||||
includeFacts: false,
|
||||
limit: 16
|
||||
}));
|
||||
|
||||
return response.financials;
|
||||
}));
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(settled.map((entry, index) => {
|
||||
const ticker = graphState.tickers[index] ?? `ticker-${index + 1}`;
|
||||
|
||||
if (entry.status === 'fulfilled') {
|
||||
return {
|
||||
ticker,
|
||||
financials: entry.value
|
||||
} satisfies GraphingFetchResult;
|
||||
}
|
||||
|
||||
const reason = entry.reason instanceof ApiError
|
||||
? entry.reason.message
|
||||
: entry.reason instanceof Error
|
||||
? entry.reason.message
|
||||
: 'Unable to load financial history';
|
||||
|
||||
return {
|
||||
ticker,
|
||||
error: reason
|
||||
} satisfies GraphingFetchResult;
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void loadComparisonSet();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [graphState, isAuthenticated, isPending, queryClient, refreshNonce]);
|
||||
|
||||
const metricOptions = useMemo(() => metricsForSurfaceAndCadence(graphState.surface, graphState.cadence), [graphState.cadence, graphState.surface]);
|
||||
const selectedMetric = useMemo(
|
||||
() => getGraphMetricDefinition(graphState.surface, graphState.cadence, graphState.metric),
|
||||
[graphState.cadence, graphState.metric, graphState.surface]
|
||||
);
|
||||
const comparison = useMemo(() => buildGraphingComparisonData({
|
||||
results,
|
||||
surface: graphState.surface,
|
||||
metric: graphState.metric
|
||||
}), [graphState.metric, graphState.surface, results]);
|
||||
const partialCoverageMessage = useMemo(() => {
|
||||
const notMeaningfulCount = comparison.companies.filter((company) => company.status === 'not_meaningful').length;
|
||||
const missingCount = comparison.companies.filter((company) => company.status === 'no_metric_data').length;
|
||||
const errorCount = comparison.companies.filter((company) => company.status === 'error').length;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (notMeaningfulCount > 0) {
|
||||
parts.push(`${notMeaningfulCount} ${notMeaningfulCount === 1 ? 'company marks' : 'companies mark'} this metric as not meaningful for the selected pack`);
|
||||
}
|
||||
|
||||
if (missingCount > 0) {
|
||||
parts.push(`${missingCount} ${missingCount === 1 ? 'company has' : 'companies have'} no metric data`);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
parts.push(`${errorCount} ${errorCount === 1 ? 'company failed' : 'companies failed'} to load`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'Partial coverage detected. Some companies are missing values for this metric.';
|
||||
}
|
||||
|
||||
return `Partial coverage detected. ${parts.join('. ')}.`;
|
||||
}, [comparison.companies]);
|
||||
|
||||
const hasCurrencyScale = selectedMetric?.unit === 'currency';
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Graphing"
|
||||
subtitle="Compare one normalized filing metric across multiple companies with shareable chart state."
|
||||
activeTicker={graphState.tickers[0] ?? null}
|
||||
actions={(
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setRefreshNonce((current) => current + 1)}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => replaceGraphState(parseGraphingParams(new URLSearchParams()))}
|
||||
>
|
||||
<BarChart3 className="size-4" />
|
||||
Reset View
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Panel title="Compare Set" subtitle="Enter up to five tickers. Duplicates are removed and the first ticker anchors research context.">
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const nextTickers = normalizeGraphTickers(tickerInput);
|
||||
replaceGraphState({ tickers: nextTickers.length > 0 ? nextTickers : [...graphState.tickers] });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
aria-label="Compare tickers"
|
||||
value={tickerInput}
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="MSFT, AAPL, NVDA"
|
||||
className="min-w-[260px] flex-1"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Search className="size-4" />
|
||||
Update Compare Set
|
||||
</Button>
|
||||
<Link
|
||||
href={buildGraphingHref(graphState.tickers[0] ?? null)}
|
||||
onMouseEnter={() => {
|
||||
if (graphState.tickers[0]) {
|
||||
prefetchResearchTicker(graphState.tickers[0]);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (graphState.tickers[0]) {
|
||||
prefetchResearchTicker(graphState.tickers[0]);
|
||||
}
|
||||
}}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open canonical graphing URL
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{graphState.tickers.map((ticker, index) => (
|
||||
<span key={ticker} className={tickerPillClass(index === 0)}>
|
||||
{ticker}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${ticker}`}
|
||||
disabled={graphState.tickers.length === 1}
|
||||
className="rounded-full p-0.5 text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
const nextTickers = graphState.tickers.filter((entry) => entry !== ticker);
|
||||
if (nextTickers.length > 0) {
|
||||
replaceGraphState({ tickers: nextTickers });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Chart Controls" subtitle="Surface, metric, cadence, chart style, and scale stay in the URL for deep-linking." variant="surface">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
|
||||
<div className="space-y-5">
|
||||
<ControlSection
|
||||
label="Surface"
|
||||
ariaLabel="Graph surface"
|
||||
value={graphState.surface}
|
||||
options={Object.entries(GRAPH_SURFACE_LABELS).map(([value, label]) => ({ value: value as GraphingUrlState['surface'], label }))}
|
||||
onChange={(value) => replaceGraphState({ surface: value })}
|
||||
/>
|
||||
<ControlSection
|
||||
label="Cadence"
|
||||
ariaLabel="Graph cadence"
|
||||
value={graphState.cadence}
|
||||
options={GRAPH_CADENCE_OPTIONS}
|
||||
onChange={(value) => replaceGraphState({ cadence: value as FinancialCadence })}
|
||||
/>
|
||||
<ControlSection
|
||||
label="Chart Type"
|
||||
ariaLabel="Chart type"
|
||||
value={graphState.chart}
|
||||
options={GRAPH_CHART_OPTIONS}
|
||||
onChange={(value) => replaceGraphState({ chart: value as GraphChartKind })}
|
||||
/>
|
||||
{hasCurrencyScale ? (
|
||||
<ControlSection
|
||||
label="Scale"
|
||||
ariaLabel="Value scale"
|
||||
value={graphState.scale}
|
||||
options={GRAPH_SCALE_OPTIONS}
|
||||
onChange={(value) => replaceGraphState({ scale: value as NumberScaleUnit })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Metric</p>
|
||||
<select
|
||||
aria-label="Metric selector"
|
||||
value={graphState.metric}
|
||||
onChange={(event) => replaceGraphState({ metric: event.target.value })}
|
||||
className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2.5 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
||||
>
|
||||
{metricOptions.map((option) => (
|
||||
<option key={option.key} value={option.key} className="bg-[#1f2227]">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedMetric ? (
|
||||
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3 text-sm text-[color:var(--terminal-muted)]">
|
||||
<p className="text-[color:var(--terminal-bright)]">{selectedMetric.label}</p>
|
||||
<p className="mt-1">
|
||||
{GRAPH_SURFACE_LABELS[selectedMetric.surface]} · {selectedMetric.category.replace(/_/g, ' ')} · unit {selectedMetric.unit}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Comparison Chart" subtitle="One metric, multiple companies, aligned by actual reported dates." variant="surface">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading comparison chart...</p>
|
||||
) : !selectedMetric || !comparison.hasAnyData ? (
|
||||
<div className="rounded-xl border border-dashed border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-4 py-6">
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">No chart data available for the selected compare set.</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Try a different metric, cadence, or company basket.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{comparison.hasPartialData ? (
|
||||
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm text-[color:var(--terminal-muted)]">
|
||||
{partialCoverageMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{comparison.companies.map((company, index) => (
|
||||
<span key={company.ticker} className={tickerPillClass(company.status !== 'ready')}>
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{company.ticker}
|
||||
<span className="text-[color:var(--terminal-muted)]">{company.companyName}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-[360px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{graphState.chart === 'line' ? (
|
||||
<LineChart data={comparison.chartData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
<XAxis
|
||||
dataKey="dateKey"
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
minTickGap={20}
|
||||
tickFormatter={(value: string) => formatShortDate(value)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
|
||||
/>
|
||||
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
|
||||
{comparison.companies.map((company, index) => (
|
||||
<Line
|
||||
key={company.ticker}
|
||||
type="monotone"
|
||||
dataKey={company.ticker}
|
||||
connectNulls={false}
|
||||
stroke={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
name={company.ticker}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={comparison.chartData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
<XAxis
|
||||
dataKey="dateKey"
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
minTickGap={20}
|
||||
tickFormatter={(value: string) => formatShortDate(value)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
|
||||
/>
|
||||
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
|
||||
{comparison.companies.map((company, index) => (
|
||||
<Bar
|
||||
key={company.ticker}
|
||||
dataKey={company.ticker}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
radius={[6, 6, 0, 0]}
|
||||
name={company.ticker}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Latest Values" subtitle="Most recent reported point per company, plus the prior point and one-step change." variant="surface">
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table min-w-[920px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Pack</th>
|
||||
<th>Latest</th>
|
||||
<th>Prior</th>
|
||||
<th>Change</th>
|
||||
<th>Period</th>
|
||||
<th>Filing</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.latestRows.map((row, index) => (
|
||||
<tr key={row.ticker}>
|
||||
<td>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{row.ticker}</span>
|
||||
</div>
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{row.fiscalPack ?? 'n/a'}</td>
|
||||
<td>{selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
||||
<td>{selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
||||
<td className={cn(row.changeValue !== null && row.changeValue >= 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}>
|
||||
{selectedMetric ? formatChangeValue(row.changeValue, selectedMetric.unit, graphState.scale) : 'n/a'}
|
||||
</td>
|
||||
<td>{formatLongDate(row.latestDateKey)} {row.latestPeriodLabel ? <span className="text-xs text-[color:var(--terminal-muted)]">· {row.latestPeriodLabel}</span> : null}</td>
|
||||
<td>{row.latestFilingType ?? 'n/a'}</td>
|
||||
<td>
|
||||
{row.status === 'ready' ? (
|
||||
<span className="text-[color:var(--accent)]">Ready</span>
|
||||
) : row.status === 'not_meaningful' ? (
|
||||
<span className="text-[color:var(--terminal-muted)]">Not meaningful for this pack</span>
|
||||
) : row.status === 'no_metric_data' ? (
|
||||
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
|
||||
) : (
|
||||
<span className="text-[#ffb5b5]">{row.errorMessage ?? 'Load failed'}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,37 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import { SidebarPreferenceProvider } from '@/components/providers/sidebar-preference-provider';
|
||||
import { QueryProvider } from '@/components/providers/query-provider';
|
||||
import {
|
||||
SIDEBAR_PREFERENCE_KEY,
|
||||
parseSidebarPreference
|
||||
} from '@/lib/sidebar-preference';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Fiscal Clone',
|
||||
description: 'Futuristic fiscal intelligence terminal with durable tasks and AI SDK integration.'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#121417'
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies();
|
||||
const initialSidebarCollapsed = parseSidebarPreference(
|
||||
cookieStore.get(SIDEBAR_PREFERENCE_KEY)?.value
|
||||
);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<SidebarPreferenceProvider initialSidebarCollapsed={initialSidebarCollapsed}>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</SidebarPreferenceProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
27
app/page.tsx
27
app/page.tsx
@@ -15,6 +15,7 @@ import {
|
||||
queuePortfolioInsights,
|
||||
queuePriceRefresh
|
||||
} from '@/lib/api';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
|
||||
import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
@@ -150,7 +151,7 @@ export default function CommandCenterPage() {
|
||||
actions={headerActions}
|
||||
>
|
||||
{error ? (
|
||||
<Panel>
|
||||
<Panel variant="surface">
|
||||
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
@@ -168,7 +169,7 @@ export default function CommandCenterPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<Panel title="Recent Tasks" subtitle="Durable jobs from queue processor" className="xl:col-span-1">
|
||||
<Panel title="Recent Tasks" subtitle="Durable jobs from queue processor" className="xl:col-span-1" variant="surface">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading tasks...</p>
|
||||
) : (
|
||||
@@ -176,7 +177,7 @@ export default function CommandCenterPage() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="AI Brief" subtitle="Latest portfolio insight from AI SDK (Zhipu)" className="xl:col-span-2">
|
||||
<Panel title="AI Brief" subtitle="Latest portfolio insight from AI SDK (Zhipu)" className="xl:col-span-2" variant="surface">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p>
|
||||
) : state.latestInsight ? (
|
||||
@@ -194,17 +195,21 @@ export default function CommandCenterPage() {
|
||||
</div>
|
||||
|
||||
<Panel title="Quick Links" subtitle="Feature modules">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" href="/analysis">
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Analysis</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Inspect one company across prices, filings, financials, and AI reports.</p>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Link className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]" href="/analysis">
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Overview</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Inspect one company across price, SEC context, valuation, and recent developments.</p>
|
||||
</Link>
|
||||
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" href="/financials">
|
||||
<Link className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]" href="/financials">
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Financials</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.</p>
|
||||
</Link>
|
||||
<Link className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]" href={buildGraphingHref()}>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Graphing</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Compare one normalized metric across multiple companies with shareable chart state.</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
|
||||
className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]"
|
||||
href="/filings"
|
||||
onMouseEnter={() => {
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({ limit: 120 }));
|
||||
@@ -217,7 +222,7 @@ export default function CommandCenterPage() {
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Sync SEC filings and trigger AI memo analysis.</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
|
||||
className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]"
|
||||
href="/portfolio"
|
||||
onMouseEnter={() => prefetchPortfolioSurfaces()}
|
||||
onFocus={() => prefetchPortfolioSurfaces()}
|
||||
@@ -226,7 +231,7 @@ export default function CommandCenterPage() {
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Manage the active private portfolio and mark positions to market.</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
|
||||
className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]"
|
||||
href="/watchlist"
|
||||
onMouseEnter={() => prefetchPortfolioSurfaces()}
|
||||
onFocus={() => prefetchPortfolioSurfaces()}
|
||||
|
||||
@@ -35,12 +35,12 @@ type FormState = {
|
||||
currentPrice: string;
|
||||
};
|
||||
|
||||
const CHART_COLORS = ['#6effd8', '#5fd3ff', '#66ffa1', '#8dbbff', '#f4f88f', '#ff9c9c'];
|
||||
const CHART_TEXT = '#e8fff8';
|
||||
const CHART_MUTED = '#b4ced9';
|
||||
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
|
||||
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
|
||||
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
|
||||
const CHART_COLORS = ['#d9dee5', '#c7cdd5', '#b5bcc5', '#a4acb6', '#939ca7', '#828b97'];
|
||||
const CHART_TEXT = '#f3f5f7';
|
||||
const CHART_MUTED = '#a1a9b3';
|
||||
const CHART_GRID = 'rgba(196, 202, 211, 0.18)';
|
||||
const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)';
|
||||
const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)';
|
||||
|
||||
const EMPTY_SUMMARY: PortfolioSummary = {
|
||||
positions: 0,
|
||||
@@ -179,20 +179,20 @@ export default function PortfolioPage() {
|
||||
title="Portfolio"
|
||||
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => void queueRefresh()}>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
<Button variant="secondary" className="w-full sm:w-auto" onClick={() => void queueRefresh()}>
|
||||
<RefreshCcw className="size-4" />
|
||||
Queue price refresh
|
||||
</Button>
|
||||
<Button onClick={() => void queueInsights()}>
|
||||
<Button className="w-full sm:w-auto" onClick={() => void queueInsights()}>
|
||||
<BrainCircuit className="size-4" />
|
||||
Generate AI brief
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<Panel>
|
||||
<Panel variant="surface">
|
||||
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
@@ -215,11 +215,11 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<Panel title="Allocation">
|
||||
<Panel title="Allocation" variant="surface">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||
) : allocationData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<div className="h-[260px] sm:h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
|
||||
@@ -228,7 +228,7 @@ export default function PortfolioPage() {
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => formatCurrency(value)}
|
||||
formatter={(value) => formatCurrency(Array.isArray(value) ? value[0] : value)}
|
||||
contentStyle={{
|
||||
backgroundColor: CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
||||
@@ -251,11 +251,11 @@ export default function PortfolioPage() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Performance %">
|
||||
<Panel title="Performance %" variant="surface">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||
) : performanceData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<div className="h-[260px] sm:h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={performanceData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
@@ -275,7 +275,7 @@ export default function PortfolioPage() {
|
||||
tick={{ fill: CHART_MUTED }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number | string | undefined) => `${asNumber(value).toFixed(2)}%`}
|
||||
formatter={(value) => `${asNumber(Array.isArray(value) ? value[0] : value).toFixed(2)}%`}
|
||||
contentStyle={{
|
||||
backgroundColor: CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
||||
@@ -283,9 +283,9 @@ export default function PortfolioPage() {
|
||||
}}
|
||||
labelStyle={{ color: CHART_TEXT }}
|
||||
itemStyle={{ color: CHART_TEXT }}
|
||||
cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }}
|
||||
cursor={{ fill: 'rgba(220, 226, 234, 0.08)' }}
|
||||
/>
|
||||
<Bar dataKey="value" fill="#68ffd5" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="value" fill="#d9dee5" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -296,117 +296,225 @@ export default function PortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.5fr_1fr]">
|
||||
<Panel title="Holdings Table" subtitle="Live mark-to-market values from latest refresh.">
|
||||
<Panel title="Holdings Table" subtitle="Live mark-to-market values from latest refresh." variant="surface">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading holdings...</p>
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
||||
) : (
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table className="data-table min-w-[1020px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Company</th>
|
||||
<th>Shares</th>
|
||||
<th>Avg Cost</th>
|
||||
<th>Price</th>
|
||||
<th>Value</th>
|
||||
<th>Gain/Loss</th>
|
||||
<th>Research</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.map((holding) => (
|
||||
<tr key={holding.id}>
|
||||
<td>{holding.ticker}</td>
|
||||
<td>{holding.company_name ?? 'n/a'}</td>
|
||||
<td>{asNumber(holding.shares).toLocaleString()}</td>
|
||||
<td>{formatCurrency(holding.avg_cost)}</td>
|
||||
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
|
||||
<td>{formatCurrency(holding.market_value)}</td>
|
||||
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
|
||||
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{holdings.map((holding) => (
|
||||
<article key={holding.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{holding.ticker}</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{holding.company_name ?? 'Company name unavailable'}</p>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
|
||||
{formatCurrency(holding.gain_loss)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Shares</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asNumber(holding.shares).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Avg Cost</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.avg_cost)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Price</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Value</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.market_value)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className={`mt-3 text-xs ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
|
||||
Return {formatPercent(holding.gain_loss_pct)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="data-table-wrap hidden max-w-full lg:block">
|
||||
<table className="data-table min-w-[1020px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Company</th>
|
||||
<th>Shares</th>
|
||||
<th>Avg Cost</th>
|
||||
<th>Price</th>
|
||||
<th>Value</th>
|
||||
<th>Gain/Loss</th>
|
||||
<th>Research</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.map((holding) => (
|
||||
<tr key={holding.id}>
|
||||
<td>{holding.ticker}</td>
|
||||
<td>{holding.company_name ?? 'n/a'}</td>
|
||||
<td>{asNumber(holding.shares).toLocaleString()}</td>
|
||||
<td>{formatCurrency(holding.avg_cost)}</td>
|
||||
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
|
||||
<td>{formatCurrency(holding.market_value)}</td>
|
||||
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
|
||||
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title={editingHoldingId === null ? 'Add Holding' : 'Edit Holding'}>
|
||||
<Panel title={editingHoldingId === null ? 'Add Holding' : 'Edit Holding'} variant="surface">
|
||||
<form onSubmit={submitHolding} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Ticker</label>
|
||||
@@ -440,12 +548,12 @@ export default function PortfolioPage() {
|
||||
<Input aria-label="Holding current price" type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
<Button type="submit" className="w-full sm:flex-1">
|
||||
<Plus className="size-4" />
|
||||
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
||||
</Button>
|
||||
{editingHoldingId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
831
app/research/page.tsx
Normal file
831
app/research/page.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
addResearchMemoEvidence,
|
||||
createResearchArtifact,
|
||||
deleteResearchArtifact,
|
||||
deleteResearchMemoEvidence,
|
||||
getResearchArtifactFileUrl,
|
||||
updateResearchArtifact,
|
||||
uploadResearchArtifact,
|
||||
upsertResearchMemo
|
||||
} from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import {
|
||||
researchLibraryQueryOptions,
|
||||
researchWorkspaceQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type {
|
||||
ResearchArtifact,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchMemo,
|
||||
ResearchMemoSection,
|
||||
ResearchWorkspace
|
||||
} from '@/lib/types';
|
||||
|
||||
const MEMO_SECTIONS: Array<{ value: ResearchMemoSection; label: string }> = [
|
||||
{ value: 'thesis', label: 'Thesis' },
|
||||
{ value: 'variant_view', label: 'Variant View' },
|
||||
{ value: 'catalysts', label: 'Catalysts' },
|
||||
{ value: 'risks', label: 'Risks' },
|
||||
{ value: 'disconfirming_evidence', label: 'Disconfirming Evidence' },
|
||||
{ value: 'next_actions', label: 'Next Actions' }
|
||||
];
|
||||
|
||||
const KIND_OPTIONS: Array<{ value: '' | ResearchArtifactKind; label: string }> = [
|
||||
{ value: '', label: 'All artifacts' },
|
||||
{ value: 'note', label: 'Notes' },
|
||||
{ value: 'ai_report', label: 'AI memos' },
|
||||
{ value: 'filing', label: 'Filings' },
|
||||
{ value: 'upload', label: 'Uploads' },
|
||||
{ value: 'status_change', label: 'Status events' }
|
||||
];
|
||||
|
||||
const SOURCE_OPTIONS: Array<{ value: '' | ResearchArtifactSource; label: string }> = [
|
||||
{ value: '', label: 'All sources' },
|
||||
{ value: 'user', label: 'User-authored' },
|
||||
{ value: 'system', label: 'System-generated' }
|
||||
];
|
||||
|
||||
type NoteFormState = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
bodyMarkdown: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
type MemoFormState = {
|
||||
rating: string;
|
||||
conviction: string;
|
||||
timeHorizonMonths: string;
|
||||
packetTitle: string;
|
||||
packetSubtitle: string;
|
||||
thesisMarkdown: string;
|
||||
variantViewMarkdown: string;
|
||||
catalystsMarkdown: string;
|
||||
risksMarkdown: string;
|
||||
disconfirmingEvidenceMarkdown: string;
|
||||
nextActionsMarkdown: string;
|
||||
};
|
||||
|
||||
const EMPTY_NOTE_FORM: NoteFormState = {
|
||||
id: null,
|
||||
title: '',
|
||||
summary: '',
|
||||
bodyMarkdown: '',
|
||||
tags: ''
|
||||
};
|
||||
|
||||
const EMPTY_MEMO_FORM: MemoFormState = {
|
||||
rating: '',
|
||||
conviction: '',
|
||||
timeHorizonMonths: '',
|
||||
packetTitle: '',
|
||||
packetSubtitle: '',
|
||||
thesisMarkdown: '',
|
||||
variantViewMarkdown: '',
|
||||
catalystsMarkdown: '',
|
||||
risksMarkdown: '',
|
||||
disconfirmingEvidenceMarkdown: '',
|
||||
nextActionsMarkdown: ''
|
||||
};
|
||||
|
||||
function parseTags(value: string) {
|
||||
const unique = new Set<string>();
|
||||
|
||||
for (const segment of value.split(',')) {
|
||||
const normalized = segment.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(normalized);
|
||||
}
|
||||
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
function tagsToInput(tags: string[]) {
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return format(date, 'MMM dd, yyyy · HH:mm');
|
||||
}
|
||||
|
||||
function toMemoForm(memo: ResearchMemo | null): MemoFormState {
|
||||
if (!memo) {
|
||||
return EMPTY_MEMO_FORM;
|
||||
}
|
||||
|
||||
return {
|
||||
rating: memo.rating ?? '',
|
||||
conviction: memo.conviction ?? '',
|
||||
timeHorizonMonths: memo.time_horizon_months ? String(memo.time_horizon_months) : '',
|
||||
packetTitle: memo.packet_title ?? '',
|
||||
packetSubtitle: memo.packet_subtitle ?? '',
|
||||
thesisMarkdown: memo.thesis_markdown,
|
||||
variantViewMarkdown: memo.variant_view_markdown,
|
||||
catalystsMarkdown: memo.catalysts_markdown,
|
||||
risksMarkdown: memo.risks_markdown,
|
||||
disconfirmingEvidenceMarkdown: memo.disconfirming_evidence_markdown,
|
||||
nextActionsMarkdown: memo.next_actions_markdown
|
||||
};
|
||||
}
|
||||
|
||||
function noteFormFromArtifact(artifact: ResearchArtifact): NoteFormState {
|
||||
return {
|
||||
id: artifact.id,
|
||||
title: artifact.title ?? '',
|
||||
summary: artifact.summary ?? '',
|
||||
bodyMarkdown: artifact.body_markdown ?? '',
|
||||
tags: tagsToInput(artifact.tags)
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</div>}>
|
||||
<ResearchPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResearchPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
|
||||
const [workspace, setWorkspace] = useState<ResearchWorkspace | null>(null);
|
||||
const [library, setLibrary] = useState<ResearchArtifact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [noteForm, setNoteForm] = useState<NoteFormState>(EMPTY_NOTE_FORM);
|
||||
const [memoForm, setMemoForm] = useState<MemoFormState>(EMPTY_MEMO_FORM);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [kindFilter, setKindFilter] = useState<'' | ResearchArtifactKind>('');
|
||||
const [sourceFilter, setSourceFilter] = useState<'' | ResearchArtifactSource>('');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [linkedOnly, setLinkedOnly] = useState(false);
|
||||
const [attachSection, setAttachSection] = useState<ResearchMemoSection>('thesis');
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploadTitle, setUploadTitle] = useState('');
|
||||
const [uploadSummary, setUploadSummary] = useState('');
|
||||
const [uploadTags, setUploadTags] = useState('');
|
||||
|
||||
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
|
||||
const deferredSearch = useDeferredValue(searchInput);
|
||||
|
||||
const loadWorkspace = async (symbol: string) => {
|
||||
if (!symbol) {
|
||||
setWorkspace(null);
|
||||
setLibrary([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = researchWorkspaceQueryOptions(symbol);
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
setWorkspace(response.workspace);
|
||||
setLibrary(response.workspace.library);
|
||||
setMemoForm(toMemoForm(response.workspace.memo));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setWorkspace(null);
|
||||
setLibrary([]);
|
||||
setError(err instanceof Error ? err.message : 'Unable to load research workspace');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLibrary = async (symbol: string) => {
|
||||
if (!symbol) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(researchLibraryQueryOptions({
|
||||
ticker: symbol,
|
||||
q: deferredSearch,
|
||||
kind: kindFilter || undefined,
|
||||
source: sourceFilter || undefined,
|
||||
tag: tagFilter || undefined,
|
||||
linkedToMemo: linkedOnly ? true : undefined,
|
||||
limit: 100
|
||||
}));
|
||||
setLibrary(response.artifacts);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load filtered library');
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateResearch = async (symbol: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() })
|
||||
]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadWorkspace(ticker);
|
||||
}
|
||||
}, [isPending, isAuthenticated, ticker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadLibrary(workspace.ticker);
|
||||
}, [workspace?.ticker, deferredSearch, kindFilter, sourceFilter, tagFilter, linkedOnly]);
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</div>;
|
||||
}
|
||||
|
||||
const saveNote = async () => {
|
||||
if (!ticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (noteForm.id === null) {
|
||||
await createResearchArtifact({
|
||||
ticker,
|
||||
kind: 'note',
|
||||
source: 'user',
|
||||
title: noteForm.title || undefined,
|
||||
summary: noteForm.summary || undefined,
|
||||
bodyMarkdown: noteForm.bodyMarkdown || undefined,
|
||||
tags: parseTags(noteForm.tags)
|
||||
});
|
||||
setNotice('Saved note to the research library.');
|
||||
} else {
|
||||
await updateResearchArtifact(noteForm.id, {
|
||||
title: noteForm.title || undefined,
|
||||
summary: noteForm.summary || undefined,
|
||||
bodyMarkdown: noteForm.bodyMarkdown || undefined,
|
||||
tags: parseTags(noteForm.tags)
|
||||
});
|
||||
setNotice('Updated research note.');
|
||||
}
|
||||
|
||||
setNoteForm(EMPTY_NOTE_FORM);
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save note');
|
||||
}
|
||||
};
|
||||
|
||||
const saveMemo = async () => {
|
||||
if (!ticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertResearchMemo({
|
||||
ticker,
|
||||
rating: memoForm.rating ? memoForm.rating as ResearchMemo['rating'] : null,
|
||||
conviction: memoForm.conviction ? memoForm.conviction as ResearchMemo['conviction'] : null,
|
||||
timeHorizonMonths: memoForm.timeHorizonMonths ? Number(memoForm.timeHorizonMonths) : null,
|
||||
packetTitle: memoForm.packetTitle || undefined,
|
||||
packetSubtitle: memoForm.packetSubtitle || undefined,
|
||||
thesisMarkdown: memoForm.thesisMarkdown,
|
||||
variantViewMarkdown: memoForm.variantViewMarkdown,
|
||||
catalystsMarkdown: memoForm.catalystsMarkdown,
|
||||
risksMarkdown: memoForm.risksMarkdown,
|
||||
disconfirmingEvidenceMarkdown: memoForm.disconfirmingEvidenceMarkdown,
|
||||
nextActionsMarkdown: memoForm.nextActionsMarkdown
|
||||
});
|
||||
setNotice('Saved investment memo.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save investment memo');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFileToLibrary = async () => {
|
||||
if (!ticker || !uploadFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadResearchArtifact({
|
||||
ticker,
|
||||
file: uploadFile,
|
||||
title: uploadTitle || undefined,
|
||||
summary: uploadSummary || undefined,
|
||||
tags: parseTags(uploadTags)
|
||||
});
|
||||
setUploadFile(null);
|
||||
setUploadTitle('');
|
||||
setUploadSummary('');
|
||||
setUploadTags('');
|
||||
setNotice('Uploaded research file.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to upload research file');
|
||||
}
|
||||
};
|
||||
|
||||
const ensureMemo = async () => {
|
||||
if (!ticker) {
|
||||
throw new Error('Ticker is required');
|
||||
}
|
||||
|
||||
if (workspace?.memo) {
|
||||
return workspace.memo.id;
|
||||
}
|
||||
|
||||
const response = await upsertResearchMemo({ ticker });
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
return response.memo.id;
|
||||
};
|
||||
|
||||
const attachArtifact = async (artifact: ResearchArtifact) => {
|
||||
try {
|
||||
const memoId = await ensureMemo();
|
||||
await addResearchMemoEvidence({
|
||||
memoId,
|
||||
artifactId: artifact.id,
|
||||
section: attachSection
|
||||
});
|
||||
setNotice(`Attached evidence to ${MEMO_SECTIONS.find((item) => item.value === attachSection)?.label}.`);
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to attach evidence');
|
||||
}
|
||||
};
|
||||
|
||||
const availableTags = workspace?.availableTags ?? [];
|
||||
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Research"
|
||||
subtitle="Build an investor-grade company dossier with evidence-linked memo sections, uploads, and a packet view."
|
||||
activeTicker={ticker || null}
|
||||
breadcrumbs={[
|
||||
{ label: 'Overview', href: ticker ? `/analysis?ticker=${encodeURIComponent(ticker)}` : '/analysis' },
|
||||
{ label: 'Research' }
|
||||
]}
|
||||
actions={(
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void invalidateResearch(ticker);
|
||||
void loadWorkspace(ticker);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{ticker ? (
|
||||
<Link
|
||||
href={`/analysis?ticker=${encodeURIComponent(ticker)}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(ticker)}
|
||||
onFocus={() => prefetchResearchTicker(ticker)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open overview
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{!ticker ? (
|
||||
<Panel title="Select a company" subtitle="Open this page with `?ticker=NVDA` or use the Coverage and Overview surfaces to pivot into research.">
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">This workspace is company-first by design and activates once a ticker is selected.</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{ticker && workspace ? (
|
||||
<>
|
||||
<Panel className="overflow-hidden border-[color:var(--line-strong)] bg-[linear-gradient(135deg,rgba(29,31,36,0.97),rgba(34,37,42,0.9)_45%,rgba(42,46,52,0.94))]">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.6fr_1fr]">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[color:var(--accent)]">Buy-Side Research Workspace</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">{workspace.companyName ?? workspace.ticker}</h2>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
|
||||
{workspace.coverage?.status ? `Coverage: ${workspace.coverage.status}` : 'Not yet on the coverage board'} ·
|
||||
{' '}Last filing: {workspace.latestFilingDate ? formatTimestamp(workspace.latestFilingDate).split(' · ')[0] : 'n/a'} ·
|
||||
{' '}Private by default
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(workspace.coverage?.tags ?? []).map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] px-3 py-1 text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.04)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Memo posture</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">
|
||||
{workspace.memo?.rating ? workspace.memo.rating.replace('_', ' ') : 'Unrated'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">
|
||||
Conviction: {workspace.memo?.conviction ?? 'unset'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.04)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Research depth</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">{workspace.library.length} artifacts</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{memoEvidenceCount} evidence links in the packet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{notice ? (
|
||||
<Panel className="border-[color:var(--line-strong)] bg-[color:var(--panel-soft)]">
|
||||
<p className="text-sm text-[color:var(--accent)]">{notice}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]">
|
||||
<Panel
|
||||
title="Library Filters"
|
||||
subtitle="Narrow the evidence set by structure, ownership, and memo linkage."
|
||||
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
|
||||
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
|
||||
<select aria-label="Artifact type filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
|
||||
{KIND_OPTIONS.map((option) => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
|
||||
<select aria-label="Artifact source filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
|
||||
<select aria-label="Artifact tag filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
|
||||
<option value="">All tags</option>
|
||||
{availableTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
|
||||
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
|
||||
Show memo-linked evidence only
|
||||
</label>
|
||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
|
||||
Access Model
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Panel
|
||||
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
|
||||
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
|
||||
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
|
||||
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
|
||||
<textarea
|
||||
aria-label="Research note body"
|
||||
value={noteForm.bodyMarkdown}
|
||||
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
|
||||
placeholder="Write the actual research note, variant view, or diligence conclusion..."
|
||||
className="min-h-[160px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
||||
/>
|
||||
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => void saveNote()}>
|
||||
<FilePlus2 className="size-4" />
|
||||
{noteForm.id === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{noteForm.id !== null ? (
|
||||
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Research Library"
|
||||
subtitle={`${library.length} artifacts match the current filter set.`}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
|
||||
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
|
||||
{MEMO_SECTIONS.map((section) => (
|
||||
<option key={section.value} value={section.value}>{section.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{library.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
|
||||
) : (
|
||||
library.map((artifact) => (
|
||||
<article key={artifact.id} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
||||
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{artifact.kind === 'upload' && artifact.storage_path ? (
|
||||
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
|
||||
<Download className="size-3" />
|
||||
File
|
||||
</a>
|
||||
) : null}
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
|
||||
<Link2 className="size-3" />
|
||||
Attach
|
||||
</Button>
|
||||
{artifact.kind === 'note' ? (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
{artifact.source === 'user' || artifact.kind === 'upload' ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteResearchArtifact(artifact.id);
|
||||
setNotice('Removed artifact from the library.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{artifact.summary ? (
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
|
||||
) : null}
|
||||
{artifact.body_markdown ? (
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{artifact.linked_to_memo ? (
|
||||
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
|
||||
) : null}
|
||||
{artifact.accession_number ? (
|
||||
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
|
||||
) : null}
|
||||
{artifact.tags.map((tag) => (
|
||||
<button
|
||||
key={`${artifact.id}-${tag}`}
|
||||
type="button"
|
||||
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
|
||||
onClick={() => setTagFilter(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Upload Research"
|
||||
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
|
||||
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
|
||||
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
|
||||
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
|
||||
<input
|
||||
aria-label="Upload file"
|
||||
type="file"
|
||||
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
||||
className="block w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
|
||||
<UploadIcon />
|
||||
Upload file
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Investment Memo"
|
||||
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
|
||||
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
|
||||
<option value="">Rating</option>
|
||||
<option value="strong_buy">Strong Buy</option>
|
||||
<option value="buy">Buy</option>
|
||||
<option value="hold">Hold</option>
|
||||
<option value="sell">Sell</option>
|
||||
</select>
|
||||
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
|
||||
<option value="">Conviction</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
|
||||
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
|
||||
</div>
|
||||
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
|
||||
{MEMO_SECTIONS.map((section) => {
|
||||
const fieldMap: Record<ResearchMemoSection, keyof MemoFormState> = {
|
||||
thesis: 'thesisMarkdown',
|
||||
variant_view: 'variantViewMarkdown',
|
||||
catalysts: 'catalystsMarkdown',
|
||||
risks: 'risksMarkdown',
|
||||
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
|
||||
next_actions: 'nextActionsMarkdown'
|
||||
};
|
||||
const field = fieldMap[section.value];
|
||||
|
||||
return (
|
||||
<div key={section.value}>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
|
||||
<textarea
|
||||
aria-label={`Memo ${section.label}`}
|
||||
value={memoForm[field]}
|
||||
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
|
||||
className="min-h-[108px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
||||
placeholder={`Write ${section.label.toLowerCase()}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button onClick={() => void saveMemo()}>
|
||||
<NotebookPen className="size-4" />
|
||||
Save memo
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Research Packet"
|
||||
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
|
||||
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{workspace.packet.sections.map((section) => (
|
||||
<section key={section.section} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Packet Section</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
|
||||
</div>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
{section.body_markdown || 'No memo content yet for this section.'}
|
||||
</p>
|
||||
{section.evidence.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{section.evidence.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
|
||||
</div>
|
||||
{workspace.memo ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteResearchMemoEvidence(workspace.memo!.id, item.id);
|
||||
setNotice('Removed memo evidence.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{item.annotation ? (
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadIcon() {
|
||||
return <FolderUp className="size-4" />;
|
||||
}
|
||||
269
app/search/page.tsx
Normal file
269
app/search/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect, useMemo, useState, useTransition } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { BrainCircuit, ExternalLink, Search as SearchIcon } from 'lucide-react';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { getSearchAnswer } from '@/lib/api';
|
||||
import { searchQueryOptions } from '@/lib/query/options';
|
||||
import type { SearchAnswerResponse, SearchResult, SearchSource } from '@/lib/types';
|
||||
|
||||
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
|
||||
{ value: 'documents', label: 'Documents' },
|
||||
{ value: 'filings', label: 'Filing briefs' },
|
||||
{ value: 'research', label: 'Research notes' }
|
||||
];
|
||||
|
||||
function parseSourceParams(value: string | null) {
|
||||
if (!value) {
|
||||
return ['documents', 'filings', 'research'] as SearchSource[];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry): entry is SearchSource => entry === 'documents' || entry === 'filings' || entry === 'research');
|
||||
|
||||
return normalized.length > 0 ? [...new Set(normalized)] : ['documents', 'filings', 'research'] as SearchSource[];
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>}>
|
||||
<SearchPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const initialQuery = searchParams.get('q')?.trim() ?? '';
|
||||
const initialTicker = searchParams.get('ticker')?.trim().toUpperCase() ?? '';
|
||||
const initialSources = useMemo(() => parseSourceParams(searchParams.get('sources')), [searchParams]);
|
||||
|
||||
const [queryInput, setQueryInput] = useState(initialQuery);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||
const [ticker, setTicker] = useState(initialTicker);
|
||||
const [sources, setSources] = useState<SearchSource[]>(initialSources);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [answer, setAnswer] = useState<SearchAnswerResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [answerLoading, startAnswerTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryInput(initialQuery);
|
||||
setQuery(initialQuery);
|
||||
setTickerInput(initialTicker);
|
||||
setTicker(initialTicker);
|
||||
setSources(initialSources);
|
||||
}, [initialQuery, initialTicker, initialSources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || !isAuthenticated) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
queryClient.fetchQuery(searchQueryOptions({
|
||||
query,
|
||||
ticker: ticker || undefined,
|
||||
sources,
|
||||
limit: 10
|
||||
})).then((response) => {
|
||||
if (!cancelled) {
|
||||
setResults(response.results);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to search indexed sources');
|
||||
setResults([]);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated, query, queryClient, sources, ticker]);
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>;
|
||||
}
|
||||
|
||||
const runAnswer = () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
startAnswerTransition(() => {
|
||||
setError(null);
|
||||
getSearchAnswer({
|
||||
query,
|
||||
ticker: ticker || undefined,
|
||||
sources,
|
||||
limit: 10
|
||||
}).then((response) => {
|
||||
setAnswer(response);
|
||||
}).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Unable to generate cited answer');
|
||||
setAnswer(null);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Search"
|
||||
subtitle="Hybrid semantic + lexical retrieval across primary filings, filing briefs, and private research notes."
|
||||
activeTicker={ticker || null}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<Panel title="Search Query" subtitle="Run semantic search with an optional ticker filter and source selection.">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setQuery(queryInput.trim());
|
||||
setTicker(tickerInput.trim().toUpperCase());
|
||||
setAnswer(null);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(event) => setQueryInput(event.target.value)}
|
||||
placeholder="Ask about margin drivers, segment commentary, risks, or your notes..."
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={tickerInput}
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker filter (optional)"
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOURCE_OPTIONS.map((option) => {
|
||||
const selected = sources.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={selected ? 'primary' : 'ghost'}
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setSources((current) => {
|
||||
if (selected && current.length > 1) {
|
||||
return current.filter((entry) => entry !== option.value);
|
||||
}
|
||||
|
||||
return selected ? current : [...current, option.value];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="w-full sm:w-auto">
|
||||
<SearchIcon className="size-4" />
|
||||
Search
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" className="w-full sm:w-auto" onClick={runAnswer} disabled={!query.trim() || answerLoading}>
|
||||
<BrainCircuit className="size-4" />
|
||||
{answerLoading ? 'Answering...' : 'Cited answer'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Cited Answer" subtitle="Single-turn answer grounded only in retrieved evidence." variant="surface">
|
||||
{answer ? (
|
||||
<div className="space-y-3">
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">{answer.answer}</p>
|
||||
{answer.citations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{answer.citations.map((citation) => (
|
||||
<Link
|
||||
key={`${citation.chunkId}-${citation.index}`}
|
||||
href={citation.href}
|
||||
className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
|
||||
>
|
||||
<span>[{citation.index}] {citation.label}</span>
|
||||
<ExternalLink className="size-3.5 text-[color:var(--accent)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No supporting citations were strong enough to answer.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Ask a question to synthesize the top retrieved passages into a cited answer.</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Semantic Search"
|
||||
subtitle={query ? `${results.length} results${ticker ? ` for ${ticker}` : ''}.` : 'Search results will appear here.'}
|
||||
variant="surface"
|
||||
>
|
||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Searching indexed sources...</p>
|
||||
) : !query ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Enter a question or topic to search the local RAG index.</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No indexed evidence matched this query.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{results.map((result) => (
|
||||
<article key={result.chunkId} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{result.source} {result.ticker ? `· ${result.ticker}` : ''} {result.filingDate ? `· ${result.filingDate}` : ''}
|
||||
</p>
|
||||
<h3 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{result.title ?? result.citationLabel}</h3>
|
||||
<p className="mt-1 text-xs text-[color:var(--accent)]">{result.citationLabel}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={result.href}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open source
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
{result.headingPath ? (
|
||||
<p className="mt-3 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">{result.headingPath}</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{result.snippet}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,14 @@ type FormState = {
|
||||
tags: string;
|
||||
};
|
||||
|
||||
type PostCreateNotice = {
|
||||
ticker: string;
|
||||
category: string | null;
|
||||
tags: string[];
|
||||
syncState: 'idle' | 'pending' | 'queued';
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: CoverageStatus; label: string }> = [
|
||||
{ value: 'backlog', label: 'Backlog' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
@@ -58,7 +66,7 @@ const EMPTY_FORM: FormState = {
|
||||
tags: ''
|
||||
};
|
||||
|
||||
const SELECT_CLASS_NAME = 'w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]';
|
||||
const SELECT_CLASS_NAME = 'min-h-11 w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]';
|
||||
|
||||
function parseTagsInput(input: string) {
|
||||
const unique = new Set<string>();
|
||||
@@ -117,6 +125,7 @@ export default function WatchlistPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [postCreateNotice, setPostCreateNotice] = useState<PostCreateNotice | null>(null);
|
||||
|
||||
const loadCoverage = useCallback(async () => {
|
||||
const options = watchlistQueryOptions();
|
||||
@@ -170,6 +179,7 @@ export default function WatchlistPage() {
|
||||
}, []);
|
||||
|
||||
const beginEdit = useCallback((item: WatchlistItem) => {
|
||||
setPostCreateNotice(null);
|
||||
setEditingItemId(item.id);
|
||||
setForm({
|
||||
ticker: item.ticker,
|
||||
@@ -191,12 +201,34 @@ export default function WatchlistPage() {
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const queueCoverageSync = useCallback(async (input: {
|
||||
ticker: string;
|
||||
category?: string | null;
|
||||
tags?: string[];
|
||||
}) => {
|
||||
const ticker = input.ticker.trim().toUpperCase();
|
||||
|
||||
await queueFilingSync({
|
||||
ticker,
|
||||
limit: 20,
|
||||
category: input.category ?? undefined,
|
||||
tags: input.tags && input.tags.length > 0 ? input.tags : undefined
|
||||
});
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
||||
void queryClient.invalidateQueries({ queryKey: ['filings', ticker] });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker) });
|
||||
}, [queryClient]);
|
||||
|
||||
const saveCoverage = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isCreate = editingItemId === null;
|
||||
const ticker = form.ticker.trim().toUpperCase();
|
||||
const wasExistingItem = items.some((item) => item.ticker === ticker);
|
||||
const payload = {
|
||||
companyName: form.companyName.trim(),
|
||||
sector: form.sector.trim() || undefined,
|
||||
@@ -206,16 +238,29 @@ export default function WatchlistPage() {
|
||||
tags: parseTagsInput(form.tags)
|
||||
};
|
||||
|
||||
if (editingItemId === null) {
|
||||
await upsertWatchlistItem({
|
||||
ticker: form.ticker.trim().toUpperCase(),
|
||||
if (isCreate) {
|
||||
const response = await upsertWatchlistItem({
|
||||
ticker,
|
||||
...payload
|
||||
});
|
||||
|
||||
if (!wasExistingItem) {
|
||||
setPostCreateNotice({
|
||||
ticker: response.item.ticker,
|
||||
category: response.item.category,
|
||||
tags: response.item.tags,
|
||||
syncState: 'idle',
|
||||
error: null
|
||||
});
|
||||
} else {
|
||||
setPostCreateNotice(null);
|
||||
}
|
||||
} else {
|
||||
await updateWatchlistItem(editingItemId, payload);
|
||||
setPostCreateNotice(null);
|
||||
}
|
||||
|
||||
invalidateCoverageQueries(form.ticker);
|
||||
invalidateCoverageQueries(ticker);
|
||||
await loadCoverage();
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
@@ -248,19 +293,51 @@ export default function WatchlistPage() {
|
||||
|
||||
const queueSync = async (item: WatchlistItem) => {
|
||||
try {
|
||||
await queueFilingSync({
|
||||
setError(null);
|
||||
await queueCoverageSync({
|
||||
ticker: item.ticker,
|
||||
limit: 20,
|
||||
category: item.category ?? undefined,
|
||||
tags: item.tags.length > 0 ? item.tags : undefined
|
||||
category: item.category,
|
||||
tags: item.tags
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.filings(item.ticker, 120) });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to queue filing sync for ${item.ticker}`);
|
||||
}
|
||||
};
|
||||
|
||||
const queuePostCreateSync = async () => {
|
||||
if (!postCreateNotice || postCreateNotice.syncState === 'queued') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPostCreateNotice((current) => current
|
||||
? {
|
||||
...current,
|
||||
syncState: 'pending',
|
||||
error: null
|
||||
}
|
||||
: null);
|
||||
|
||||
try {
|
||||
await queueCoverageSync(postCreateNotice);
|
||||
setPostCreateNotice((current) => current
|
||||
? {
|
||||
...current,
|
||||
syncState: 'queued',
|
||||
error: null
|
||||
}
|
||||
: null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : `Failed to queue filing sync for ${postCreateNotice.ticker}`;
|
||||
setPostCreateNotice((current) => current
|
||||
? {
|
||||
...current,
|
||||
syncState: 'idle',
|
||||
error: message
|
||||
}
|
||||
: null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading coverage terminal...</div>;
|
||||
}
|
||||
@@ -274,6 +351,7 @@ export default function WatchlistPage() {
|
||||
<Panel
|
||||
title="Coverage Board"
|
||||
subtitle={`${items.length} tracked companies across backlog, active work, and archive.`}
|
||||
variant="surface"
|
||||
actions={(
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Input
|
||||
@@ -281,10 +359,11 @@ export default function WatchlistPage() {
|
||||
aria-label="Search coverage"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search ticker, company, tag, sector..."
|
||||
className="min-w-[18rem]"
|
||||
className="w-full sm:min-w-[18rem]"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
void loadCoverage();
|
||||
@@ -302,31 +381,28 @@ export default function WatchlistPage() {
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No coverage items match the current search.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table min-w-[1120px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Tags</th>
|
||||
<th>Last Filing</th>
|
||||
<th>Last Reviewed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{filteredItems.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-[color:var(--terminal-bright)]">{item.ticker}</div>
|
||||
<div className="text-sm text-[color:var(--terminal-bright)]">{item.company_name}</div>
|
||||
<div className="text-xs text-[color:var(--terminal-muted)]">
|
||||
{item.sector ?? 'Unclassified'}
|
||||
{item.category ? ` · ${item.category}` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
</div>
|
||||
<div className="text-right text-xs text-[color:var(--terminal-muted)]">
|
||||
<p>Last filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{formatDateOnly(item.latest_filing_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</label>
|
||||
<select
|
||||
aria-label={`${item.ticker} status`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
@@ -343,8 +419,9 @@ export default function WatchlistPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Priority</label>
|
||||
<select
|
||||
aria-label={`${item.ticker} priority`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
@@ -361,100 +438,266 @@ export default function WatchlistPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{item.tags.length > 0 ? (
|
||||
<div className="flex max-w-[18rem] flex-wrap gap-1">
|
||||
{item.tags.map((tag) => (
|
||||
<span
|
||||
key={`${item.id}-${tag}`}
|
||||
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{item.tags.length > 0 ? item.tags.map((tag) => (
|
||||
<span
|
||||
key={`${item.id}-${tag}`}
|
||||
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)) : <span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-[color:var(--terminal-muted)]">Last reviewed: {formatDateTime(item.last_reviewed_at)}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open overview
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
||||
Sync filings
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void updateCoverageInline(item, {
|
||||
lastReviewedAt: new Date().toISOString()
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="size-3" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEdit(item)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteWatchlistItem(item.id);
|
||||
invalidateCoverageQueries(item.ticker);
|
||||
await loadCoverage();
|
||||
if (postCreateNotice?.ticker === item.ticker) {
|
||||
setPostCreateNotice(null);
|
||||
}
|
||||
if (editingItemId === item.id) {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="data-table-wrap hidden lg:block">
|
||||
<table className="data-table min-w-[1120px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Tags</th>
|
||||
<th>Last Filing</th>
|
||||
<th>Last Reviewed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div className="font-medium text-[color:var(--terminal-bright)]">{item.ticker}</div>
|
||||
<div className="text-sm text-[color:var(--terminal-bright)]">{item.company_name}</div>
|
||||
<div className="text-xs text-[color:var(--terminal-muted)]">
|
||||
{item.sector ?? 'Unclassified'}
|
||||
{item.category ? ` · ${item.category}` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDateOnly(item.latest_filing_date)}</td>
|
||||
<td>{formatDateTime(item.last_reviewed_at)}</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analyze
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`${item.ticker} status`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={item.status}
|
||||
onChange={(event) => {
|
||||
void updateCoverageInline(item, {
|
||||
lastReviewedAt: new Date().toISOString()
|
||||
status: event.target.value as CoverageStatus
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="size-3" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEdit(item)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteWatchlistItem(item.id);
|
||||
invalidateCoverageQueries(item.ticker);
|
||||
await loadCoverage();
|
||||
if (editingItemId === item.id) {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
|
||||
}
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`${item.ticker} priority`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={item.priority}
|
||||
onChange={(event) => {
|
||||
void updateCoverageInline(item, {
|
||||
priority: event.target.value as CoveragePriority
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{item.tags.length > 0 ? (
|
||||
<div className="flex max-w-[18rem] flex-wrap gap-1">
|
||||
{item.tags.map((tag) => (
|
||||
<span
|
||||
key={`${item.id}-${tag}`}
|
||||
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDateOnly(item.latest_filing_date)}</td>
|
||||
<td>{formatDateTime(item.last_reviewed_at)}</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open overview
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/research?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Research
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
||||
Sync filings
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void updateCoverageInline(item, {
|
||||
lastReviewedAt: new Date().toISOString()
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="size-3" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEdit(item)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteWatchlistItem(item.id);
|
||||
invalidateCoverageQueries(item.ticker);
|
||||
await loadCoverage();
|
||||
if (postCreateNotice?.ticker === item.ticker) {
|
||||
setPostCreateNotice(null);
|
||||
}
|
||||
if (editingItemId === item.id) {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -462,8 +705,53 @@ export default function WatchlistPage() {
|
||||
<Panel
|
||||
title={editingItemId === null ? 'Add Coverage' : 'Edit Coverage'}
|
||||
subtitle={editingItemId === null ? 'Create a new company coverage record.' : 'Update metadata, priority, and workflow status.'}
|
||||
variant="surface"
|
||||
>
|
||||
<form onSubmit={saveCoverage} className="space-y-3">
|
||||
{editingItemId === null ? (
|
||||
<p className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-muted)]">
|
||||
Saving coverage adds the company to your board only. Filing sync starts when you choose Sync filings.
|
||||
</p>
|
||||
) : null}
|
||||
{postCreateNotice ? (
|
||||
<div
|
||||
data-testid="watchlist-post-create-notice"
|
||||
className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm"
|
||||
>
|
||||
<p className="text-[color:var(--terminal-bright)]">
|
||||
{postCreateNotice.syncState === 'queued'
|
||||
? `${postCreateNotice.ticker} added to coverage. Filing sync is queued.`
|
||||
: `${postCreateNotice.ticker} added to coverage. Filing sync has not started yet.`}
|
||||
</p>
|
||||
{postCreateNotice.error ? (
|
||||
<p className="mt-2 text-sm text-[#ffb5b5]">{postCreateNotice.error}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={postCreateNotice.syncState === 'pending' || postCreateNotice.syncState === 'queued'}
|
||||
onClick={() => {
|
||||
void queuePostCreateSync();
|
||||
}}
|
||||
>
|
||||
{postCreateNotice.syncState === 'pending'
|
||||
? 'Queueing...'
|
||||
: postCreateNotice.syncState === 'queued'
|
||||
? 'Sync queued'
|
||||
: 'Sync filings'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setPostCreateNotice(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Ticker</label>
|
||||
<Input
|
||||
@@ -542,12 +830,12 @@ export default function WatchlistPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="flex-1" disabled={saving}>
|
||||
<Button type="submit" className="w-full sm:flex-1" disabled={saving}>
|
||||
<Plus className="size-4" />
|
||||
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
|
||||
</Button>
|
||||
{editingItemId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetForm}>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetForm}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { runTaskProcessor } from '@/lib/server/task-processors';
|
||||
import { runTaskProcessor, type TaskExecutionOutcome } from '@/lib/server/task-processors';
|
||||
import { describeTaskFailure } from '@/lib/server/task-errors';
|
||||
import {
|
||||
completeTask,
|
||||
getTaskById,
|
||||
@@ -23,14 +24,12 @@ export async function runTaskWorkflow(taskId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await processTaskStep(refreshedTask);
|
||||
await completeTaskStep(task.id, result);
|
||||
const outcome = await processTaskStep(refreshedTask);
|
||||
await completeTaskStep(task.id, outcome);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error
|
||||
? error.message
|
||||
: 'Task failed unexpectedly';
|
||||
|
||||
await markTaskFailureStep(task.id, reason);
|
||||
const latestTask = await loadTaskStep(task.id);
|
||||
const failure = describeTaskFailure(latestTask ?? task, error);
|
||||
await markTaskFailureStep(task.id, failure, latestTask ?? task);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -52,15 +51,21 @@ async function processTaskStep(task: Task) {
|
||||
|
||||
// Keep retries at the projection workflow level to avoid duplicate side effects.
|
||||
(
|
||||
processTaskStep as ((task: Task) => Promise<Record<string, unknown>>) & { maxRetries?: number }
|
||||
processTaskStep as ((task: Task) => Promise<TaskExecutionOutcome>) & { maxRetries?: number }
|
||||
).maxRetries = 0;
|
||||
|
||||
async function completeTaskStep(taskId: string, result: Record<string, unknown>) {
|
||||
async function completeTaskStep(taskId: string, outcome: TaskExecutionOutcome) {
|
||||
'use step';
|
||||
await completeTask(taskId, result);
|
||||
await completeTask(taskId, outcome.result, {
|
||||
detail: outcome.completionDetail,
|
||||
context: outcome.completionContext ?? null
|
||||
});
|
||||
}
|
||||
|
||||
async function markTaskFailureStep(taskId: string, reason: string) {
|
||||
async function markTaskFailureStep(taskId: string, failure: { summary: string; detail: string }, latestTask: Task) {
|
||||
'use step';
|
||||
await markTaskFailure(taskId, reason);
|
||||
await markTaskFailure(taskId, failure.detail, latestTask.stage === 'completed' ? 'failed' : latestTask.stage, {
|
||||
detail: failure.summary,
|
||||
context: latestTask.stage_context ?? null
|
||||
});
|
||||
}
|
||||
|
||||
69
components/analysis/analysis-toolbar.tsx
Normal file
69
components/analysis/analysis-toolbar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type AnalysisToolbarProps = {
|
||||
tickerInput: string;
|
||||
currentTicker: string;
|
||||
onTickerInputChange: (value: string) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
onRefresh: () => void;
|
||||
quickLinks: {
|
||||
research: string;
|
||||
filings: string;
|
||||
financials: string;
|
||||
graphing: string;
|
||||
};
|
||||
onLinkPrefetch?: () => void;
|
||||
};
|
||||
|
||||
export function AnalysisToolbar(props: AnalysisToolbarProps) {
|
||||
return (
|
||||
<form
|
||||
className="border-t border-[color:var(--line-weak)] pt-4"
|
||||
onSubmit={props.onSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="panel-heading text-xs uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Company overview</p>
|
||||
<h2 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Inspect the latest high-level picture for {props.currentTicker}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={props.tickerInput}
|
||||
aria-label="Overview ticker"
|
||||
onChange={(event) => props.onTickerInputChange(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="w-full sm:min-w-[180px]"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Search className="size-4" />
|
||||
Load overview
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={props.onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-sm">
|
||||
<Link href={props.quickLinks.research} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
|
||||
Research
|
||||
</Link>
|
||||
<Link href={props.quickLinks.filings} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
|
||||
Filings
|
||||
</Link>
|
||||
<Link href={props.quickLinks.financials} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
|
||||
Financials
|
||||
</Link>
|
||||
<Link href={props.quickLinks.graphing} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
|
||||
Graphing
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
68
components/analysis/bull-bear-panel.tsx
Normal file
68
components/analysis/bull-bear-panel.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Link from 'next/link';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import type { CompanyBullBear } from '@/lib/types';
|
||||
|
||||
type BullBearPanelProps = {
|
||||
bullBear: CompanyBullBear;
|
||||
researchHref: string;
|
||||
onLinkPrefetch?: () => void;
|
||||
};
|
||||
|
||||
export function BullBearPanel(props: BullBearPanelProps) {
|
||||
const hasContent = props.bullBear.bull.length > 0 || props.bullBear.bear.length > 0;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Bull vs Bear"
|
||||
subtitle="The highest-level reasons investors may lean in or lean out right now."
|
||||
className="pt-2"
|
||||
>
|
||||
{!hasContent ? (
|
||||
<div className="border-t border-dashed border-[color:var(--line-weak)] py-5 text-sm text-[color:var(--terminal-muted)]">
|
||||
No synthesis inputs are available yet. Add memo sections or filing context in Research to populate this debate surface.
|
||||
<div className="mt-4">
|
||||
<Link href={props.researchHref} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
Open research workspace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="border-t border-[rgba(150,245,191,0.24)] pt-5">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Bull case</h3>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{props.bullBear.bull.map((item) => (
|
||||
<li key={item} className="border-t border-[rgba(150,245,191,0.16)] pt-3">
|
||||
<div className="flex gap-3 text-sm leading-6 text-[color:var(--terminal-bright)]">
|
||||
<span
|
||||
className="mt-1.5 h-0 w-0 shrink-0 border-x-[5px] border-x-transparent border-b-[8px] border-b-[#4ade80]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-[rgba(255,159,159,0.24)] pt-5">
|
||||
<h3 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Bear case</h3>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{props.bullBear.bear.map((item) => (
|
||||
<li key={item} className="border-t border-[rgba(255,159,159,0.16)] pt-3">
|
||||
<div className="flex gap-3 text-sm leading-6 text-[color:var(--terminal-bright)]">
|
||||
<span
|
||||
className="mt-1.5 h-0 w-0 shrink-0 border-x-[5px] border-x-transparent border-t-[8px] border-t-[#f87171]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
88
components/analysis/company-overview-card.tsx
Normal file
88
components/analysis/company-overview-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Panel } from "@/components/ui/panel";
|
||||
import type { CompanyAnalysis } from "@/lib/types";
|
||||
|
||||
type CompanyOverviewCardProps = {
|
||||
analysis: CompanyAnalysis;
|
||||
};
|
||||
|
||||
export function CompanyOverviewCard(props: CompanyOverviewCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Get the actual business description, filtering out raw data artifacts
|
||||
const rawDescription = props.analysis.companyProfile.description;
|
||||
const isRawData = rawDescription && (
|
||||
rawDescription.includes('http://') ||
|
||||
rawDescription.includes('P1Y') ||
|
||||
rawDescription.includes('P3Y') ||
|
||||
rawDescription.includes('FY false')
|
||||
);
|
||||
|
||||
const description = !rawDescription || isRawData
|
||||
? "No business description available."
|
||||
: rawDescription;
|
||||
|
||||
const needsClamp = description.length > 320;
|
||||
|
||||
// Combine metadata into a single line
|
||||
const metadata = [
|
||||
props.analysis.company.ticker,
|
||||
props.analysis.company.sector ?? props.analysis.companyProfile.industry,
|
||||
props.analysis.company.cik ? `CIK ${props.analysis.company.cik}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<Panel className="h-full pt-2">
|
||||
<div className="space-y-5">
|
||||
{/* Header with company name and metadata */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[color:var(--terminal-bright)]">
|
||||
{props.analysis.company.companyName}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">
|
||||
{metadata}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Business description section */}
|
||||
<div className="border-t border-[color:var(--line-weak)] py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
Business description
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
{expanded || !needsClamp
|
||||
? description
|
||||
: `${description.slice(0, 320).trimEnd()}...`}
|
||||
</p>
|
||||
</div>
|
||||
{props.analysis.companyProfile.website ? (
|
||||
<a
|
||||
href={props.analysis.companyProfile.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Website
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{needsClamp ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="mt-3 text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
{expanded ? "Show less" : "Read more"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
70
components/analysis/company-profile-facts-table.tsx
Normal file
70
components/analysis/company-profile-facts-table.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { formatScaledNumber } from '@/lib/format';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
|
||||
type CompanyProfileFactsTableProps = {
|
||||
analysis: CompanyAnalysis;
|
||||
};
|
||||
|
||||
function factValue(value: string | null | undefined) {
|
||||
return value && value.trim().length > 0 ? value : 'n/a';
|
||||
}
|
||||
|
||||
function employeeCountLabel(value: number | null) {
|
||||
return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
export function CompanyProfileFactsTable(props: CompanyProfileFactsTableProps) {
|
||||
const items = [
|
||||
{ label: 'Exchange', value: factValue(props.analysis.companyProfile.exchange) },
|
||||
{ label: 'Industry', value: factValue(props.analysis.companyProfile.industry ?? props.analysis.company.sector) },
|
||||
{ label: 'Country / state', value: factValue(props.analysis.companyProfile.country) },
|
||||
{ label: 'Fiscal year end', value: factValue(props.analysis.companyProfile.fiscalYearEnd) },
|
||||
{ label: 'Employees', value: employeeCountLabel(props.analysis.companyProfile.employeeCount) },
|
||||
{ label: 'Website', value: factValue(props.analysis.companyProfile.website) },
|
||||
{ label: 'Category', value: factValue(props.analysis.company.category) },
|
||||
{ label: 'CIK', value: factValue(props.analysis.company.cik) }
|
||||
];
|
||||
const rows = Array.from({ length: Math.ceil(items.length / 2) }, (_, index) => items.slice(index * 2, index * 2 + 2));
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Company profile facts"
|
||||
className="pt-2"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse table-fixed">
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.map((item) => item.label).join('-')} className="border-t border-[color:var(--line-weak)]">
|
||||
{row.map((item) => (
|
||||
<Fragment key={item.label}>
|
||||
<th className="w-[18%] py-2 pr-3 text-left align-top text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{item.label}
|
||||
</th>
|
||||
<td className="w-[32%] py-2 pr-4 text-sm text-[color:var(--terminal-bright)]">
|
||||
{item.label === 'Website' && item.value !== 'n/a' ? (
|
||||
<a href={item.value} target="_blank" rel="noreferrer" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
{item.value}
|
||||
</a>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</td>
|
||||
</Fragment>
|
||||
))}
|
||||
{row.length === 1 ? (
|
||||
<>
|
||||
<th className="w-[18%] py-2 pr-3" />
|
||||
<td className="w-[32%] py-2 pr-4" />
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
147
components/analysis/price-history-card.tsx
Normal file
147
components/analysis/price-history-card.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { formatCurrency } from '@/lib/format';
|
||||
import { InteractivePriceChart } from '@/components/charts/interactive-price-chart';
|
||||
import type { DataSeries, Holding, PriceData } from '@/lib/types';
|
||||
|
||||
type PriceHistoryCardProps = {
|
||||
loading: boolean;
|
||||
priceHistory: PriceData<Array<{ date: string; close: number }> | null>;
|
||||
benchmarkHistory: PriceData<Array<{ date: string; close: number }> | null>;
|
||||
quote: PriceData<number | null>;
|
||||
position: Holding | null;
|
||||
};
|
||||
|
||||
function formatLongDate(value: string) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function PriceHistoryCard(props: PriceHistoryCardProps) {
|
||||
const priceHistoryValue = props.priceHistory.value;
|
||||
const benchmarkHistoryValue = props.benchmarkHistory.value;
|
||||
const quoteValue = props.quote.value;
|
||||
|
||||
const firstPoint = priceHistoryValue?.[0];
|
||||
const lastPoint = priceHistoryValue?.[priceHistoryValue.length - 1];
|
||||
const inPortfolio = props.position !== null;
|
||||
const hasPriceData = priceHistoryValue !== null && priceHistoryValue.length > 0;
|
||||
|
||||
const defaultChange = firstPoint && lastPoint ? lastPoint.close - firstPoint.close : null;
|
||||
const defaultChangePct = firstPoint && firstPoint.close > 0 && defaultChange !== null
|
||||
? (defaultChange / firstPoint.close) * 100
|
||||
: null;
|
||||
const holdingChange = asFiniteNumber(props.position?.gain_loss);
|
||||
const holdingChangePct = asFiniteNumber(props.position?.gain_loss_pct);
|
||||
|
||||
const change = inPortfolio ? holdingChange : defaultChange;
|
||||
const changePct = inPortfolio ? holdingChangePct : defaultChangePct;
|
||||
const rangeLabel = inPortfolio ? 'Holding return' : '1Y return';
|
||||
const changeLabel = inPortfolio ? 'Holding P/L' : '1Y change';
|
||||
const dateRange = inPortfolio
|
||||
? props.position?.created_at && (props.position.last_price_at ?? lastPoint?.date)
|
||||
? `${formatLongDate(props.position.created_at)} to ${formatLongDate(props.position.last_price_at ?? lastPoint?.date ?? '')}`
|
||||
: 'No holding period available'
|
||||
: firstPoint && lastPoint
|
||||
? `${formatLongDate(firstPoint.date)} to ${formatLongDate(lastPoint.date)}`
|
||||
: 'No comparison range';
|
||||
const statusToneClass = inPortfolio ? 'text-[#96f5bf]' : 'text-[color:var(--terminal-bright)]';
|
||||
const performanceToneClass = change !== null && change < 0 ? 'text-[#ff9f9f]' : 'text-[#96f5bf]';
|
||||
|
||||
const chartData = priceHistoryValue?.map(point => ({
|
||||
date: point.date,
|
||||
price: point.close
|
||||
})) ?? [];
|
||||
|
||||
const benchmarkData = benchmarkHistoryValue?.map((point) => ({
|
||||
date: point.date,
|
||||
price: point.close
|
||||
})) ?? [];
|
||||
|
||||
const comparisonSeries: DataSeries[] = [
|
||||
{
|
||||
id: 'stock',
|
||||
label: 'Stock',
|
||||
data: chartData,
|
||||
color: '#96f5bf',
|
||||
type: 'line'
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
label: 'S&P 500',
|
||||
data: benchmarkData,
|
||||
color: '#8ba0b8',
|
||||
type: 'line'
|
||||
}
|
||||
];
|
||||
|
||||
const quoteAvailable = quoteValue !== null && Number.isFinite(quoteValue);
|
||||
const staleIndicator = props.quote.stale ? ' (stale)' : '';
|
||||
const helperText = quoteAvailable
|
||||
? `Spot price ${formatCurrency(quoteValue)}${staleIndicator}`
|
||||
: 'Spot price unavailable';
|
||||
|
||||
return (
|
||||
<Panel title="Price chart" subtitle={hasPriceData ? 'Interactive chart with historical data' : 'Price data unavailable'}>
|
||||
<div className="space-y-4">
|
||||
{!hasPriceData && (
|
||||
<div className="rounded border border-[color:var(--line-weak)] bg-[color:var(--surface-dim)] px-4 py-3">
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">
|
||||
Price history data is currently unavailable. This may be due to a temporary issue with the market data provider.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Portfolio status</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${statusToneClass}`}>{inPortfolio ? 'In portfolio' : 'Not in portfolio'}</p>
|
||||
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">{helperText}</p>
|
||||
</div>
|
||||
<div className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{changeLabel}</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${performanceToneClass}`}>
|
||||
{change === null ? 'n/a' : formatCurrency(change)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{rangeLabel}</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${performanceToneClass}`}>
|
||||
{changePct === null ? 'n/a' : `${changePct.toFixed(2)}%`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
{dateRange}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPriceData && (
|
||||
<InteractivePriceChart
|
||||
data={chartData}
|
||||
dataSeries={comparisonSeries}
|
||||
defaultChartType="line"
|
||||
defaultTimeRange="1Y"
|
||||
showVolume={false}
|
||||
showToolbar={true}
|
||||
height={320}
|
||||
loading={props.loading}
|
||||
formatters={{
|
||||
price: formatCurrency,
|
||||
date: (date: string) => format(new Date(date), 'MMM dd, yyyy')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
58
components/analysis/recent-developments-section.tsx
Normal file
58
components/analysis/recent-developments-section.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { format } from 'date-fns';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { WeeklySnapshotCard } from '@/components/analysis/weekly-snapshot-card';
|
||||
import type { RecentDevelopments } from '@/lib/types';
|
||||
|
||||
type RecentDevelopmentsSectionProps = {
|
||||
recentDevelopments: RecentDevelopments;
|
||||
};
|
||||
|
||||
function formatDate(value: string) {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
export function RecentDevelopmentsSection(props: RecentDevelopmentsSectionProps) {
|
||||
return (
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(280px,0.72fr)_minmax(0,1.28fr)]">
|
||||
<WeeklySnapshotCard snapshot={props.recentDevelopments.weeklySnapshot} />
|
||||
|
||||
<Panel title="Recent Developments" subtitle="SEC-first event cards sourced from filings and attached analysis." className="pt-2">
|
||||
{props.recentDevelopments.items.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No recent development items are available for this ticker yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{props.recentDevelopments.items.map((item) => (
|
||||
<article key={item.id} className="border-t border-[color:var(--line-weak)] pt-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{item.kind} · {formatDate(item.publishedAt)}
|
||||
</p>
|
||||
<h3 className="mt-2 text-base font-semibold text-[color:var(--terminal-bright)]">{item.title}</h3>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{item.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{item.summary ?? 'No summary is available for this development item yet.'}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{item.accessionNumber ?? 'No accession'}
|
||||
</p>
|
||||
{item.url ? (
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
Open filing
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
67
components/analysis/valuation-facts-table.tsx
Normal file
67
components/analysis/valuation-facts-table.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { formatCompactCurrency, formatScaledNumber } from '@/lib/format';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
|
||||
type ValuationFactsTableProps = {
|
||||
analysis: CompanyAnalysis;
|
||||
};
|
||||
|
||||
function formatRatio(value: number | null) {
|
||||
return value === null ? '—' : `${value.toFixed(2)}x`;
|
||||
}
|
||||
|
||||
function formatShares(value: number | null) {
|
||||
return value === null ? '—' : formatScaledNumber(value, { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatCompactCurrencyOrDash(value: number | null) {
|
||||
return value === null ? '—' : formatCompactCurrency(value);
|
||||
}
|
||||
|
||||
export function ValuationFactsTable(props: ValuationFactsTableProps) {
|
||||
const items = [
|
||||
{ label: 'Source', value: props.analysis.valuationSnapshot.source },
|
||||
{ label: 'Market cap', value: formatCompactCurrencyOrDash(props.analysis.valuationSnapshot.marketCap) },
|
||||
{ label: 'Enterprise value', value: formatCompactCurrencyOrDash(props.analysis.valuationSnapshot.enterpriseValue) },
|
||||
{ label: 'Shares outstanding', value: formatShares(props.analysis.valuationSnapshot.sharesOutstanding) },
|
||||
{ label: 'Trailing P/E', value: formatRatio(props.analysis.valuationSnapshot.trailingPe) },
|
||||
{ label: 'EV / Revenue', value: formatRatio(props.analysis.valuationSnapshot.evToRevenue) },
|
||||
{ label: 'EV / EBITDA', value: formatRatio(props.analysis.valuationSnapshot.evToEbitda) }
|
||||
];
|
||||
const rows = Array.from({ length: Math.ceil(items.length / 2) }, (_, index) => items.slice(index * 2, index * 2 + 2));
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Valuation"
|
||||
className="pt-2"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse table-fixed">
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.map((item) => item.label).join('-')} className="border-t border-[color:var(--line-weak)]">
|
||||
{row.map((item) => (
|
||||
<Fragment key={item.label}>
|
||||
<th className="w-[18%] py-2 pr-3 text-left align-top text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{item.label}
|
||||
</th>
|
||||
<td className="w-[32%] py-2 pr-4 text-sm text-[color:var(--terminal-bright)]">
|
||||
{item.value}
|
||||
</td>
|
||||
</Fragment>
|
||||
))}
|
||||
{row.length === 1 ? (
|
||||
<>
|
||||
<th className="w-[18%] py-2 pr-3" />
|
||||
<td className="w-[32%] py-2 pr-4" />
|
||||
</>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
36
components/analysis/valuation-stat-grid.tsx
Normal file
36
components/analysis/valuation-stat-grid.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { CompanyValuationSnapshot } from '@/lib/types';
|
||||
import { formatCompactCurrency, formatScaledNumber } from '@/lib/format';
|
||||
|
||||
type ValuationStatGridProps = {
|
||||
valuation: CompanyValuationSnapshot;
|
||||
};
|
||||
|
||||
function formatRatio(value: number | null) {
|
||||
return value === null ? 'n/a' : `${value.toFixed(2)}x`;
|
||||
}
|
||||
|
||||
function formatShares(value: number | null) {
|
||||
return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function ValuationStatGrid(props: ValuationStatGridProps) {
|
||||
const items = [
|
||||
{ label: 'Market cap', value: props.valuation.marketCap === null ? 'n/a' : formatCompactCurrency(props.valuation.marketCap) },
|
||||
{ label: 'Enterprise value', value: props.valuation.enterpriseValue === null ? 'n/a' : formatCompactCurrency(props.valuation.enterpriseValue) },
|
||||
{ label: 'Shares out.', value: formatShares(props.valuation.sharesOutstanding) },
|
||||
{ label: 'Trailing P/E', value: formatRatio(props.valuation.trailingPe) },
|
||||
{ label: 'EV / Revenue', value: formatRatio(props.valuation.evToRevenue) },
|
||||
{ label: 'EV / EBITDA', value: formatRatio(props.valuation.evToEbitda) }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.label}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
components/analysis/weekly-snapshot-card.tsx
Normal file
35
components/analysis/weekly-snapshot-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import type { RecentDevelopmentsWeeklySnapshot } from '@/lib/types';
|
||||
|
||||
type WeeklySnapshotCardProps = {
|
||||
snapshot: RecentDevelopmentsWeeklySnapshot | null;
|
||||
};
|
||||
|
||||
export function WeeklySnapshotCard(props: WeeklySnapshotCardProps) {
|
||||
return (
|
||||
<Panel title="Past 7 Days" subtitle="A compact narrative of the most recent filing-driven developments." className="h-full pt-2">
|
||||
{props.snapshot ? (
|
||||
<div className="space-y-4">
|
||||
<div className="border-t border-[color:var(--line-weak)] py-4">
|
||||
<p className="text-sm leading-7 text-[color:var(--terminal-bright)]">{props.snapshot.summary}</p>
|
||||
</div>
|
||||
{props.snapshot.highlights.length > 0 ? (
|
||||
<ul className="space-y-3">
|
||||
{props.snapshot.highlights.map((highlight) => (
|
||||
<li key={highlight} className="border-t border-[color:var(--line-weak)] pt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">
|
||||
{highlight}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
<span>{props.snapshot.itemCount} tracked items</span>
|
||||
<span>{props.snapshot.startDate} to {props.snapshot.endDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No weekly snapshot is available yet.</p>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,12 @@ type AuthShellProps = {
|
||||
export function AuthShell({ title, subtitle, children, footer }: AuthShellProps) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="ambient-grid" aria-hidden="true" />
|
||||
<div className="noise-layer" aria-hidden="true" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-5xl flex-col justify-center gap-8 px-4 py-10 md:px-8 lg:flex-row lg:items-center">
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-6 lg:w-[42%]">
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-5xl flex-col justify-center gap-5 px-4 py-6 sm:gap-8 sm:py-10 md:px-8 lg:flex-row lg:items-center">
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 sm:p-6 lg:w-[42%]">
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">Autonomous Analyst Desk</h1>
|
||||
<h1 className="mt-3 text-2xl font-semibold text-[color:var(--terminal-bright)] sm:text-3xl">Autonomous Analyst Desk</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-muted)]">
|
||||
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows powered by AI SDK.
|
||||
</p>
|
||||
@@ -29,8 +28,8 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-6 shadow-[0_20px_60px_rgba(1,4,10,0.55)] lg:w-[58%]">
|
||||
<h2 className="text-2xl font-semibold text-[color:var(--terminal-bright)]">{title}</h2>
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_20px_60px_rgba(1,4,10,0.55)] sm:p-6 lg:w-[58%]">
|
||||
<h2 className="text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl">{title}</h2>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
|
||||
<div className="mt-6">{children}</div>
|
||||
|
||||
38
components/charts/hooks/use-chart-export.ts
Normal file
38
components/charts/hooks/use-chart-export.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { getChartColors, getComputedColors } from '../utils/chart-colors';
|
||||
|
||||
export function useChartExport() {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const exportChart = useCallback(async (filename: string = 'chart.png') => {
|
||||
if (!chartRef.current) {
|
||||
console.error('Chart ref not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get background color from CSS variable
|
||||
const colors = getChartColors();
|
||||
const computedColors = getComputedColors(colors);
|
||||
const backgroundColor = computedColors.tooltipBg || colors.tooltipBg;
|
||||
|
||||
const dataUrl = await toPng(chartRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // High DPI export
|
||||
backgroundColor: backgroundColor
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} catch (error) {
|
||||
console.error('Failed to export chart:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { chartRef, exportChart };
|
||||
}
|
||||
44
components/charts/hooks/use-chart-zoom.ts
Normal file
44
components/charts/hooks/use-chart-zoom.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ChartZoomState } from '@/lib/types';
|
||||
|
||||
export function useChartZoom(dataLength: number) {
|
||||
const [zoomState, setZoomState] = useState<ChartZoomState>({
|
||||
startIndex: 0,
|
||||
endIndex: Math.max(0, dataLength - 1),
|
||||
isZoomed: false
|
||||
});
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(brushData: { startIndex?: number; endIndex?: number }) => {
|
||||
if (
|
||||
brushData.startIndex === undefined ||
|
||||
brushData.endIndex === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZoomState({
|
||||
startIndex: brushData.startIndex,
|
||||
endIndex: brushData.endIndex,
|
||||
isZoomed:
|
||||
brushData.startIndex !== 0 ||
|
||||
brushData.endIndex !== dataLength - 1
|
||||
});
|
||||
},
|
||||
[dataLength]
|
||||
);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setZoomState({
|
||||
startIndex: 0,
|
||||
endIndex: Math.max(0, dataLength - 1),
|
||||
isZoomed: false
|
||||
});
|
||||
}, [dataLength]);
|
||||
|
||||
return {
|
||||
zoomState,
|
||||
handleZoomChange,
|
||||
resetZoom
|
||||
};
|
||||
}
|
||||
92
components/charts/interactive-price-chart.tsx
Normal file
92
components/charts/interactive-price-chart.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { InteractivePriceChartProps, ChartType, TimeRange } from '@/lib/types';
|
||||
import { filterByTimeRange, isOHLCVData } from './utils/chart-data-transformers';
|
||||
import { ChartContainer } from './primitives/chart-container';
|
||||
import { ChartToolbar } from './primitives/chart-toolbar';
|
||||
import { LineChartView } from './renderers/line-chart-view';
|
||||
import { CombinationChartView } from './renderers/combination-chart-view';
|
||||
import { VolumeIndicator } from './primitives/volume-indicator';
|
||||
import { useChartExport } from './hooks/use-chart-export';
|
||||
|
||||
export function InteractivePriceChart({
|
||||
data,
|
||||
dataSeries,
|
||||
defaultChartType = 'line',
|
||||
defaultTimeRange = '1Y',
|
||||
showVolume = false,
|
||||
showToolbar = true,
|
||||
height = 400,
|
||||
loading = false,
|
||||
error = null,
|
||||
formatters,
|
||||
onChartTypeChange,
|
||||
onTimeRangeChange
|
||||
}: InteractivePriceChartProps) {
|
||||
const [chartType, setChartType] = useState<ChartType>(defaultChartType);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(defaultTimeRange);
|
||||
const filteredData = useMemo(() => filterByTimeRange(data, timeRange), [data, timeRange]);
|
||||
const filteredDataSeries = useMemo(
|
||||
() => dataSeries?.map((series) => ({
|
||||
...series,
|
||||
data: filterByTimeRange(series.data, timeRange)
|
||||
})),
|
||||
[dataSeries, timeRange]
|
||||
);
|
||||
const { chartRef, exportChart } = useChartExport();
|
||||
|
||||
const handleChartTypeChange = (type: ChartType) => {
|
||||
setChartType(type);
|
||||
onChartTypeChange?.(type);
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (range: TimeRange) => {
|
||||
setTimeRange(range);
|
||||
onTimeRangeChange?.(range);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
exportChart(`chart-${Date.now()}.png`);
|
||||
};
|
||||
|
||||
const shouldShowVolume = showVolume && filteredData.some(isOHLCVData);
|
||||
|
||||
return (
|
||||
<div ref={chartRef} className="w-full" data-testid="interactive-price-chart">
|
||||
{showToolbar && (
|
||||
<ChartToolbar
|
||||
chartType={chartType}
|
||||
timeRange={timeRange}
|
||||
onChartTypeChange={handleChartTypeChange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChartContainer height={height} loading={loading} error={error}>
|
||||
{chartType === 'line' && (
|
||||
<LineChartView
|
||||
data={filteredData}
|
||||
formatters={formatters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartType === 'combination' && filteredDataSeries && (
|
||||
<CombinationChartView
|
||||
dataSeries={filteredDataSeries}
|
||||
formatters={formatters}
|
||||
/>
|
||||
)}
|
||||
</ChartContainer>
|
||||
|
||||
{shouldShowVolume && (
|
||||
<VolumeIndicator
|
||||
data={filteredData.filter(isOHLCVData)}
|
||||
height={80}
|
||||
formatters={formatters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/charts/primitives/chart-container.tsx
Normal file
45
components/charts/primitives/chart-container.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ChartContainerProps = {
|
||||
children: React.ReactNode;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ChartContainer({
|
||||
children,
|
||||
height = 400,
|
||||
loading = false,
|
||||
error = null,
|
||||
className
|
||||
}: ChartContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)]',
|
||||
className
|
||||
)}
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-sm text-[color:var(--danger)]">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="h-full w-full p-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
components/charts/primitives/chart-toolbar.tsx
Normal file
76
components/charts/primitives/chart-toolbar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChartType, TimeRange } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type ChartToolbarProps = {
|
||||
chartType: ChartType;
|
||||
timeRange: TimeRange;
|
||||
onChartTypeChange: (type: ChartType) => void;
|
||||
onTimeRangeChange: (range: TimeRange) => void;
|
||||
onExport: () => void;
|
||||
};
|
||||
|
||||
const TIME_RANGES: TimeRange[] = ['1W', '1M', '3M', '1Y', '3Y', '5Y', '10Y', '20Y'];
|
||||
const CHART_TYPES: { value: ChartType; label: string }[] = [
|
||||
{ value: 'line', label: 'Line' },
|
||||
{ value: 'combination', label: 'Compare' }
|
||||
];
|
||||
|
||||
export function ChartToolbar({
|
||||
chartType,
|
||||
timeRange,
|
||||
onChartTypeChange,
|
||||
onTimeRangeChange,
|
||||
onExport
|
||||
}: ChartToolbarProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TIME_RANGES.map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
onClick={() => onTimeRangeChange(range)}
|
||||
className={cn(
|
||||
'min-h-9 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
timeRange === range
|
||||
? 'bg-[color:var(--accent)] text-[#16181c]'
|
||||
: 'bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-bright)] hover:text-[color:var(--terminal-bright)]'
|
||||
)}
|
||||
>
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{CHART_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => onChartTypeChange(type.value)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
chartType === type.value
|
||||
? 'bg-[color:var(--accent)] text-[#16181c]'
|
||||
: 'bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-bright)] hover:text-[color:var(--terminal-bright)]'
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onExport}
|
||||
className="min-h-9 gap-1.5 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
components/charts/primitives/chart-tooltip.tsx
Normal file
79
components/charts/primitives/chart-tooltip.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { TooltipContentProps, TooltipPayloadEntry } from 'recharts';
|
||||
import { formatCurrency, formatCompactCurrency } from '@/lib/format';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { isOHLCVData } from '../utils/chart-data-transformers';
|
||||
|
||||
type ChartTooltipProps = TooltipContentProps & {
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ChartTooltip(props: ChartTooltipProps) {
|
||||
const { active, payload, label, formatters } = props;
|
||||
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
|
||||
const colors = getChartColors();
|
||||
const formatDate = formatters?.date || ((date: string) => new Date(date).toLocaleDateString());
|
||||
const formatPrice = formatters?.price || formatCurrency;
|
||||
const formatVolume = formatters?.volume || formatCompactCurrency;
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[180px] rounded-xl border p-3 backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
borderColor: colors.tooltipBorder
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 text-xs uppercase tracking-wider" style={{ color: colors.muted }}>
|
||||
{formatDate(label as string)}
|
||||
</div>
|
||||
|
||||
{isOHLCVData(data) ? (
|
||||
<div className="space-y-1.5">
|
||||
<TooltipRow label="Open" value={formatPrice(data.open)} color={colors.text} />
|
||||
<TooltipRow label="High" value={formatPrice(data.high)} color={colors.positive} />
|
||||
<TooltipRow label="Low" value={formatPrice(data.low)} color={colors.negative} />
|
||||
<TooltipRow label="Close" value={formatPrice(data.close)} color={colors.text} />
|
||||
<div className="mt-2 border-t pt-2" style={{ borderColor: colors.tooltipBorder }}>
|
||||
<TooltipRow label="Volume" value={formatVolume(data.volume)} color={colors.muted} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: TooltipPayloadEntry, index: number) => (
|
||||
<TooltipRow
|
||||
key={index}
|
||||
label={String(entry.name || 'Value')}
|
||||
value={formatPrice(entry.value as number)}
|
||||
color={entry.color || colors.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipRowProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function TooltipRow({ label, value, color }: TooltipRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 text-xs">
|
||||
<span style={{ color: color }}>{label}:</span>
|
||||
<span className="font-mono font-medium" style={{ color: color }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
components/charts/primitives/volume-indicator.tsx
Normal file
58
components/charts/primitives/volume-indicator.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BarChart, Bar, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { OHLCVDataPoint } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { formatCompactCurrency } from '@/lib/format';
|
||||
|
||||
type VolumeIndicatorProps = {
|
||||
data: OHLCVDataPoint[];
|
||||
height?: number;
|
||||
formatters?: {
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function VolumeIndicator({
|
||||
data,
|
||||
height = 80,
|
||||
formatters
|
||||
}: VolumeIndicatorProps) {
|
||||
const colors = getChartColors();
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const formatVolume = formatters?.volume || formatCompactCurrency;
|
||||
|
||||
return (
|
||||
<div style={{ height: `${height}px`, width: '100%' }} className="mt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<YAxis
|
||||
orientation="right"
|
||||
tickFormatter={formatVolume}
|
||||
stroke={colors.muted}
|
||||
fontSize={10}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
formatVolume(typeof value === 'number' ? value : Number(value ?? 0)),
|
||||
'Volume'
|
||||
]}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: '0.75rem',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="volume"
|
||||
fill={colors.volume}
|
||||
opacity={0.3}
|
||||
isAnimationActive={data.length <= 500}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
components/charts/renderers/candlestick-chart-view.tsx
Normal file
71
components/charts/renderers/candlestick-chart-view.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Scatter
|
||||
} from 'recharts';
|
||||
import type { ChartDataPoint } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { ChartTooltip } from '../primitives/chart-tooltip';
|
||||
import { CandlestickShape } from '../utils/candlestick-shapes';
|
||||
import { isOHLCVData } from '../utils/chart-data-transformers';
|
||||
|
||||
type CandlestickChartViewProps = {
|
||||
data: ChartDataPoint[];
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CandlestickChartView({
|
||||
data,
|
||||
formatters
|
||||
}: CandlestickChartViewProps) {
|
||||
const colors = getChartColors();
|
||||
|
||||
const ohlcvData = data.filter(isOHLCVData);
|
||||
|
||||
if (ohlcvData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[color:var(--terminal-muted)]">
|
||||
Candlestick chart requires OHLCV data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={ohlcvData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.date}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.price}
|
||||
width={60}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(tooltipProps) => <ChartTooltip {...tooltipProps} formatters={formatters} />}
|
||||
cursor={{ stroke: colors.muted, strokeDasharray: '3 3' }}
|
||||
/>
|
||||
<Scatter
|
||||
dataKey="close"
|
||||
shape={<CandlestickShape />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
112
components/charts/renderers/combination-chart-view.tsx
Normal file
112
components/charts/renderers/combination-chart-view.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ComposedChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
import type { DataSeries } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { ChartTooltip } from '../primitives/chart-tooltip';
|
||||
import { mergeDataSeries } from '../utils/chart-data-transformers';
|
||||
|
||||
type CombinationChartViewProps = {
|
||||
dataSeries: DataSeries[];
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CombinationChartView({
|
||||
dataSeries,
|
||||
formatters
|
||||
}: CombinationChartViewProps) {
|
||||
const colors = getChartColors();
|
||||
|
||||
if (!dataSeries || dataSeries.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[color:var(--terminal-muted)]">
|
||||
No data series provided
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mergedData = mergeDataSeries(dataSeries);
|
||||
const visibleSeries = dataSeries.filter(series => series.visible !== false);
|
||||
const baseValues = Object.fromEntries(visibleSeries.map((series) => {
|
||||
const initialPoint = mergedData.find((entry) => typeof entry[series.id] === 'number');
|
||||
const baseValue = typeof initialPoint?.[series.id] === 'number' ? Number(initialPoint[series.id]) : null;
|
||||
return [series.id, baseValue];
|
||||
}));
|
||||
const normalizedData = mergedData.map((point) => {
|
||||
const normalizedPoint: Record<string, string | number | null> = { date: point.date };
|
||||
|
||||
visibleSeries.forEach((series) => {
|
||||
const baseValue = baseValues[series.id];
|
||||
const currentValue = typeof point[series.id] === 'number' ? Number(point[series.id]) : null;
|
||||
|
||||
normalizedPoint[series.id] = baseValue && currentValue
|
||||
? ((currentValue / baseValue) - 1) * 100
|
||||
: null;
|
||||
});
|
||||
|
||||
return normalizedPoint;
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={normalizedData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.date}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={(value) => `${Number(value).toFixed(0)}%`}
|
||||
width={60}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(tooltipProps) => (
|
||||
<ChartTooltip
|
||||
{...tooltipProps}
|
||||
formatters={{
|
||||
...formatters,
|
||||
price: (value: number) => `${value.toFixed(2)}%`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
cursor={{ stroke: colors.muted, strokeDasharray: '3 3' }}
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{visibleSeries.map(series => {
|
||||
const seriesColor = series.color || colors.primary;
|
||||
return (
|
||||
<Line
|
||||
key={series.id}
|
||||
type="linear"
|
||||
dataKey={series.id}
|
||||
name={series.label}
|
||||
stroke={seriesColor}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
isAnimationActive={normalizedData.length <= 500}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
100
components/charts/renderers/line-chart-view.tsx
Normal file
100
components/charts/renderers/line-chart-view.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import type { ChartDataPoint } from '@/lib/types';
|
||||
import { getChartColors } from '../utils/chart-colors';
|
||||
import { ChartTooltip } from '../primitives/chart-tooltip';
|
||||
import { isPriceData, isOHLCVData } from '../utils/chart-data-transformers';
|
||||
|
||||
type LineChartViewProps = {
|
||||
data: ChartDataPoint[];
|
||||
formatters?: {
|
||||
price?: (value: number) => string;
|
||||
date?: (value: string) => string;
|
||||
volume?: (value: number) => string;
|
||||
};
|
||||
};
|
||||
|
||||
export function LineChartView({
|
||||
data,
|
||||
formatters
|
||||
}: LineChartViewProps) {
|
||||
const colors = getChartColors();
|
||||
|
||||
const chartData = data.map(point => {
|
||||
if (isOHLCVData(point)) {
|
||||
return {
|
||||
date: point.date,
|
||||
price: point.close,
|
||||
open: point.open,
|
||||
high: point.high,
|
||||
low: point.low,
|
||||
close: point.close,
|
||||
volume: point.volume
|
||||
};
|
||||
} else if (isPriceData(point)) {
|
||||
return {
|
||||
date: point.date,
|
||||
price: point.price
|
||||
};
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={colors.grid}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.date}
|
||||
minTickGap={50}
|
||||
axisLine={{ stroke: colors.grid }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.muted}
|
||||
fontSize={11}
|
||||
tickFormatter={formatters?.price}
|
||||
width={65}
|
||||
domain={['auto', 'auto']}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
orientation="right"
|
||||
/>
|
||||
<Tooltip
|
||||
content={(tooltipProps) => <ChartTooltip {...tooltipProps} formatters={formatters} />}
|
||||
cursor={{ stroke: colors.muted, strokeWidth: 1, strokeDasharray: '5 5' }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="price"
|
||||
stroke={colors.primary}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: colors.primary,
|
||||
strokeWidth: 2,
|
||||
fill: colors.tooltipBg
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
connectNulls={true}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
68
components/charts/utils/candlestick-shapes.tsx
Normal file
68
components/charts/utils/candlestick-shapes.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getPriceChangeColor } from './chart-colors';
|
||||
|
||||
type CandlestickShapeProps = {
|
||||
cx?: number;
|
||||
payload?: {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom candlestick shape component for Recharts
|
||||
* Renders candlestick with wick and body
|
||||
*/
|
||||
export function CandlestickShape(props: CandlestickShapeProps) {
|
||||
const { cx, payload } = props;
|
||||
|
||||
if (!payload || !cx) return null;
|
||||
|
||||
const { open, high, low, close } = payload;
|
||||
const isPositive = close >= open;
|
||||
const color = getPriceChangeColor(close - open);
|
||||
|
||||
// Calculate positions
|
||||
const bodyTop = Math.min(open, close);
|
||||
const bodyBottom = Math.max(open, close);
|
||||
const bodyHeight = Math.max(bodyBottom - bodyTop, 1);
|
||||
|
||||
// Candlestick width
|
||||
const width = 8;
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Upper wick */}
|
||||
<line
|
||||
x1={cx}
|
||||
y1={high}
|
||||
x2={cx}
|
||||
y2={bodyTop}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<rect
|
||||
x={cx - width / 2}
|
||||
y={bodyTop}
|
||||
width={width}
|
||||
height={bodyHeight}
|
||||
fill={isPositive ? 'transparent' : color}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Lower wick */}
|
||||
<line
|
||||
x1={cx}
|
||||
y1={bodyBottom}
|
||||
x2={cx}
|
||||
y2={low}
|
||||
stroke={color}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
65
components/charts/utils/chart-colors.ts
Normal file
65
components/charts/utils/chart-colors.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ChartColorPalette } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Get chart color palette using CSS variables for theming
|
||||
* These colors match the existing dark theme in the codebase
|
||||
*/
|
||||
export function getChartColors(): ChartColorPalette {
|
||||
return {
|
||||
primary: 'var(--accent)',
|
||||
secondary: 'var(--terminal-muted)',
|
||||
positive: '#96f5bf', // Green - matches existing price-history-card
|
||||
negative: '#ff9f9f', // Red - matches existing price-history-card
|
||||
grid: 'var(--line-weak)',
|
||||
text: 'var(--terminal-bright)',
|
||||
muted: 'var(--terminal-muted)',
|
||||
tooltipBg: 'rgba(31, 34, 39, 0.96)',
|
||||
tooltipBorder: 'var(--line-strong)',
|
||||
volume: 'var(--terminal-muted)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for price change (positive/negative)
|
||||
*/
|
||||
export function getPriceChangeColor(change: number): string {
|
||||
const colors = getChartColors();
|
||||
return change >= 0 ? colors.positive : colors.negative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSS variable to computed color value
|
||||
* Used for chart export since html-to-image can't render CSS variables
|
||||
*/
|
||||
export function cssVarToColor(cssVar: string): string {
|
||||
if (typeof window === 'undefined') return cssVar;
|
||||
|
||||
// If it's already a color value, return as-is
|
||||
if (!cssVar.startsWith('var(')) return cssVar;
|
||||
|
||||
// Extract variable name from var(--name)
|
||||
const varName = cssVar.match(/var\((--[^)]+)\)/)?.[1];
|
||||
if (!varName) return cssVar;
|
||||
|
||||
// Get computed color from CSS variable
|
||||
const computedColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
|
||||
return computedColor || cssVar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entire color palette to computed colors for export
|
||||
*/
|
||||
export function getComputedColors(palette: Partial<ChartColorPalette>): Partial<ChartColorPalette> {
|
||||
const computed: Partial<ChartColorPalette> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(palette)) {
|
||||
if (value) {
|
||||
computed[key as keyof ChartColorPalette] = cssVarToColor(value);
|
||||
}
|
||||
}
|
||||
|
||||
return computed;
|
||||
}
|
||||
82
components/charts/utils/chart-data-transformers.test.ts
Normal file
82
components/charts/utils/chart-data-transformers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mergeDataSeries } from './chart-data-transformers';
|
||||
|
||||
type PricePoint = {
|
||||
date: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
describe('mergeDataSeries', () => {
|
||||
it('normalizes intraday timestamps onto the same trading day', () => {
|
||||
const merged = mergeDataSeries<PricePoint>([
|
||||
{
|
||||
id: 'stock',
|
||||
data: [
|
||||
{ date: '2026-03-10T21:00:00.000Z', price: 100 },
|
||||
{ date: '2026-03-11T21:00:00.000Z', price: 101 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 5000 },
|
||||
{ date: '2026-03-11', price: 5050 }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ date: '2026-03-10', stock: 100, sp500: 5000 },
|
||||
{ date: '2026-03-11', stock: 101, sp500: 5050 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills a comparison series from the nearest prior trading day when dates are missing', () => {
|
||||
const merged = mergeDataSeries<PricePoint>([
|
||||
{
|
||||
id: 'stock',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 100 },
|
||||
{ date: '2026-03-11', price: 103 },
|
||||
{ date: '2026-03-12', price: 104 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 5000 },
|
||||
{ date: '2026-03-12', price: 5060 }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ date: '2026-03-10', stock: 100, sp500: 5000 },
|
||||
{ date: '2026-03-11', stock: 103, sp500: 5000 },
|
||||
{ date: '2026-03-12', stock: 104, sp500: 5060 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not backfill dates before a series first appears', () => {
|
||||
const merged = mergeDataSeries<PricePoint>([
|
||||
{
|
||||
id: 'stock',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 100 },
|
||||
{ date: '2026-03-11', price: 103 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
data: [
|
||||
{ date: '2026-03-11', price: 5000 }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ date: '2026-03-10', stock: 100 },
|
||||
{ date: '2026-03-11', stock: 103, sp500: 5000 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
191
components/charts/utils/chart-data-transformers.ts
Normal file
191
components/charts/utils/chart-data-transformers.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { subWeeks, subMonths, subYears } from 'date-fns';
|
||||
import type { TimeRange, ChartDataPoint } from '@/lib/types';
|
||||
|
||||
function toTradingDayKey(value: string): string {
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Date(parsed).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter chart data by time range
|
||||
*/
|
||||
export function filterByTimeRange<T extends ChartDataPoint>(
|
||||
data: T[],
|
||||
range: TimeRange
|
||||
): T[] {
|
||||
if (data.length === 0) return data;
|
||||
|
||||
const now = new Date();
|
||||
const cutoffDate = {
|
||||
'1W': subWeeks(now, 1),
|
||||
'1M': subMonths(now, 1),
|
||||
'3M': subMonths(now, 3),
|
||||
'1Y': subYears(now, 1),
|
||||
'3Y': subYears(now, 3),
|
||||
'5Y': subYears(now, 5),
|
||||
'10Y': subYears(now, 10),
|
||||
'20Y': subYears(now, 20)
|
||||
}[range];
|
||||
|
||||
return data.filter(point => new Date(point.date) >= cutoffDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data point has OHLCV fields
|
||||
*/
|
||||
export function isOHLCVData(data: ChartDataPoint): data is {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
} {
|
||||
return 'open' in data && 'high' in data && 'low' in data && 'close' in data && 'volume' in data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data point has simple price field
|
||||
*/
|
||||
export function isPriceData(data: ChartDataPoint): data is { date: string; price: number } {
|
||||
return 'price' in data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize data to ensure consistent structure
|
||||
* Converts price data to OHLCV-like structure for candlestick charts
|
||||
*/
|
||||
export function normalizeChartData<T extends ChartDataPoint>(data: T[]): T[] {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// Sort by date ascending
|
||||
return [...data].sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample data for performance with large datasets
|
||||
* Keeps every Nth point when dataset is too large
|
||||
*/
|
||||
export function sampleData<T extends ChartDataPoint>(
|
||||
data: T[],
|
||||
maxPoints: number = 1000
|
||||
): T[] {
|
||||
if (data.length <= maxPoints) return data;
|
||||
|
||||
const samplingRate = Math.ceil(data.length / maxPoints);
|
||||
const sampled: T[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += samplingRate) {
|
||||
sampled.push(data[i]);
|
||||
}
|
||||
|
||||
// Always include the last point
|
||||
if (sampled[sampled.length - 1] !== data[data.length - 1]) {
|
||||
sampled.push(data[data.length - 1]);
|
||||
}
|
||||
|
||||
return sampled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate min/max values for Y-axis domain
|
||||
*/
|
||||
export function calculateYAxisDomain<T extends ChartDataPoint>(
|
||||
data: T[],
|
||||
padding: number = 0.1
|
||||
): [number, number] {
|
||||
if (data.length === 0) return [0, 100];
|
||||
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
|
||||
data.forEach(point => {
|
||||
if (isOHLCVData(point)) {
|
||||
min = Math.min(min, point.low);
|
||||
max = Math.max(max, point.high);
|
||||
} else if (isPriceData(point)) {
|
||||
min = Math.min(min, point.price);
|
||||
max = Math.max(max, point.price);
|
||||
}
|
||||
});
|
||||
|
||||
// Add padding
|
||||
const range = max - min;
|
||||
const paddedMin = min - (range * padding);
|
||||
const paddedMax = max + (range * padding);
|
||||
|
||||
return [paddedMin, paddedMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume max for volume indicator Y-axis
|
||||
*/
|
||||
export function calculateVolumeMax<T extends ChartDataPoint>(data: T[]): number {
|
||||
if (data.length === 0 || !isOHLCVData(data[0])) return 0;
|
||||
|
||||
return Math.max(...data.map(d => (isOHLCVData(d) ? d.volume : 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple data series by date for combination charts
|
||||
*/
|
||||
export function mergeDataSeries<T extends ChartDataPoint>(
|
||||
seriesArray: Array<{ id: string; data: T[] }>
|
||||
): Array<{ date: string } & Record<string, string | number>> {
|
||||
if (!seriesArray || seriesArray.length === 0) return [];
|
||||
|
||||
// Create map indexed by normalized trading day.
|
||||
const dateMap = new Map<string, { date: string } & Record<string, string | number>>();
|
||||
const seriesPointMaps = seriesArray.map((series) => {
|
||||
const pointMap = new Map<string, number>();
|
||||
|
||||
series.data.forEach(point => {
|
||||
const date = toTradingDayKey(point.date);
|
||||
|
||||
if (isOHLCVData(point)) {
|
||||
pointMap.set(date, point.close);
|
||||
} else if (isPriceData(point)) {
|
||||
pointMap.set(date, point.price);
|
||||
}
|
||||
});
|
||||
|
||||
pointMap.forEach((_value, date) => {
|
||||
const existing = dateMap.get(date) || { date } as { date: string } & Record<string, string | number>;
|
||||
dateMap.set(date, existing);
|
||||
});
|
||||
|
||||
return {
|
||||
id: series.id,
|
||||
pointMap
|
||||
};
|
||||
});
|
||||
|
||||
const mergedDates = Array.from(dateMap.keys()).sort((a, b) =>
|
||||
new Date(a).getTime() - new Date(b).getTime()
|
||||
);
|
||||
|
||||
// Fill forward each series to keep benchmark/comparison lines aligned on trading days.
|
||||
seriesPointMaps.forEach(({ id, pointMap }) => {
|
||||
let lastKnownValue: number | null = null;
|
||||
|
||||
mergedDates.forEach((date) => {
|
||||
const currentValue = pointMap.get(date);
|
||||
if (typeof currentValue === 'number') {
|
||||
lastKnownValue = currentValue;
|
||||
}
|
||||
|
||||
const existing = dateMap.get(date);
|
||||
if (existing && lastKnownValue !== null) {
|
||||
existing[id] = lastKnownValue;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return mergedDates.map((date) => dateMap.get(date)!);
|
||||
}
|
||||
@@ -10,9 +10,9 @@ type MetricCardProps = {
|
||||
|
||||
export function MetricCard({ label, value, delta, positive = true, className }: MetricCardProps) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4', className)}>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">{label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">{value}</p>
|
||||
<div className={cn('min-w-0 border-t border-[color:var(--line-weak)] pt-3', className)}>
|
||||
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-[color:var(--terminal-bright)]">{value}</p>
|
||||
{delta ? (
|
||||
<p className={cn('mt-2 text-xs', positive ? 'text-[#96f5bf]' : 'text-[#ff9898]')}>
|
||||
{delta}
|
||||
|
||||
@@ -10,7 +10,8 @@ const taskLabels: Record<Task['task_type'], string> = {
|
||||
sync_filings: 'Sync filings',
|
||||
refresh_prices: 'Refresh prices',
|
||||
analyze_filing: 'Analyze filing',
|
||||
portfolio_insights: 'Portfolio insights'
|
||||
portfolio_insights: 'Portfolio insights',
|
||||
index_search: 'Index search'
|
||||
};
|
||||
|
||||
export function TaskFeed({ tasks }: TaskFeedProps) {
|
||||
@@ -19,9 +20,9 @@ export function TaskFeed({ tasks }: TaskFeedProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
<ul>
|
||||
{tasks.slice(0, 8).map((task) => (
|
||||
<li key={task.id} className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<li key={task.id} className="flex items-center justify-between gap-3 border-b border-[color:var(--line-weak)] py-3 last:border-b-0 last:pb-0 first:pt-0">
|
||||
<div>
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">{taskLabels[task.task_type]}</p>
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">
|
||||
|
||||
@@ -41,24 +41,24 @@ export function FinancialControlBar({
|
||||
className
|
||||
}: FinancialControlBarProps) {
|
||||
return (
|
||||
<section className={cn('rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-4 py-3', className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[color:var(--terminal-bright)]">{title}</h3>
|
||||
<section className={cn('border-t border-[color:var(--line-weak)] pt-4', className)}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-[color:var(--terminal-bright)]">{title}</h3>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions && actions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div className="grid w-full grid-cols-1 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:justify-end">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
type="button"
|
||||
variant={action.variant ?? 'secondary'}
|
||||
disabled={action.disabled}
|
||||
className="px-2 py-1 text-xs"
|
||||
className="px-2 py-1 text-xs sm:min-h-9"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
@@ -68,31 +68,29 @@ export function FinancialControlBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<div className="flex min-w-max flex-wrap gap-2">
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1.5"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{section.options.map((option) => (
|
||||
<Button
|
||||
key={`${section.id}-${option.value}`}
|
||||
type="button"
|
||||
variant={option.value === section.value ? 'primary' : 'ghost'}
|
||||
disabled={option.disabled}
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => section.onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="data-surface px-3 py-3"
|
||||
>
|
||||
<span className="mb-2 block text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{section.options.map((option) => (
|
||||
<Button
|
||||
key={`${section.id}-${option.value}`}
|
||||
type="button"
|
||||
variant={option.value === section.value ? 'primary' : 'ghost'}
|
||||
disabled={option.disabled}
|
||||
className="px-2 py-1 text-xs sm:min-h-9"
|
||||
onClick={() => section.onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
79
components/financials/normalization-summary.tsx
Normal file
79
components/financials/normalization-summary.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import type { NormalizationMetadata } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type NormalizationSummaryProps = {
|
||||
normalization: NormalizationMetadata;
|
||||
};
|
||||
|
||||
function SummaryCard(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'default' | 'warning';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'data-surface px-3 py-3',
|
||||
props.tone === 'warning' && 'border-[#7f6250] bg-[linear-gradient(180deg,rgba(80,58,41,0.92),rgba(38,27,21,0.78))]'
|
||||
)}
|
||||
>
|
||||
<p className="panel-heading text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{props.label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{props.value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NormalizationSummary({ normalization }: NormalizationSummaryProps) {
|
||||
const hasMaterialUnmapped = normalization.materialUnmappedRowCount > 0;
|
||||
const hasWarnings = normalization.warnings.length > 0;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Normalization Summary"
|
||||
subtitle="Pack, parser, and residual mapping health for the compact statement surface."
|
||||
variant="surface"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-8">
|
||||
<SummaryCard label="Pack" value={normalization.fiscalPack ?? 'unknown'} />
|
||||
<SummaryCard label="Regime" value={normalization.regime} />
|
||||
<SummaryCard label="Parser" value={`${normalization.parserEngine} ${normalization.parserVersion}`} />
|
||||
<SummaryCard label="Surface Rows" value={String(normalization.surfaceRowCount)} />
|
||||
<SummaryCard label="Detail Rows" value={String(normalization.detailRowCount)} />
|
||||
<SummaryCard label="KPI Rows" value={String(normalization.kpiRowCount)} />
|
||||
<SummaryCard label="Unmapped Rows" value={String(normalization.unmappedRowCount)} />
|
||||
<SummaryCard
|
||||
label="Material Unmapped"
|
||||
value={String(normalization.materialUnmappedRowCount)}
|
||||
tone={hasMaterialUnmapped ? 'warning' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
{hasWarnings ? (
|
||||
<div className="mt-3 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3">
|
||||
<p className="panel-heading text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
Parser Warnings
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{normalization.warnings.map((warning) => (
|
||||
<span
|
||||
key={warning}
|
||||
className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] px-3 py-1 text-xs text-[color:var(--terminal-bright)]"
|
||||
>
|
||||
{warning}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasMaterialUnmapped ? (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-xl border border-[#7f6250] bg-[rgba(91,66,46,0.18)] px-3 py-3 text-sm text-[#f5d5c0]">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<p>Material unmapped rows were detected for this filing set. Use the inspector and detail rows before relying on cross-company comparisons.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
208
components/financials/statement-matrix.tsx
Normal file
208
components/financials/statement-matrix.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { FinancialStatementPeriod, SurfaceFinancialRow, DetailFinancialRow } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
StatementInspectorSelection,
|
||||
StatementTreeNode,
|
||||
StatementTreeSection
|
||||
} from '@/lib/financials/statement-view-model';
|
||||
|
||||
type MatrixRow = SurfaceFinancialRow | DetailFinancialRow;
|
||||
|
||||
type StatementMatrixProps = {
|
||||
periods: FinancialStatementPeriod[];
|
||||
sections: StatementTreeSection[];
|
||||
selectedRowRef: StatementInspectorSelection | null;
|
||||
onToggleRow: (key: string) => void;
|
||||
onSelectRow: (selection: StatementInspectorSelection) => void;
|
||||
renderCellValue: (row: MatrixRow, periodId: string, previousPeriodId: string | null) => string;
|
||||
periodLabelFormatter: (value: string) => string;
|
||||
};
|
||||
|
||||
function isSurfaceNode(node: StatementTreeNode): node is Extract<StatementTreeNode, { kind: 'surface' }> {
|
||||
return node.kind === 'surface';
|
||||
}
|
||||
|
||||
function rowSelected(
|
||||
node: StatementTreeNode,
|
||||
selectedRowRef: StatementInspectorSelection | null
|
||||
) {
|
||||
if (!selectedRowRef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.kind === 'surface') {
|
||||
return selectedRowRef.kind === 'surface' && selectedRowRef.key === node.row.key;
|
||||
}
|
||||
|
||||
return selectedRowRef.kind === 'detail'
|
||||
&& selectedRowRef.key === node.row.key
|
||||
&& selectedRowRef.parentKey === node.parentSurfaceKey;
|
||||
}
|
||||
|
||||
function surfaceBadges(node: Extract<StatementTreeNode, { kind: 'surface' }>) {
|
||||
const badges: Array<{ label: string; tone: 'default' | 'warning' | 'muted' }> = [];
|
||||
|
||||
if (node.row.resolutionMethod === 'formula_derived') {
|
||||
badges.push({ label: 'Formula', tone: node.row.confidence === 'low' ? 'warning' : 'default' });
|
||||
}
|
||||
|
||||
if (node.row.resolutionMethod === 'not_meaningful') {
|
||||
badges.push({ label: 'N/M', tone: 'muted' });
|
||||
}
|
||||
|
||||
if (node.row.confidence === 'low') {
|
||||
badges.push({ label: 'Low confidence', tone: 'warning' });
|
||||
}
|
||||
|
||||
const detailCount = node.row.detailCount ?? node.directDetailCount;
|
||||
if (detailCount > 0) {
|
||||
badges.push({ label: `${detailCount} details`, tone: 'default' });
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
function badgeClass(tone: 'default' | 'warning' | 'muted') {
|
||||
if (tone === 'warning') {
|
||||
return 'border-[#84614f] bg-[rgba(112,76,54,0.22)] text-[#ffd7bf]';
|
||||
}
|
||||
|
||||
if (tone === 'muted') {
|
||||
return 'border-[color:var(--line-weak)] bg-[rgba(80,85,92,0.16)] text-[color:var(--terminal-muted)]';
|
||||
}
|
||||
|
||||
return 'border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] text-[color:var(--terminal-bright)]';
|
||||
}
|
||||
|
||||
function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[] }) {
|
||||
return props.nodes.map((node) => {
|
||||
const isSelected = rowSelected(node, props.selectedRowRef);
|
||||
const labelIndent = node.kind === 'detail' ? node.level * 18 + 18 : node.level * 18;
|
||||
const canToggle = isSurfaceNode(node) && node.expandable;
|
||||
const nextSelection: StatementInspectorSelection = node.kind === 'surface'
|
||||
? { kind: 'surface', key: node.row.key }
|
||||
: { kind: 'detail', key: node.row.key, parentKey: node.parentSurfaceKey };
|
||||
|
||||
return (
|
||||
<Fragment key={node.id}>
|
||||
<tr className={cn(isSelected && 'bg-[color:rgba(70,77,87,0.48)]')}>
|
||||
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
||||
<div className="flex min-w-[260px] items-start gap-2" style={{ paddingLeft: `${labelIndent}px` }}>
|
||||
{canToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${node.expanded ? 'Collapse' : 'Expand'} ${node.row.label} details`}
|
||||
aria-expanded={node.expanded}
|
||||
aria-controls={`statement-children-${node.id}`}
|
||||
className="mt-0.5 inline-flex size-11 shrink-0 items-center justify-center rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onToggleRow(node.row.key);
|
||||
}}
|
||||
>
|
||||
{node.expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex size-11 shrink-0 items-center justify-center text-[color:var(--terminal-muted)]" aria-hidden="true">
|
||||
{node.kind === 'detail' ? '·' : ''}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-1 py-2 text-left"
|
||||
onClick={() => props.onSelectRow(nextSelection)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-sm text-[color:var(--terminal-bright)]',
|
||||
node.kind === 'detail' && 'text-[13px] text-[color:var(--terminal-soft)]',
|
||||
node.kind === 'surface' && node.level > 0 && 'text-[color:var(--terminal-soft)]'
|
||||
)}>
|
||||
{node.row.label}
|
||||
</span>
|
||||
{node.kind === 'detail' ? (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">
|
||||
{node.row.localName}
|
||||
{node.row.residualFlag ? ' · residual' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{surfaceBadges(node).map((badge) => (
|
||||
<span
|
||||
key={`${node.row.key}-${badge.label}`}
|
||||
className={cn(
|
||||
'rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-[0.14em]',
|
||||
badgeClass(badge.tone)
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{props.periods.map((period, index) => (
|
||||
<td key={`${node.id}-${period.id}`}>
|
||||
{props.renderCellValue(node.row, period.id, index > 0 ? props.periods[index - 1]?.id ?? null : null)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{isSurfaceNode(node) && node.expanded ? (
|
||||
<>
|
||||
<tr id={`statement-children-${node.id}`} className="sr-only">
|
||||
<td colSpan={props.periods.length + 1}>Expanded children for {node.row.label}</td>
|
||||
</tr>
|
||||
{renderNodes({
|
||||
...props,
|
||||
nodes: node.children
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function StatementMatrix(props: StatementMatrixProps) {
|
||||
return (
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table min-w-[1040px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
|
||||
{props.periods.map((period) => (
|
||||
<th key={period.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{props.periodLabelFormatter(period.periodEnd ?? period.filingDate)}</span>
|
||||
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.sections.map((section) => (
|
||||
<Fragment key={section.key}>
|
||||
{section.label ? (
|
||||
<tr className="bg-[color:var(--panel-soft)]">
|
||||
<td colSpan={props.periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">
|
||||
{section.label}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{renderNodes({
|
||||
...props,
|
||||
nodes: section.nodes
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
components/financials/statement-row-inspector.tsx
Normal file
177
components/financials/statement-row-inspector.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import type {
|
||||
DetailFinancialRow,
|
||||
DimensionBreakdownRow,
|
||||
FinancialStatementPeriod,
|
||||
FinancialSurfaceKind,
|
||||
SurfaceFinancialRow
|
||||
} from '@/lib/types';
|
||||
import type { ResolvedStatementSelection } from '@/lib/financials/statement-view-model';
|
||||
|
||||
type StatementRowInspectorProps = {
|
||||
selection: ResolvedStatementSelection | null;
|
||||
dimensionRows: DimensionBreakdownRow[];
|
||||
periods: FinancialStatementPeriod[];
|
||||
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||
renderValue: (row: SurfaceFinancialRow | DetailFinancialRow, periodId: string, previousPeriodId: string | null) => string;
|
||||
renderDimensionValue: (value: number | null, rowKey: string, unit: SurfaceFinancialRow['unit']) => string;
|
||||
};
|
||||
|
||||
function InspectorCard(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-[color:var(--terminal-muted)]">{props.label}</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{props.value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderList(values: string[] | null | undefined) {
|
||||
return (values ?? []).length > 0 ? (values ?? []).join(', ') : 'n/a';
|
||||
}
|
||||
|
||||
export function StatementRowInspector(props: StatementRowInspectorProps) {
|
||||
const selection = props.selection;
|
||||
const parentSurfaceLabel = selection?.kind === 'detail'
|
||||
? selection.parentSurfaceRow?.label ?? (selection.row.parentSurfaceKey === 'unmapped' ? 'Unmapped / Residual' : selection.row.parentSurfaceKey)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Row Details"
|
||||
subtitle="Inspect compact-surface resolution, raw drill-down rows, and dimensional evidence."
|
||||
variant="surface"
|
||||
>
|
||||
{!selection ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Select a compact surface row or raw detail row to inspect details.</p>
|
||||
) : selection.kind === 'surface' ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InspectorCard label="Label" value={selection.row.label} />
|
||||
<InspectorCard label="Key" value={selection.row.key} />
|
||||
<InspectorCard label="Resolution" value={selection.row.resolutionMethod ?? 'direct'} />
|
||||
<InspectorCard label="Confidence" value={selection.row.confidence ?? 'high'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<InspectorCard label="Source Row Keys" value={renderList(selection.row.sourceRowKeys)} />
|
||||
<InspectorCard label="Source Concepts" value={renderList(selection.row.sourceConcepts)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<InspectorCard label="Source Fact IDs" value={(selection.row.sourceFactIds ?? []).length > 0 ? (selection.row.sourceFactIds ?? []).join(', ') : 'n/a'} />
|
||||
<InspectorCard label="Warning Codes" value={renderList(selection.row.warningCodes ?? [])} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<InspectorCard label="Child Surface Rows" value={selection.childSurfaceRows.length > 0 ? selection.childSurfaceRows.map((row) => row.label).join(', ') : 'None'} />
|
||||
<InspectorCard label="Raw Detail Rows" value={String(selection.detailRows.length)} />
|
||||
</div>
|
||||
|
||||
{selection.detailRows.length > 0 ? (
|
||||
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3">
|
||||
<p className="text-[color:var(--terminal-muted)]">Raw Detail Labels</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selection.detailRows.map((row) => (
|
||||
<span
|
||||
key={`${selection.row.key}-${row.key}`}
|
||||
className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] px-3 py-1 text-xs text-[color:var(--terminal-bright)]"
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selection.row.hasDimensions ? (
|
||||
props.dimensionRows.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected compact row.</p>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table min-w-[760px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Axis</th>
|
||||
<th>Member</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.dimensionRows.map((row, index) => (
|
||||
<tr key={`${selection.row.key}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
||||
<td>{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
|
||||
<td>{row.axis}</td>
|
||||
<td>{row.member}</td>
|
||||
<td>{props.renderDimensionValue(row.value, selection.row.key, selection.row.unit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this compact surface row.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InspectorCard label="Label" value={selection.row.label} />
|
||||
<InspectorCard label="Key" value={selection.row.key} />
|
||||
<InspectorCard label="Parent Surface" value={parentSurfaceLabel ?? selection.row.parentSurfaceKey} />
|
||||
<InspectorCard label="Residual" value={selection.row.residualFlag ? 'Yes' : 'No'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<InspectorCard label="Concept Key" value={selection.row.conceptKey} />
|
||||
<InspectorCard label="QName" value={selection.row.qname} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<InspectorCard label="Local Name" value={selection.row.localName} />
|
||||
<InspectorCard label="Source Fact IDs" value={(selection.row.sourceFactIds ?? []).length > 0 ? (selection.row.sourceFactIds ?? []).join(', ') : 'n/a'} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-[color:var(--terminal-muted)]">Dimensions Summary</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{renderList(selection.row.dimensionsSummary)}</p>
|
||||
</div>
|
||||
|
||||
{props.dimensionRows.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected raw detail row.</p>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table min-w-[760px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Axis</th>
|
||||
<th>Member</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.dimensionRows.map((row, index) => (
|
||||
<tr key={`${selection.row.parentSurfaceKey}-${selection.row.key}-${row.periodId}-${index}`}>
|
||||
<td>{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
|
||||
<td>{row.axis}</td>
|
||||
<td>{row.member}</td>
|
||||
<td>{props.renderDimensionValue(row.value, selection.row.key, props.surfaceKind === 'balance_sheet' ? 'currency' : 'currency')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronDown, LoaderCircle, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTaskTimelineQuery } from '@/hooks/use-api-queries';
|
||||
import { buildStageTimeline, stageLabel, taskTypeLabel } from '@/components/notifications/task-stage-helpers';
|
||||
import { StatusPill } from '@/components/ui/status-pill';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Task, TaskStageContext } from '@/lib/types';
|
||||
|
||||
function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
@@ -21,6 +23,94 @@ function formatTimestamp(value: string | null) {
|
||||
return format(parsed, 'MMM dd, yyyy HH:mm:ss');
|
||||
}
|
||||
|
||||
function formatCounterLabel(value: string) {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function ProgressPanel({ task }: { task: Task }) {
|
||||
const progress = task.notification.progress;
|
||||
if (!progress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-[color:var(--terminal-muted)]">
|
||||
<span>{progress.current}/{progress.total} {progress.unit}</span>
|
||||
<span>{progress.percent ?? 0}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[color:rgba(255,255,255,0.08)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[color:var(--accent)] transition-[width] duration-300"
|
||||
style={{ width: `${progress.percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ task }: { task: Task }) {
|
||||
const counters = task.stage_context?.counters ?? {};
|
||||
const counterEntries = Object.entries(counters);
|
||||
|
||||
if (task.notification.stats.length === 0 && counterEntries.length === 0) {
|
||||
return <p className="text-xs text-[color:var(--terminal-muted)]">No structured metrics available for this job yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.notification.stats.map((stat) => (
|
||||
<div key={`${stat.label}:${stat.value}`} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-xs text-[color:var(--terminal-bright)]">
|
||||
<span className="text-[color:var(--terminal-muted)]">{stat.label}</span> {stat.value}
|
||||
</div>
|
||||
))}
|
||||
{counterEntries.map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-xs text-[color:var(--terminal-bright)]">
|
||||
<span className="text-[color:var(--terminal-muted)]">{formatCounterLabel(label)}</span> {value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageContextBlock({ context }: { context: TaskStageContext | null }) {
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const counters = Object.entries(context.counters ?? {});
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{context.progress ? (
|
||||
<div className="text-[11px] text-[color:var(--terminal-muted)]">
|
||||
Progress {context.progress.current}/{context.progress.total} {context.progress.unit}
|
||||
</div>
|
||||
) : null}
|
||||
{context.subject ? (
|
||||
<div className="text-[11px] text-[color:var(--terminal-muted)]">
|
||||
{[context.subject.ticker, context.subject.accessionNumber, context.subject.label].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
{counters.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{counters.map(([label, value]) => (
|
||||
<span
|
||||
key={label}
|
||||
className="inline-flex items-center rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{formatCounterLabel(label)}: {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TaskDetailModalProps = {
|
||||
isOpen: boolean;
|
||||
taskId: string | null;
|
||||
@@ -35,10 +125,14 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp
|
||||
const [expandedStage, setExpandedStage] = useState<string | null>(null);
|
||||
|
||||
const defaultExpandedStage = useMemo(() => {
|
||||
if (task?.status === 'completed' || task?.status === 'failed') {
|
||||
if (task?.status === 'completed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (task?.status === 'failed') {
|
||||
return task.stage;
|
||||
}
|
||||
|
||||
for (const item of timeline) {
|
||||
if (item.state === 'active') {
|
||||
return item.stage;
|
||||
@@ -136,6 +230,39 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">Attempts: <span className="text-[color:var(--terminal-bright)]">{task.attempts}/{task.max_attempts}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Summary</p>
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">{task.notification.title}</p>
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-bright)]">{task.notification.statusLine}</p>
|
||||
{task.notification.detailLine ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.notification.detailLine}</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<ProgressPanel task={task} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{task.notification.actions
|
||||
.filter((action) => action.id !== 'open_details' && action.href)
|
||||
.map((action) => (
|
||||
<Link
|
||||
key={action.id}
|
||||
href={action.href ?? '#'}
|
||||
className={action.primary
|
||||
? 'inline-flex items-center rounded-lg border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-1.5 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--accent)]'
|
||||
: 'inline-flex items-center rounded-lg border border-[color:var(--line-weak)] px-3 py-1.5 text-xs text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]'
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Key metrics</p>
|
||||
<StatsPanel task={task} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Stage timeline</p>
|
||||
<ol className="max-h-72 space-y-1.5 overflow-y-auto pr-1">
|
||||
@@ -154,10 +281,12 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={item.state === 'active'
|
||||
? 'text-[11px] uppercase tracking-[0.12em] text-[#9fffcf]'
|
||||
? 'text-[11px] uppercase tracking-[0.12em] text-[color:var(--accent)]'
|
||||
: item.state === 'failed'
|
||||
? 'text-[11px] uppercase tracking-[0.12em] text-[#ff8f8f]'
|
||||
: item.state === 'completed'
|
||||
? 'text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]'
|
||||
: 'text-[11px] uppercase tracking-[0.12em] text-[#6f8791]'}
|
||||
: 'text-[11px] uppercase tracking-[0.12em] text-[#7f8994]'}
|
||||
>
|
||||
{item.state}
|
||||
</span>
|
||||
@@ -168,12 +297,7 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp
|
||||
{expandedStage === item.stage ? (
|
||||
<div className="border-t border-[color:var(--line-weak)] px-3 py-2">
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">{item.detail ?? 'No additional detail for this step.'}</p>
|
||||
{item.stage === 'completed' && task.result ? (
|
||||
<div className="mt-2 rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Result detail</p>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words text-xs text-[color:var(--terminal-bright)]">{JSON.stringify(task.result, null, 2)}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
<StageContextBlock context={item.context} />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
@@ -187,6 +311,13 @@ export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProp
|
||||
<p className="mt-1 text-sm text-[#ffd6d6]">{task.error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{task.result ? (
|
||||
<details className="mb-3 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
<summary className="cursor-pointer text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Debug result</summary>
|
||||
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap break-words text-xs text-[color:var(--terminal-bright)]">{JSON.stringify(task.result, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Bell, BellRing, LoaderCircle } from 'lucide-react';
|
||||
import type { Task } from '@/lib/types';
|
||||
import type { TaskNotificationEntry } from '@/lib/types';
|
||||
import { StatusPill } from '@/components/ui/status-pill';
|
||||
import { taskTypeLabel } from '@/components/notifications/task-stage-helpers';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type TaskNotificationsTriggerProps = {
|
||||
@@ -12,30 +11,150 @@ type TaskNotificationsTriggerProps = {
|
||||
isPopoverOpen: boolean;
|
||||
setIsPopoverOpen: (value: boolean) => void;
|
||||
isLoading: boolean;
|
||||
activeTasks: Task[];
|
||||
visibleFinishedTasks: Task[];
|
||||
awaitingReviewTasks: Task[];
|
||||
activeEntries: TaskNotificationEntry[];
|
||||
visibleFinishedEntries: TaskNotificationEntry[];
|
||||
awaitingReviewEntries: TaskNotificationEntry[];
|
||||
showReadFinished: boolean;
|
||||
setShowReadFinished: (value: boolean) => void;
|
||||
openTaskDetails: (taskId: string) => void;
|
||||
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
|
||||
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
|
||||
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ProgressBar({ entry }: { entry: TaskNotificationEntry }) {
|
||||
const progress = entry.progress;
|
||||
if (!progress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 flex items-center justify-between text-[11px] text-[color:var(--terminal-muted)]">
|
||||
<span>{progress.current}/{progress.total} {progress.unit}</span>
|
||||
<span>{progress.percent ?? 0}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[color:rgba(255,255,255,0.08)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[color:var(--accent)] transition-[width] duration-300"
|
||||
style={{ width: `${progress.percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChips({ entry }: { entry: TaskNotificationEntry }) {
|
||||
if (entry.stats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{entry.stats.map((stat) => (
|
||||
<span
|
||||
key={`${stat.label}:${stat.value}`}
|
||||
className="inline-flex items-center rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{stat.label}: {stat.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationCard({
|
||||
entry,
|
||||
openTaskDetails,
|
||||
openTaskAction,
|
||||
silenceEntry,
|
||||
markEntryRead
|
||||
}: {
|
||||
entry: TaskNotificationEntry;
|
||||
openTaskDetails: (taskId: string) => void;
|
||||
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||
}) {
|
||||
const isRead = entry.notificationReadAt !== null;
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">{entry.title}</p>
|
||||
<StatusPill status={entry.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-bright)]">{entry.statusLine}</p>
|
||||
{entry.detailLine ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{entry.detailLine}</p>
|
||||
) : null}
|
||||
<ProgressBar entry={entry} />
|
||||
<StatChips entry={entry} />
|
||||
<p className="mt-1 text-[11px] text-[color:var(--terminal-muted)]">
|
||||
{formatDistanceToNow(new Date(entry.updatedAt), { addSuffix: true })}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
{entry.actions
|
||||
.filter((action) => action.primary && action.id !== 'open_details')
|
||||
.slice(0, 1)
|
||||
.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||
onClick={() => openTaskAction(entry, action.id)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||
onClick={() => openTaskDetails(entry.primaryTaskId)}
|
||||
>
|
||||
Open details
|
||||
</button>
|
||||
{entry.status === 'queued' || entry.status === 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
||||
onClick={() => {
|
||||
void silenceEntry(entry, true);
|
||||
}}
|
||||
>
|
||||
Silence
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
||||
onClick={() => {
|
||||
void markEntryRead(entry, !isRead);
|
||||
}}
|
||||
>
|
||||
{isRead ? 'Mark unread' : 'Mark read'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskNotificationsTrigger({
|
||||
unreadCount,
|
||||
isPopoverOpen,
|
||||
setIsPopoverOpen,
|
||||
isLoading,
|
||||
activeTasks,
|
||||
visibleFinishedTasks,
|
||||
awaitingReviewTasks,
|
||||
activeEntries,
|
||||
visibleFinishedEntries,
|
||||
awaitingReviewEntries,
|
||||
showReadFinished,
|
||||
setShowReadFinished,
|
||||
openTaskDetails,
|
||||
silenceTask,
|
||||
markTaskRead,
|
||||
openTaskAction,
|
||||
silenceEntry,
|
||||
markEntryRead,
|
||||
className
|
||||
}: TaskNotificationsTriggerProps) {
|
||||
const button = (
|
||||
@@ -50,7 +169,7 @@ export function TaskNotificationsTrigger({
|
||||
>
|
||||
{unreadCount > 0 ? <BellRing className="size-4" /> : <Bell className="size-4" />}
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -right-1.5 -top-1.5 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[color:var(--accent)] px-1 text-[10px] font-semibold text-[#00241d]">
|
||||
<span className="absolute -right-1.5 -top-1.5 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[color:var(--accent)] px-1 text-[10px] font-semibold text-[#16181c]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -86,7 +205,7 @@ export function TaskNotificationsTrigger({
|
||||
className="h-4 w-4 accent-[color:var(--accent)]"
|
||||
/>
|
||||
</label>
|
||||
<div className="mb-2 text-xs text-[color:var(--terminal-muted)]">Unread finished: {awaitingReviewTasks.length}</div>
|
||||
<div className="mb-2 text-xs text-[color:var(--terminal-muted)]">Unread finished: {awaitingReviewEntries.length}</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
@@ -106,81 +225,37 @@ export function TaskNotificationsTrigger({
|
||||
<div className="h-[calc(100%-5.5rem)] space-y-3 overflow-y-auto pr-1">
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Active jobs</p>
|
||||
{activeTasks.length === 0 ? (
|
||||
{activeEntries.length === 0 ? (
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">No active jobs.</p>
|
||||
) : (
|
||||
activeTasks.map((task) => (
|
||||
<article key={task.id} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">{taskTypeLabel(task.task_type)}</p>
|
||||
<StatusPill status={task.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.stage_detail ?? 'Running in workflow engine.'}</p>
|
||||
<p className="mt-1 text-[11px] text-[color:var(--terminal-muted)]">
|
||||
{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||
onClick={() => openTaskDetails(task.id)}
|
||||
>
|
||||
Open details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
||||
onClick={() => {
|
||||
void silenceTask(task.id, true);
|
||||
}}
|
||||
>
|
||||
Silence
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
activeEntries.map((entry) => (
|
||||
<NotificationCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
openTaskDetails={openTaskDetails}
|
||||
openTaskAction={openTaskAction}
|
||||
silenceEntry={silenceEntry}
|
||||
markEntryRead={markEntryRead}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Awaiting review</p>
|
||||
{visibleFinishedTasks.length === 0 ? (
|
||||
{visibleFinishedEntries.length === 0 ? (
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">No finished jobs to review.</p>
|
||||
) : (
|
||||
visibleFinishedTasks.map((task) => {
|
||||
const isRead = task.notification_read_at !== null;
|
||||
|
||||
return (
|
||||
<article key={task.id} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">{taskTypeLabel(task.task_type)}</p>
|
||||
<StatusPill status={task.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.stage_detail ?? task.stage}</p>
|
||||
<p className="mt-1 text-[11px] text-[color:var(--terminal-muted)]">
|
||||
{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||
onClick={() => openTaskDetails(task.id)}
|
||||
>
|
||||
Open details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
||||
onClick={() => {
|
||||
void markTaskRead(task.id, !isRead);
|
||||
}}
|
||||
>
|
||||
{isRead ? 'Mark unread' : 'Mark read'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
visibleFinishedEntries.map((entry) => (
|
||||
<NotificationCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
openTaskDetails={openTaskDetails}
|
||||
openTaskAction={openTaskAction}
|
||||
silenceEntry={silenceEntry}
|
||||
markEntryRead={markEntryRead}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,160 +1,8 @@
|
||||
import type { Task, TaskStage, TaskStageEvent, TaskType } from '@/lib/types';
|
||||
|
||||
export type StageTimelineItem = {
|
||||
stage: TaskStage;
|
||||
label: string;
|
||||
state: 'completed' | 'active' | 'pending';
|
||||
detail: string | null;
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||
sync_filings: 'Filing sync',
|
||||
refresh_prices: 'Price refresh',
|
||||
analyze_filing: 'Filing analysis',
|
||||
portfolio_insights: 'Portfolio insight'
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<TaskStage, string> = {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
'sync.fetch_filings': 'Fetch filings',
|
||||
'sync.discover_assets': 'Discover taxonomy assets',
|
||||
'sync.extract_taxonomy': 'Extract taxonomy',
|
||||
'sync.normalize_taxonomy': 'Normalize taxonomy',
|
||||
'sync.derive_metrics': 'Derive metrics',
|
||||
'sync.validate_pdf_metrics': 'Validate PDF metrics',
|
||||
'sync.persist_taxonomy': 'Persist taxonomy',
|
||||
'sync.fetch_metrics': 'Fetch filing metrics',
|
||||
'sync.persist_filings': 'Persist filings',
|
||||
'sync.hydrate_statements': 'Hydrate statements',
|
||||
'refresh.load_holdings': 'Load holdings',
|
||||
'refresh.fetch_quotes': 'Fetch quotes',
|
||||
'refresh.persist_prices': 'Persist prices',
|
||||
'analyze.load_filing': 'Load filing',
|
||||
'analyze.fetch_document': 'Fetch primary document',
|
||||
'analyze.extract': 'Extract context',
|
||||
'analyze.generate_report': 'Generate report',
|
||||
'analyze.persist_report': 'Persist report',
|
||||
'insights.load_holdings': 'Load holdings',
|
||||
'insights.generate': 'Generate insight',
|
||||
'insights.persist': 'Persist insight'
|
||||
};
|
||||
|
||||
const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
|
||||
sync_filings: [
|
||||
'queued',
|
||||
'running',
|
||||
'sync.fetch_filings',
|
||||
'sync.persist_filings',
|
||||
'sync.discover_assets',
|
||||
'sync.extract_taxonomy',
|
||||
'sync.normalize_taxonomy',
|
||||
'sync.derive_metrics',
|
||||
'sync.validate_pdf_metrics',
|
||||
'sync.persist_taxonomy',
|
||||
'completed'
|
||||
],
|
||||
refresh_prices: [
|
||||
'queued',
|
||||
'running',
|
||||
'refresh.load_holdings',
|
||||
'refresh.fetch_quotes',
|
||||
'refresh.persist_prices',
|
||||
'completed'
|
||||
],
|
||||
analyze_filing: [
|
||||
'queued',
|
||||
'running',
|
||||
'analyze.load_filing',
|
||||
'analyze.fetch_document',
|
||||
'analyze.extract',
|
||||
'analyze.generate_report',
|
||||
'analyze.persist_report',
|
||||
'completed'
|
||||
],
|
||||
portfolio_insights: [
|
||||
'queued',
|
||||
'running',
|
||||
'insights.load_holdings',
|
||||
'insights.generate',
|
||||
'insights.persist',
|
||||
'completed'
|
||||
]
|
||||
};
|
||||
|
||||
export function taskTypeLabel(taskType: TaskType) {
|
||||
return TASK_TYPE_LABELS[taskType];
|
||||
}
|
||||
|
||||
export function stageLabel(stage: TaskStage) {
|
||||
return STAGE_LABELS[stage] ?? stage;
|
||||
}
|
||||
|
||||
export function buildStageTimeline(task: Task, events: TaskStageEvent[]): StageTimelineItem[] {
|
||||
const baseOrder = TASK_STAGE_ORDER[task.task_type] ?? ['queued', 'running', 'completed'];
|
||||
const orderedStages = [...baseOrder];
|
||||
|
||||
if (task.status === 'failed' && !orderedStages.includes('failed')) {
|
||||
orderedStages.push('failed');
|
||||
}
|
||||
|
||||
const latestEventByStage = new Map<TaskStage, TaskStageEvent>();
|
||||
for (const event of events) {
|
||||
latestEventByStage.set(event.stage, event);
|
||||
}
|
||||
|
||||
return orderedStages.map((stage) => {
|
||||
const event = latestEventByStage.get(stage);
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
if (stage === task.stage) {
|
||||
return {
|
||||
stage,
|
||||
label: stageLabel(stage),
|
||||
state: 'active' as const,
|
||||
detail: event?.stage_detail ?? task.stage_detail,
|
||||
timestamp: event?.created_at ?? null
|
||||
};
|
||||
}
|
||||
|
||||
if (event) {
|
||||
return {
|
||||
stage,
|
||||
label: stageLabel(stage),
|
||||
state: 'completed' as const,
|
||||
detail: event.stage_detail,
|
||||
timestamp: event.created_at
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage,
|
||||
label: stageLabel(stage),
|
||||
state: 'pending' as const,
|
||||
detail: null,
|
||||
timestamp: null
|
||||
};
|
||||
}
|
||||
|
||||
if (stage === task.stage || event) {
|
||||
return {
|
||||
stage,
|
||||
label: stageLabel(stage),
|
||||
state: 'completed' as const,
|
||||
detail: event?.stage_detail ?? task.stage_detail,
|
||||
timestamp: event?.created_at ?? task.finished_at
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stage,
|
||||
label: stageLabel(stage),
|
||||
state: 'pending' as const,
|
||||
detail: null,
|
||||
timestamp: null
|
||||
};
|
||||
});
|
||||
}
|
||||
export {
|
||||
buildStageTimeline,
|
||||
fallbackStageProgress,
|
||||
stageLabel,
|
||||
taskStageOrder,
|
||||
taskTypeLabel,
|
||||
type StageTimelineItem
|
||||
} from '@/lib/task-workflow';
|
||||
|
||||
32
components/providers/sidebar-preference-provider.tsx
Normal file
32
components/providers/sidebar-preference-provider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
type SidebarPreferenceContextValue = {
|
||||
initialSidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
const SidebarPreferenceContext =
|
||||
createContext<SidebarPreferenceContextValue>({
|
||||
initialSidebarCollapsed: false
|
||||
});
|
||||
|
||||
type SidebarPreferenceProviderProps = {
|
||||
children: React.ReactNode;
|
||||
initialSidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
export function SidebarPreferenceProvider({
|
||||
children,
|
||||
initialSidebarCollapsed
|
||||
}: SidebarPreferenceProviderProps) {
|
||||
return (
|
||||
<SidebarPreferenceContext.Provider value={{ initialSidebarCollapsed }}>
|
||||
{children}
|
||||
</SidebarPreferenceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarPreference() {
|
||||
return useContext(SidebarPreferenceContext);
|
||||
}
|
||||
@@ -1,27 +1,45 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { TaskDetailModal } from '@/components/notifications/task-detail-modal';
|
||||
import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger';
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
BookOpenText,
|
||||
ChartCandlestick,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Landmark,
|
||||
LineChart,
|
||||
LogOut,
|
||||
Menu,
|
||||
NotebookTabs,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { TaskDetailModal } from "@/components/notifications/task-detail-modal";
|
||||
import { TaskNotificationsTrigger } from "@/components/notifications/task-notifications-trigger";
|
||||
import { useSidebarPreference } from "@/components/providers/sidebar-preference-provider";
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
companyFinancialStatementsQueryOptions,
|
||||
filingsQueryOptions,
|
||||
holdingsQueryOptions,
|
||||
latestPortfolioInsightQueryOptions,
|
||||
portfolioSummaryQueryOptions,
|
||||
recentTasksQueryOptions,
|
||||
watchlistQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type { ActiveContext, NavGroup, NavItem } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTaskNotificationsCenter } from '@/hooks/use-task-notifications-center';
|
||||
import { cn } from '@/lib/utils';
|
||||
watchlistQueryOptions,
|
||||
} from "@/lib/query/options";
|
||||
import { buildGraphingHref } from "@/lib/graphing/catalog";
|
||||
import type { ActiveContext, NavGroup, NavItem } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTaskNotificationsCenter } from "@/hooks/use-task-notifications-center";
|
||||
import { SIDEBAR_PREFERENCE_KEY } from "@/lib/sidebar-preference";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AppShellProps = {
|
||||
title: string;
|
||||
@@ -38,72 +56,102 @@ type NavConfigItem = NavItem & {
|
||||
|
||||
const NAV_ITEMS: NavConfigItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
href: '/',
|
||||
label: 'Home',
|
||||
id: "home",
|
||||
href: "/",
|
||||
label: "Home",
|
||||
icon: Activity,
|
||||
group: 'overview',
|
||||
matchMode: 'exact',
|
||||
mobilePrimary: true
|
||||
group: "overview",
|
||||
matchMode: "exact",
|
||||
mobilePrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
href: '/analysis',
|
||||
label: 'Analysis',
|
||||
id: "analysis",
|
||||
href: "/analysis",
|
||||
label: "Overview",
|
||||
icon: LineChart,
|
||||
group: 'research',
|
||||
matchMode: 'prefix',
|
||||
group: "research",
|
||||
matchMode: "prefix",
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
mobilePrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'financials',
|
||||
href: '/financials',
|
||||
label: 'Financials',
|
||||
id: "research",
|
||||
href: "/research",
|
||||
label: "Research",
|
||||
icon: NotebookTabs,
|
||||
group: "research",
|
||||
matchMode: "exact",
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true,
|
||||
},
|
||||
{
|
||||
id: "graphing",
|
||||
href: "/graphing",
|
||||
label: "Graphing",
|
||||
icon: BarChart3,
|
||||
group: "research",
|
||||
matchMode: "exact",
|
||||
preserveTicker: true,
|
||||
mobilePrimary: false,
|
||||
},
|
||||
{
|
||||
id: "financials",
|
||||
href: "/financials",
|
||||
label: "Financials",
|
||||
icon: Landmark,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
group: "research",
|
||||
matchMode: "exact",
|
||||
preserveTicker: true,
|
||||
mobilePrimary: false
|
||||
mobilePrimary: false,
|
||||
},
|
||||
{
|
||||
id: 'filings',
|
||||
href: '/filings',
|
||||
label: 'Filings',
|
||||
id: "filings",
|
||||
href: "/filings",
|
||||
label: "Filings",
|
||||
icon: BookOpenText,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
group: "research",
|
||||
matchMode: "exact",
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
mobilePrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'portfolio',
|
||||
href: '/portfolio',
|
||||
label: 'Portfolio',
|
||||
id: "search",
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: Search,
|
||||
group: "research",
|
||||
matchMode: "exact",
|
||||
preserveTicker: true,
|
||||
mobilePrimary: false,
|
||||
},
|
||||
{
|
||||
id: "portfolio",
|
||||
href: "/portfolio",
|
||||
label: "Portfolio",
|
||||
icon: ChartCandlestick,
|
||||
group: 'portfolio',
|
||||
matchMode: 'exact',
|
||||
mobilePrimary: true
|
||||
group: "portfolio",
|
||||
matchMode: "exact",
|
||||
mobilePrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'watchlist',
|
||||
href: '/watchlist',
|
||||
label: 'Coverage',
|
||||
id: "watchlist",
|
||||
href: "/watchlist",
|
||||
label: "Coverage",
|
||||
icon: Eye,
|
||||
group: 'portfolio',
|
||||
matchMode: 'exact',
|
||||
mobilePrimary: true
|
||||
}
|
||||
group: "portfolio",
|
||||
matchMode: "exact",
|
||||
mobilePrimary: true,
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_LABELS: Record<NavGroup, string> = {
|
||||
overview: 'Overview',
|
||||
research: 'Research',
|
||||
portfolio: 'Portfolio'
|
||||
overview: "Overview",
|
||||
research: "Research",
|
||||
portfolio: "Portfolio",
|
||||
};
|
||||
|
||||
function normalizeTicker(value: string | null | undefined) {
|
||||
const normalized = value?.trim().toUpperCase() ?? '';
|
||||
const normalized = value?.trim().toUpperCase() ?? "";
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
@@ -112,11 +160,15 @@ function toTickerHref(baseHref: string, activeTicker: string | null) {
|
||||
return baseHref;
|
||||
}
|
||||
|
||||
const separator = baseHref.includes('?') ? '&' : '?';
|
||||
const separator = baseHref.includes("?") ? "&" : "?";
|
||||
return `${baseHref}${separator}ticker=${encodeURIComponent(activeTicker)}`;
|
||||
}
|
||||
|
||||
function resolveNavHref(item: NavItem, context: ActiveContext) {
|
||||
if (item.href === "/graphing") {
|
||||
return buildGraphingHref(context.activeTicker);
|
||||
}
|
||||
|
||||
if (!item.preserveTicker) {
|
||||
return item.href;
|
||||
}
|
||||
@@ -125,98 +177,138 @@ function resolveNavHref(item: NavItem, context: ActiveContext) {
|
||||
}
|
||||
|
||||
function isItemActive(item: NavItem, pathname: string) {
|
||||
if (item.matchMode === 'prefix') {
|
||||
if (item.matchMode === "prefix") {
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return pathname === item.href;
|
||||
}
|
||||
|
||||
function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null) {
|
||||
const analysisHref = toTickerHref('/analysis', activeTicker);
|
||||
const financialsHref = toTickerHref('/financials', activeTicker);
|
||||
const filingsHref = toTickerHref('/filings', activeTicker);
|
||||
function buildDefaultBreadcrumbs(
|
||||
pathname: string,
|
||||
activeTicker: string | null,
|
||||
) {
|
||||
const analysisHref = toTickerHref("/analysis", activeTicker);
|
||||
const researchHref = toTickerHref("/research", activeTicker);
|
||||
const graphingHref = buildGraphingHref(activeTicker);
|
||||
const financialsHref = toTickerHref("/financials", activeTicker);
|
||||
const filingsHref = toTickerHref("/filings", activeTicker);
|
||||
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Home' }];
|
||||
if (pathname === "/") {
|
||||
return [{ label: "Home" }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/analysis/reports/')) {
|
||||
if (pathname.startsWith("/analysis/reports/")) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Reports', href: analysisHref },
|
||||
{ label: activeTicker ?? 'Summary' }
|
||||
{ label: "Overview", href: analysisHref },
|
||||
{ label: "Reports", href: analysisHref },
|
||||
{ label: activeTicker ?? "Summary" },
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/analysis')) {
|
||||
return [{ label: 'Analysis' }];
|
||||
if (pathname.startsWith("/analysis")) {
|
||||
return [{ label: "Overview" }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/financials')) {
|
||||
if (pathname.startsWith("/research")) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Financials' }
|
||||
{ label: "Overview", href: analysisHref },
|
||||
{ label: "Research", href: researchHref },
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/filings')) {
|
||||
if (pathname.startsWith("/financials")) {
|
||||
return [{ label: "Overview", href: analysisHref }, { label: "Financials" }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/graphing")) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Filings' }
|
||||
{ label: "Overview", href: analysisHref },
|
||||
{ label: "Graphing", href: graphingHref },
|
||||
{ label: activeTicker ?? "Compare Set" },
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/portfolio')) {
|
||||
return [{ label: 'Portfolio' }];
|
||||
if (pathname.startsWith("/filings")) {
|
||||
return [{ label: "Overview", href: analysisHref }, { label: "Filings" }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/watchlist')) {
|
||||
return [
|
||||
{ label: 'Portfolio', href: '/portfolio' },
|
||||
{ label: 'Coverage' }
|
||||
];
|
||||
if (pathname.startsWith("/search")) {
|
||||
return [{ label: "Overview", href: analysisHref }, { label: "Search" }];
|
||||
}
|
||||
|
||||
return [{ label: 'Home', href: '/' }, { label: pathname }];
|
||||
if (pathname.startsWith("/portfolio")) {
|
||||
return [{ label: "Portfolio" }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/watchlist")) {
|
||||
return [{ label: "Portfolio", href: "/portfolio" }, { label: "Coverage" }];
|
||||
}
|
||||
|
||||
return [{ label: "Home", href: "/" }, { label: pathname }];
|
||||
}
|
||||
|
||||
export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs, children }: AppShellProps) {
|
||||
export function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
activeTicker,
|
||||
breadcrumbs,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { initialSidebarCollapsed } = useSidebarPreference();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
|
||||
initialSidebarCollapsed,
|
||||
);
|
||||
const [hasResolvedSidebarPreference, setHasResolvedSidebarPreference] =
|
||||
useState(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const notifications = useTaskNotificationsCenter();
|
||||
const { data: session } = authClient.useSession();
|
||||
const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null;
|
||||
const sessionUser = (session?.user ?? null) as {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
role?: unknown;
|
||||
} | null;
|
||||
|
||||
const role = typeof sessionUser?.role === 'string'
|
||||
? sessionUser.role
|
||||
: Array.isArray(sessionUser?.role)
|
||||
? sessionUser.role.filter((entry): entry is string => typeof entry === 'string').join(', ')
|
||||
: null;
|
||||
const role =
|
||||
typeof sessionUser?.role === "string"
|
||||
? sessionUser.role
|
||||
: Array.isArray(sessionUser?.role)
|
||||
? sessionUser.role
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
const displayName = sessionUser?.name || sessionUser?.email || 'Authenticated user';
|
||||
const displayName =
|
||||
sessionUser?.name || sessionUser?.email || "Authenticated user";
|
||||
|
||||
const derivedTickerFromPath = useMemo(() => {
|
||||
if (!pathname.startsWith('/analysis/reports/')) {
|
||||
if (!pathname.startsWith("/analysis/reports/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const tickerSegment = segments[2];
|
||||
return tickerSegment ? normalizeTicker(decodeURIComponent(tickerSegment)) : null;
|
||||
return tickerSegment
|
||||
? normalizeTicker(decodeURIComponent(tickerSegment))
|
||||
: null;
|
||||
}, [pathname]);
|
||||
|
||||
const context: ActiveContext = useMemo(() => {
|
||||
const queryTicker = normalizeTicker(searchParams.get('ticker'));
|
||||
const queryTicker = normalizeTicker(searchParams.get("ticker"));
|
||||
|
||||
return {
|
||||
pathname,
|
||||
activeTicker: normalizeTicker(activeTicker) ?? queryTicker ?? derivedTickerFromPath
|
||||
activeTicker:
|
||||
normalizeTicker(activeTicker) ?? queryTicker ?? derivedTickerFromPath,
|
||||
};
|
||||
}, [activeTicker, derivedTickerFromPath, pathname, searchParams]);
|
||||
|
||||
@@ -226,16 +318,16 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
return {
|
||||
...item,
|
||||
href,
|
||||
active: isItemActive(item, pathname)
|
||||
active: isItemActive(item, pathname),
|
||||
};
|
||||
});
|
||||
}, [context, pathname]);
|
||||
|
||||
const groupedNav = useMemo(() => {
|
||||
const groups: Array<{ group: NavGroup; items: typeof navEntries }> = [
|
||||
{ group: 'overview', items: [] },
|
||||
{ group: 'research', items: [] },
|
||||
{ group: 'portfolio', items: [] }
|
||||
{ group: "overview", items: [] },
|
||||
{ group: "research", items: [] },
|
||||
{ group: "portfolio", items: [] },
|
||||
];
|
||||
|
||||
for (const entry of navEntries) {
|
||||
@@ -267,41 +359,72 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
const prefetchForHref = (href: string) => {
|
||||
router.prefetch(href);
|
||||
|
||||
if (href.startsWith('/analysis')) {
|
||||
if (href.startsWith("/analysis")) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker));
|
||||
void queryClient.prefetchQuery(
|
||||
companyAnalysisQueryOptions(context.activeTicker),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/financials')) {
|
||||
if (href.startsWith("/financials")) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker));
|
||||
void queryClient.prefetchQuery(
|
||||
companyAnalysisQueryOptions(context.activeTicker),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/filings')) {
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({
|
||||
ticker: context.activeTicker ?? undefined,
|
||||
limit: 120
|
||||
}));
|
||||
if (href.startsWith("/graphing")) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(
|
||||
companyFinancialStatementsQueryOptions({
|
||||
ticker: context.activeTicker,
|
||||
surfaceKind: "income_statement",
|
||||
cadence: "annual",
|
||||
includeDimensions: false,
|
||||
includeFacts: false,
|
||||
limit: 16,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/portfolio')) {
|
||||
if (href.startsWith("/filings")) {
|
||||
void queryClient.prefetchQuery(
|
||||
filingsQueryOptions({
|
||||
ticker: context.activeTicker ?? undefined,
|
||||
limit: 120,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith("/search")) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(
|
||||
companyAnalysisQueryOptions(context.activeTicker),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith("/portfolio")) {
|
||||
void queryClient.prefetchQuery(holdingsQueryOptions());
|
||||
void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
|
||||
void queryClient.prefetchQuery(latestPortfolioInsightQueryOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/watchlist')) {
|
||||
if (href.startsWith("/watchlist")) {
|
||||
void queryClient.prefetchQuery(watchlistQueryOptions());
|
||||
return;
|
||||
}
|
||||
|
||||
if (href === '/') {
|
||||
if (href === "/") {
|
||||
void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({ limit: 200 }));
|
||||
void queryClient.prefetchQuery(watchlistQueryOptions());
|
||||
@@ -310,6 +433,32 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const storedPreference = window.localStorage.getItem(
|
||||
SIDEBAR_PREFERENCE_KEY,
|
||||
);
|
||||
if (storedPreference === "true" || storedPreference === "false") {
|
||||
setIsSidebarCollapsed(storedPreference === "true");
|
||||
}
|
||||
setHasResolvedSidebarPreference(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResolvedSidebarPreference) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
SIDEBAR_PREFERENCE_KEY,
|
||||
String(isSidebarCollapsed),
|
||||
);
|
||||
document.cookie = `${SIDEBAR_PREFERENCE_KEY}=${String(isSidebarCollapsed)}; path=/; max-age=31536000; samesite=lax`;
|
||||
}, [hasResolvedSidebarPreference, isSidebarCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const browserWindow = window as Window & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback) => number;
|
||||
@@ -317,7 +466,13 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
};
|
||||
|
||||
const runPrefetch = () => {
|
||||
const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'filings' || entry.id === 'portfolio');
|
||||
const prioritized = navEntries.filter(
|
||||
(entry) =>
|
||||
entry.id === "analysis" ||
|
||||
entry.id === "graphing" ||
|
||||
entry.id === "filings" ||
|
||||
entry.id === "portfolio",
|
||||
);
|
||||
for (const entry of prioritized) {
|
||||
prefetchForHref(entry.href);
|
||||
}
|
||||
@@ -355,7 +510,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await authClient.signOut();
|
||||
router.replace('/auth/signin');
|
||||
router.replace("/auth/signin");
|
||||
} finally {
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
@@ -363,23 +518,78 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
|
||||
return (
|
||||
<div className="app-surface">
|
||||
<div className="ambient-grid" aria-hidden="true" />
|
||||
<div className="noise-layer" aria-hidden="true" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-[1300px] gap-6 px-4 pb-12 pt-6 md:px-8">
|
||||
<aside className="hidden w-72 shrink-0 flex-col gap-6 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_0_0_1px_rgba(0,255,180,0.06),0_20px_60px_rgba(1,4,10,0.55)] lg:flex">
|
||||
<div>
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">Neon Desk</h1>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
|
||||
Financial intelligence cockpit with durable AI workflows.
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 mx-auto flex min-h-screen w-full max-w-[1440px] gap-6 px-4 pb-10 pt-4 sm:px-5 sm:pb-12 sm:pt-6 md:px-8 lg:gap-8",
|
||||
isSidebarCollapsed ? "lg:pl-3" : "lg:pl-6",
|
||||
)}
|
||||
>
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden shrink-0 flex-col gap-4 border-r border-[color:var(--line-weak)] lg:flex",
|
||||
hasMounted ? "transition-[width,padding] duration-200" : "",
|
||||
isSidebarCollapsed ? "w-16 pr-1" : "w-72 pr-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3",
|
||||
isSidebarCollapsed ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!isSidebarCollapsed ? (
|
||||
<div className="min-w-0">
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">
|
||||
Fiscal Clone
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">
|
||||
Neon Desk
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
|
||||
Financial intelligence cockpit with durable AI workflows.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsSidebarCollapsed((prev) => !prev)}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed
|
||||
? "min-h-10 w-10 px-0"
|
||||
: "min-h-11 w-11 px-0",
|
||||
)}
|
||||
aria-label={
|
||||
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
|
||||
}
|
||||
aria-pressed={isSidebarCollapsed}
|
||||
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<ChevronRight className="size-4" />
|
||||
) : (
|
||||
<ChevronLeft className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-4" aria-label="Primary">
|
||||
<nav className="space-y-3" aria-label="Primary">
|
||||
{groupedNav.map(({ group, items }) => (
|
||||
<div key={group} className="space-y-2">
|
||||
<p className="terminal-caption px-2 text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{GROUP_LABELS[group]}</p>
|
||||
<div key={group} className="space-y-1.5">
|
||||
{isSidebarCollapsed ? (
|
||||
<div
|
||||
className="mx-auto h-px w-8 bg-[color:var(--line-weak)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<p className="terminal-caption px-2 text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">
|
||||
{GROUP_LABELS[group]}
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
@@ -387,18 +597,23 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
aria-label={isSidebarCollapsed ? item.label : undefined}
|
||||
onMouseEnter={() => prefetchForHref(item.href)}
|
||||
onFocus={() => prefetchForHref(item.href)}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
|
||||
"flex items-center rounded-xl border border-transparent text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent",
|
||||
isSidebarCollapsed
|
||||
? "justify-center px-0 py-2.5"
|
||||
: "gap-3 px-2.5 py-1.5",
|
||||
item.active
|
||||
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[0_0_18px_rgba(0,255,180,0.16)]'
|
||||
: 'border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]'
|
||||
? "border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] shadow-[inset_2px_0_0_var(--accent)]"
|
||||
: "text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{item.label}
|
||||
{!isSidebarCollapsed ? <span>{item.label}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -406,51 +621,82 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
|
||||
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
|
||||
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
|
||||
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
AI and market data are driven by environment configuration and live API tasks.
|
||||
</p>
|
||||
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||
<LogOut className="size-4" />
|
||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||
</Button>
|
||||
</div>
|
||||
{!isSidebarCollapsed ? (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => void signOut()}
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{isSigningOut ? "Signing out..." : "Sign out"}
|
||||
</Button>
|
||||
|
||||
<div className="rounded-[1rem] border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">
|
||||
Runtime
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">
|
||||
{displayName}
|
||||
</p>
|
||||
{role ? (
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
Role: {role}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
AI and market data are driven by environment configuration and
|
||||
live API tasks.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 pb-24 lg:pb-0">
|
||||
<header className="relative mb-4 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-6 py-5 pr-20 shadow-[0_0_0_1px_rgba(0,255,180,0.05),0_14px_40px_rgba(1,4,10,0.5)]">
|
||||
<div className="absolute right-5 top-5 z-10">
|
||||
<header className="relative mb-4 border-b border-[color:var(--line-weak)] pb-4 pr-16 sm:pb-5 sm:pr-20">
|
||||
<div className="absolute right-4 top-4 z-10 sm:right-5 sm:top-5">
|
||||
<TaskNotificationsTrigger
|
||||
unreadCount={notifications.unreadCount}
|
||||
isPopoverOpen={notifications.isPopoverOpen}
|
||||
setIsPopoverOpen={notifications.setIsPopoverOpen}
|
||||
isLoading={notifications.isLoading}
|
||||
activeTasks={notifications.activeTasks}
|
||||
visibleFinishedTasks={notifications.visibleFinishedTasks}
|
||||
awaitingReviewTasks={notifications.awaitingReviewTasks}
|
||||
activeEntries={notifications.activeEntries}
|
||||
visibleFinishedEntries={notifications.visibleFinishedEntries}
|
||||
awaitingReviewEntries={notifications.awaitingReviewEntries}
|
||||
showReadFinished={notifications.showReadFinished}
|
||||
setShowReadFinished={notifications.setShowReadFinished}
|
||||
openTaskDetails={notifications.openTaskDetails}
|
||||
silenceTask={notifications.silenceTask}
|
||||
markTaskRead={notifications.markTaskRead}
|
||||
openTaskAction={notifications.openTaskAction}
|
||||
silenceEntry={notifications.silenceEntry}
|
||||
markEntryRead={notifications.markEntryRead}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Live System</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)] md:text-3xl">{title}</h2>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 pr-6 sm:pr-0">
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">
|
||||
Live System
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl md:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
{actions}
|
||||
<Button variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="max-sm:hidden sm:inline-flex lg:hidden"
|
||||
onClick={() => void signOut()}
|
||||
disabled={isSigningOut}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||
{isSigningOut ? "Signing out..." : "Sign out"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,25 +704,35 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="mb-6 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2"
|
||||
className="mb-6 overflow-x-auto border-b border-[color:var(--line-weak)] pb-3"
|
||||
>
|
||||
<ol className="flex flex-wrap items-center gap-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
<ol className="flex min-w-max items-center gap-2 text-xs text-[color:var(--terminal-muted)] sm:min-w-0 sm:flex-wrap">
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="flex items-center gap-2">
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
onMouseEnter={() => prefetchForHref(item.href as string)}
|
||||
onMouseEnter={() =>
|
||||
prefetchForHref(item.href as string)
|
||||
}
|
||||
onFocus={() => prefetchForHref(item.href as string)}
|
||||
className="rounded px-1 py-0.5 text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn(isLast ? 'text-[color:var(--terminal-bright)]' : '')} aria-current={isLast ? 'page' : undefined}>
|
||||
<span
|
||||
className={cn(
|
||||
isLast ? "text-[color:var(--terminal-bright)]" : "",
|
||||
)}
|
||||
aria-current={isLast ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -492,10 +748,13 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--line-weak)] bg-[color:rgba(5,14,22,0.96)] px-2 py-2 backdrop-blur lg:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--line-weak)] bg-[color:rgba(24,27,32,0.96)] px-2 py-2 backdrop-blur lg:hidden"
|
||||
aria-label="Mobile primary"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-1 px-1" style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) * 0.5)' }}>
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-1 px-1"
|
||||
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) * 0.5)" }}
|
||||
>
|
||||
{mobilePrimaryEntries.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
@@ -503,14 +762,14 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onMouseEnter={() => prefetchForHref(item.href)}
|
||||
onFocus={() => prefetchForHref(item.href)}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]',
|
||||
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-lg px-2 py-1.5 text-[11px] transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]",
|
||||
item.active
|
||||
? 'bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
|
||||
: 'text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]'
|
||||
? "bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
|
||||
: "text-[color:var(--terminal-muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
@@ -533,15 +792,20 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
</nav>
|
||||
|
||||
{isMoreOpen ? (
|
||||
<div className="fixed inset-0 z-50 bg-[color:rgba(0,0,0,0.55)] lg:hidden" onClick={() => setIsMoreOpen(false)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-[color:rgba(0,0,0,0.55)] lg:hidden"
|
||||
onClick={() => setIsMoreOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="absolute inset-x-3 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3 shadow-[0_20px_60px_rgba(0,0,0,0.45)]"
|
||||
style={{ bottom: 'calc(4.7rem + env(safe-area-inset-bottom))' }}
|
||||
style={{ bottom: "calc(4.7rem + env(safe-area-inset-bottom))" }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p className="terminal-caption mb-2 px-1 text-[11px] uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">More destinations</p>
|
||||
<p className="terminal-caption mb-2 px-1 text-[11px] uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">
|
||||
More destinations
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{mobileMoreEntries.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@@ -549,15 +813,15 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onMouseEnter={() => prefetchForHref(item.href)}
|
||||
onFocus={() => prefetchForHref(item.href)}
|
||||
onClick={() => setIsMoreOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]',
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]",
|
||||
item.active
|
||||
? 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
|
||||
: 'border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]'
|
||||
? "border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]"
|
||||
: "border-transparent text-[color:var(--terminal-muted)] hover:border-[color:var(--line-weak)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--terminal-bright)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
@@ -574,7 +838,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
className="col-span-2 flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--line-strong)]"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||
{isSigningOut ? "Signing out..." : "Sign out"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
};
|
||||
|
||||
const variantMap: Record<ButtonVariant, string> = {
|
||||
primary: 'border-[color:var(--line-strong)] bg-[color:var(--accent)] text-[#001515] hover:bg-[color:var(--accent-strong)]',
|
||||
primary: 'border-[color:var(--line-strong)] bg-[color:var(--accent)] text-[#16181c] hover:bg-[color:var(--accent-strong)]',
|
||||
secondary: 'border-[color:var(--line-weak)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)] hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel)]',
|
||||
ghost: 'border-[color:var(--line-weak)] bg-transparent text-[color:var(--terminal-bright)] hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]',
|
||||
danger: 'border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[#ffc9c9] hover:bg-[color:var(--danger)] hover:text-[#1e0d0d]'
|
||||
@@ -17,7 +17,7 @@ export function Button({ className, variant = 'primary', ...props }: ButtonProps
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition duration-200 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition duration-200 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variantMap[variant],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Input({ className, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]',
|
||||
'min-h-11 w-full rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,23 +6,26 @@ type PanelProps = {
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'flat' | 'surface';
|
||||
};
|
||||
|
||||
export function Panel({ title, subtitle, actions, children, className }: PanelProps) {
|
||||
export function Panel({ title, subtitle, actions, children, className, variant = 'flat' }: PanelProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'min-w-0 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_0_0_1px_rgba(0,255,180,0.03),0_12px_30px_rgba(1,4,10,0.45)]',
|
||||
variant === 'surface'
|
||||
? 'min-w-0 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03),0_12px_30px_rgba(0,0,0,0.38)] sm:p-5'
|
||||
: 'min-w-0 border-t border-[color:var(--line-weak)] pt-4 sm:pt-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || subtitle || actions) ? (
|
||||
<header className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{title ? <h3 className="text-base font-semibold text-[color:var(--terminal-bright)]">{title}</h3> : null}
|
||||
{subtitle ? <p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div>{actions}</div> : null}
|
||||
{actions ? <div className="w-full sm:w-auto">{actions}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
|
||||
@@ -6,9 +6,9 @@ type StatusPillProps = {
|
||||
};
|
||||
|
||||
const classes: Record<TaskStatus, string> = {
|
||||
queued: 'border-[#33587a] bg-[#0a2c3f] text-[#7ecaf5]',
|
||||
running: 'border-[#4f7a33] bg-[#0f311d] text-[#99f085]',
|
||||
completed: 'border-[#1a7a53] bg-[#083a2a] text-[#8bf7cb]',
|
||||
queued: 'border-[#525861] bg-[#262a30] text-[#c0c7d0]',
|
||||
running: 'border-[#686e77] bg-[#2d3137] text-[#d8dde4]',
|
||||
completed: 'border-[#7b828c] bg-[#353a42] text-[#eef2f6]',
|
||||
failed: 'border-[#8f3d3d] bg-[#431616] text-[#ff9c9c]'
|
||||
};
|
||||
|
||||
|
||||
57
contracts/xbrl-hydrate-v1.schema.json
Normal file
57
contracts/xbrl-hydrate-v1.schema.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://fiscal.ai/contracts/xbrl-hydrate-v1.schema.json",
|
||||
"title": "Fiscal XBRL Hydrate Filing v1",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filingId": { "type": "integer" },
|
||||
"ticker": { "type": "string" },
|
||||
"cik": { "type": "string" },
|
||||
"accessionNumber": { "type": "string" },
|
||||
"filingDate": { "type": "string", "format": "date" },
|
||||
"filingType": { "type": "string", "enum": ["10-K", "10-Q"] },
|
||||
"filingUrl": { "type": ["string", "null"] },
|
||||
"primaryDocument": { "type": ["string", "null"] },
|
||||
"cacheDir": { "type": "string" }
|
||||
},
|
||||
"required": [
|
||||
"filingId",
|
||||
"ticker",
|
||||
"cik",
|
||||
"accessionNumber",
|
||||
"filingDate",
|
||||
"filingType",
|
||||
"cacheDir"
|
||||
],
|
||||
"$defs": {
|
||||
"statementKind": {
|
||||
"type": "string",
|
||||
"enum": ["income", "balance", "cash_flow", "equity", "comprehensive_income"]
|
||||
},
|
||||
"surfaceRow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"label": { "type": "string" },
|
||||
"category": { "type": "string" },
|
||||
"order": { "type": "integer" },
|
||||
"unit": { "type": "string" },
|
||||
"values": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["number", "null"]
|
||||
}
|
||||
},
|
||||
"sourceConcepts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"sourceFactIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"required": ["key", "label", "category", "order", "unit", "values", "sourceConcepts", "sourceFactIds"]
|
||||
}
|
||||
}
|
||||
}
|
||||
391
docs/DESIGN_SYSTEM.md
Normal file
391
docs/DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# Fiscal Clone Design System
|
||||
|
||||
This file is the shared design reference for Fiscal Clone. Use it when building new components, revising existing screens, or making frontend design decisions.
|
||||
|
||||
## How to use this document
|
||||
|
||||
- Treat this file as the human-readable source of truth for UI decisions.
|
||||
- Treat `app/globals.css` as the implementation source of truth for tokens already in code.
|
||||
- If a new UI pattern is introduced, update this document in the same change so the system does not drift.
|
||||
- Prefer extending an existing pattern before inventing a new one.
|
||||
|
||||
## Product and visual intent
|
||||
|
||||
Fiscal Clone is a financial analysis terminal, not a generic SaaS dashboard.
|
||||
|
||||
The UI should feel:
|
||||
|
||||
- focused
|
||||
- technical
|
||||
- data-dense without looking cramped
|
||||
- calm and high-confidence
|
||||
- operational, with clear state and hierarchy
|
||||
|
||||
The current visual language is a dark research workstation with muted chrome, soft glass-like surfaces, monospaced metadata, and restrained highlights.
|
||||
|
||||
## Core design principles
|
||||
|
||||
1. Data first. Numbers, charts, and evidence should dominate decorative elements.
|
||||
2. Restraint over novelty. Avoid bright accents, oversized illustrations, and consumer-app styling.
|
||||
3. One primary action per area. Panels and toolbars should make the next step obvious.
|
||||
4. Reuse familiar surfaces. Panels, pills, tables, and toolbars should feel like parts of one terminal.
|
||||
5. Dense, not crowded. Compact layouts are fine, but preserve scanability and whitespace between blocks.
|
||||
6. Status must be legible. State should never depend on color alone.
|
||||
7. Motion is ambient, not performative. Animation should support orientation, not demand attention.
|
||||
|
||||
## Brand voice and tone
|
||||
|
||||
Interface copy should sound like an analyst workstation:
|
||||
|
||||
- concise
|
||||
- factual
|
||||
- operational
|
||||
- low-hype
|
||||
|
||||
Prefer:
|
||||
|
||||
- "Load overview"
|
||||
- "Queue AI insight"
|
||||
- "Recent tasks"
|
||||
- "Runtime state: stable"
|
||||
|
||||
Avoid:
|
||||
|
||||
- playful marketing language
|
||||
- vague CTA labels like "Explore" or "Learn more"
|
||||
- overly conversational empty states
|
||||
|
||||
## Layout system
|
||||
|
||||
### Application shell
|
||||
|
||||
The app shell is the primary frame for authenticated product surfaces.
|
||||
|
||||
- Left navigation is the persistent desktop anchor.
|
||||
- Mobile navigation is task-oriented and bottom-docked.
|
||||
- Every page should have a clear title, optional subtitle, and optional action group.
|
||||
- Breadcrumbs should orient the user inside research workflows, especially when a ticker is active.
|
||||
|
||||
Reference implementation: `components/shell/app-shell.tsx`
|
||||
|
||||
### Page structure
|
||||
|
||||
Use this ordering for most product pages:
|
||||
|
||||
1. page title and subtitle
|
||||
2. top-level actions
|
||||
3. summary metrics or controls
|
||||
4. primary content panels
|
||||
5. secondary detail or supporting panels
|
||||
|
||||
### Grid guidance
|
||||
|
||||
- Prefer `gap-2` for tight dashboard summaries.
|
||||
- Prefer `gap-4` for full content sections.
|
||||
- Use one-column layout by default on mobile.
|
||||
- Expand to two or three columns only when comparison improves comprehension.
|
||||
- Keep important analytical content in the left or center visual path.
|
||||
|
||||
### Width and spacing
|
||||
|
||||
- The Tailwind container is centered with `2rem` horizontal padding and a `1400px` `2xl` width.
|
||||
- Major surfaces typically use `rounded-2xl`.
|
||||
- Controls typically use `rounded-xl`.
|
||||
- Default panel padding is `p-4` on mobile and `sm:p-5` or `sm:p-6` on larger screens.
|
||||
|
||||
## Typography
|
||||
|
||||
### Font roles
|
||||
|
||||
- Primary UI font: `--font-display`
|
||||
- Monospaced metadata font: `--font-mono`
|
||||
|
||||
The display font is used for headings, paragraph content, and core UI. The mono font is reserved for labels, captions, table headers, status metadata, and technical framing.
|
||||
|
||||
Reference implementation: `app/globals.css`
|
||||
|
||||
### Type hierarchy
|
||||
|
||||
- Page titles: `text-2xl` to `text-3xl`, semibold
|
||||
- Section titles: `text-lg` to `text-xl`, semibold
|
||||
- Panel titles: `text-base`
|
||||
- Body copy: `text-sm` with generous line height where content is dense
|
||||
- Metadata and eyebrow labels: `text-xs` or `text-[11px]`, uppercase, increased letter spacing
|
||||
|
||||
### Typography rules
|
||||
|
||||
- Use uppercase mono labels for section identifiers, table headers, and compact metadata.
|
||||
- Avoid long all-caps paragraphs.
|
||||
- Keep body copy left-aligned.
|
||||
- Keep line length moderate for explanatory text.
|
||||
|
||||
## Color and surface system
|
||||
|
||||
### Primary tokens
|
||||
|
||||
These tokens already define the current visual system:
|
||||
|
||||
| Token | Value | Usage |
|
||||
| --- | --- | --- |
|
||||
| `--bg-0` | `#121417` | page background base |
|
||||
| `--bg-1` | `#181b20` | page background mid |
|
||||
| `--bg-2` | `#21252b` | page background depth |
|
||||
| `--panel` | `rgba(28, 31, 36, 0.84)` | primary panel surface |
|
||||
| `--panel-soft` | `rgba(36, 39, 45, 0.72)` | secondary panel or hover surface |
|
||||
| `--panel-bright` | `rgba(49, 53, 60, 0.94)` | stronger elevated surface |
|
||||
| `--line-weak` | `rgba(196, 202, 211, 0.18)` | default border |
|
||||
| `--line-strong` | `rgba(220, 226, 234, 0.34)` | active or hover border |
|
||||
| `--accent` | `#d9dee5` | restrained accent |
|
||||
| `--accent-strong` | `#f4f7fb` | brighter accent state |
|
||||
| `--danger` | `#ff8e8e` | destructive state |
|
||||
| `--danger-soft` | `rgba(111, 46, 46, 0.42)` | destructive surface |
|
||||
| `--terminal-bright` | `#f3f5f7` | primary foreground |
|
||||
| `--terminal-muted` | `#a1a9b3` | secondary foreground |
|
||||
| `--focus-ring` | `rgba(229, 231, 235, 0.14)` | focus halo |
|
||||
|
||||
Reference implementation: `app/globals.css`
|
||||
|
||||
### Color usage rules
|
||||
|
||||
- Use muted neutrals for most surfaces and borders.
|
||||
- Use `--accent` sparingly for links, primary actions, and selected emphasis.
|
||||
- Use `--danger` only for destructive actions or failure states.
|
||||
- Success color may appear in metrics, but should remain understated.
|
||||
- Never rely on saturated color blocks as the main visual identity.
|
||||
|
||||
### Background treatment
|
||||
|
||||
Backgrounds should feel atmospheric and infrastructural:
|
||||
|
||||
- layered radial gradients
|
||||
- subtle grid textures
|
||||
- low-opacity noise
|
||||
|
||||
These effects support the terminal identity and should remain secondary to content.
|
||||
|
||||
## Shape, borders, and depth
|
||||
|
||||
- Standard control radius: `rounded-xl`
|
||||
- Standard panel radius: `rounded-2xl`
|
||||
- Most surfaces use a 1px border with `--line-weak`
|
||||
- Hover and active states strengthen the border before changing fill aggressively
|
||||
- Shadows should feel soft and deep, not crisp or card-like
|
||||
|
||||
## Component decisions
|
||||
|
||||
### Buttons
|
||||
|
||||
There are four established button variants:
|
||||
|
||||
- `primary`
|
||||
- `secondary`
|
||||
- `ghost`
|
||||
- `danger`
|
||||
|
||||
Button rules:
|
||||
|
||||
- Minimum touch height is `44px` via `min-h-11`
|
||||
- Use icon-plus-label pairs when the action benefits from faster scanning
|
||||
- Keep labels direct and verb-based
|
||||
- Prefer `primary` for the single most important action in a local group
|
||||
- Use `secondary` for adjacent supporting actions
|
||||
- Use `ghost` for low-emphasis actions
|
||||
- Use `danger` only when the action is irreversible or risky
|
||||
|
||||
Reference implementation: `components/ui/button.tsx`
|
||||
|
||||
### Inputs
|
||||
|
||||
Inputs use soft panel surfaces with a stronger border and subtle halo on focus.
|
||||
|
||||
Input rules:
|
||||
|
||||
- Minimum touch height is `44px`
|
||||
- Placeholder text should remain muted and never act as the only label
|
||||
- External labels are preferred for forms
|
||||
- Search and symbol-entry fields may include leading icons
|
||||
|
||||
Reference implementation: `components/ui/input.tsx`
|
||||
|
||||
### Panels
|
||||
|
||||
Panels are the core information container.
|
||||
|
||||
Use `surface` panels for:
|
||||
|
||||
- grouped data blocks
|
||||
- modal-like sections
|
||||
- elevated analytical modules
|
||||
|
||||
Use `flat` sections for:
|
||||
|
||||
- page subsections
|
||||
- inline grouped content that does not need a full card treatment
|
||||
|
||||
Panel rules:
|
||||
|
||||
- Titles should be short and descriptive
|
||||
- Subtitles should explain context or time horizon
|
||||
- Header actions should stay compact
|
||||
- Avoid stacking too many fully elevated panels when a flatter rhythm improves readability
|
||||
|
||||
Reference implementation: `components/ui/panel.tsx`
|
||||
|
||||
### Status pills and tags
|
||||
|
||||
Status pills should be compact, uppercase, and visually coded, but the text label must remain meaningful on its own.
|
||||
|
||||
Use pills for:
|
||||
|
||||
- job status
|
||||
- research posture
|
||||
- small categorical states
|
||||
|
||||
Reference implementation: `components/ui/status-pill.tsx`
|
||||
|
||||
### Metric cards
|
||||
|
||||
Metric cards are terse summary blocks, not mini-panels.
|
||||
|
||||
Rules:
|
||||
|
||||
- lead with the metric value
|
||||
- keep the label in mono uppercase style
|
||||
- use delta text only for meaningful comparison or trend context
|
||||
- reserve green and red text for clearly directional values
|
||||
|
||||
Reference implementation: `components/dashboard/metric-card.tsx`
|
||||
|
||||
### Tables
|
||||
|
||||
Tables are a first-class pattern in this product.
|
||||
|
||||
Rules:
|
||||
|
||||
- use `.data-table` and `.data-table-wrap` styles where possible
|
||||
- table headers should use mono uppercase metadata styling
|
||||
- row hover may add a subtle background, but should not reduce readability
|
||||
- numeric columns should remain easy to compare visually
|
||||
- avoid decorative striping unless it materially helps scanability
|
||||
|
||||
Reference implementation: `app/globals.css`
|
||||
|
||||
### Navigation links
|
||||
|
||||
Navigation and quick links should feel like terminal controls, not marketing cards.
|
||||
|
||||
Rules:
|
||||
|
||||
- prefer border, text, and surface changes over large movement
|
||||
- active states should be obvious from both color and structure
|
||||
- keep mobile nav labels short
|
||||
|
||||
## Interaction and motion
|
||||
|
||||
### Interaction rules
|
||||
|
||||
- Hover states should generally strengthen border, foreground, or surface contrast.
|
||||
- Focus states must always be visible.
|
||||
- Loading states should be plain and informative.
|
||||
- Empty states should explain what to do next.
|
||||
- Error states should be specific and concise.
|
||||
|
||||
### Motion rules
|
||||
|
||||
- Standard transition duration is about `200ms`
|
||||
- Keep motion subtle and utility-driven
|
||||
- Ambient background animation is acceptable when low contrast and slow
|
||||
- Respect `prefers-reduced-motion`
|
||||
- Do not add decorative animation to dense analytical surfaces
|
||||
|
||||
Reference implementation: `app/globals.css`
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
- Mobile should preserve the same visual language, not switch to a different product identity.
|
||||
- Collapse complex layouts vertically before removing information.
|
||||
- Horizontal scroll is acceptable for dense tables, but not for primary page structure.
|
||||
- Mobile action groups should stack cleanly and remain thumb-friendly.
|
||||
- Reduce background intensity slightly on small screens to preserve legibility.
|
||||
|
||||
References:
|
||||
|
||||
- `components/shell/app-shell.tsx`
|
||||
- `components/auth/auth-shell.tsx`
|
||||
- `app/globals.css`
|
||||
|
||||
## Accessibility baseline
|
||||
|
||||
All new UI should meet this baseline:
|
||||
|
||||
- WCAG 2.1 AA contrast for body text and controls
|
||||
- keyboard access for all interactive elements
|
||||
- visible focus styles
|
||||
- labels for all inputs
|
||||
- no color-only status communication
|
||||
- readable hit targets at or above 44x44px where practical
|
||||
- motion should degrade gracefully for reduced-motion users
|
||||
|
||||
Additional guidance:
|
||||
|
||||
- Use semantic HTML first.
|
||||
- Use ARIA only when native semantics are insufficient.
|
||||
- Keep tab order aligned with the visual reading order.
|
||||
- Preserve text clarity over background effects.
|
||||
|
||||
## Page-specific patterns
|
||||
|
||||
### Auth pages
|
||||
|
||||
Auth pages use a split layout:
|
||||
|
||||
- one side for product framing
|
||||
- one side for the form
|
||||
|
||||
This area should feel slightly more cinematic than the internal app, but still restrained and operational.
|
||||
|
||||
Reference implementation: `components/auth/auth-shell.tsx`
|
||||
|
||||
### Research and analysis pages
|
||||
|
||||
These pages are company-first and should foreground the selected ticker, current context, and next analytical move.
|
||||
|
||||
Use:
|
||||
|
||||
- a strong top toolbar
|
||||
- fast context switching between adjacent research surfaces
|
||||
- supporting metadata close to the relevant content
|
||||
|
||||
Reference implementation: `components/analysis/analysis-toolbar.tsx`
|
||||
|
||||
## Rules for adding new components
|
||||
|
||||
Before adding a new component:
|
||||
|
||||
1. Check whether `Button`, `Input`, `Panel`, table styles, pills, or existing shell patterns already solve the need.
|
||||
2. Reuse existing tokens before introducing new colors, radii, shadows, or spacing values.
|
||||
3. Match the established terminal tone in both visuals and copy.
|
||||
4. Test the component in mobile and desktop layouts.
|
||||
5. Verify keyboard focus, contrast, and empty or loading states.
|
||||
|
||||
Create a new pattern only when reuse would produce awkward semantics or poor usability.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Avoid pushing the interface toward:
|
||||
|
||||
- bright startup-dashboard aesthetics
|
||||
- highly animated consumer-product styling
|
||||
- oversized rounded cards everywhere
|
||||
- generic marketing-site gradients disconnected from the product
|
||||
- decorative icons or illustrations that compete with data
|
||||
|
||||
## Update protocol
|
||||
|
||||
When a design decision changes:
|
||||
|
||||
1. update the implementation
|
||||
2. update this file in the same PR
|
||||
3. note the new default pattern instead of documenting one-off exceptions
|
||||
|
||||
If the code and this file disagree, align them quickly. Long-lived mismatches will turn this document into stale theory, which makes it useless.
|
||||
145
docs/architecture/financial-surfaces.md
Normal file
145
docs/architecture/financial-surfaces.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Financial Surface Definitions Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
As of Issue #26, the financial statement mapping architecture follows a **Rust-first approach** where the Rust sidecar is the authoritative source for surface definitions.
|
||||
|
||||
**All legacy TypeScript template code has been removed.**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SEC EDGAR Filing │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Rust Sidecar (fiscal-xbrl) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ rust/taxonomy/fiscal/v1/core.surface.json │ │
|
||||
│ │ rust/taxonomy/fiscal/v1/core.income-bridge.json │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ surface_mapper.rs - builds surface_rows │ │
|
||||
│ │ kpi_mapper.rs - builds kpi_rows │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ filing_taxonomy_snapshot.surface_rows │
|
||||
│ filing_taxonomy_snapshot.detail_rows │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TypeScript Layer │
|
||||
│ financial-taxonomy.ts:aggregateSurfaceRows() │
|
||||
│ - Reads surface_rows from DB snapshots │
|
||||
│ - Aggregates across selected periods │
|
||||
│ - Returns to frontend for display │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Source of Truth
|
||||
|
||||
### Authoritative Sources (Edit These)
|
||||
1. **`rust/taxonomy/fiscal/v1/core.surface.json`**
|
||||
- Defines all surface keys, labels, categories, orders, and formulas
|
||||
- Example: `revenue`, `cost_of_revenue`, `gross_profit`, `net_income`
|
||||
|
||||
2. **`rust/taxonomy/fiscal/v1/core.income-bridge.json`**
|
||||
- Maps XBRL concepts to income statement surfaces
|
||||
- Defines component surfaces for formula derivation
|
||||
|
||||
### Removed Files (Do NOT Recreate)
|
||||
The following files have been **permanently removed**:
|
||||
1. ~~`lib/server/financials/standard-template.ts`~~ - Template definitions (now in Rust JSON)
|
||||
2. ~~`lib/server/financials/surface.ts`~~ - Fallback surface builder (no longer needed)
|
||||
3. ~~`lib/server/financials/standardize.ts`~~ - Template-based row builder (replaced by Rust)
|
||||
|
||||
### Remaining TypeScript Helpers
|
||||
`lib/server/financials/standardize.ts` (simplified version) contains only:
|
||||
- `buildLtmStandardizedRows` - Computes LTM values from quarterly data
|
||||
- `buildDimensionBreakdown` - Builds dimension breakdowns from facts
|
||||
|
||||
These operate on already-mapped surface data from the Rust sidecar.
|
||||
|
||||
2. **`lib/server/financials/standardize.ts`**
|
||||
- Contains `buildStandardizedRows` - kept for fallback/testing only
|
||||
- Marked as `@deprecated`
|
||||
|
||||
## How to Add a New Surface
|
||||
|
||||
1. **Add to `rust/taxonomy/fiscal/v1/core.surface.json`**:
|
||||
```json
|
||||
{
|
||||
"surface_key": "new_metric",
|
||||
"statement": "income",
|
||||
"label": "New Metric",
|
||||
"category": "surface",
|
||||
"order": 100,
|
||||
"unit": "currency",
|
||||
"rollup_policy": "direct_only",
|
||||
"allowed_source_concepts": ["us-gaap:NewMetricConcept"],
|
||||
"allowed_authoritative_concepts": ["us-gaap:NewMetricConcept"],
|
||||
"formula_fallback": null,
|
||||
"detail_grouping_policy": "top_level_only",
|
||||
"materiality_policy": "income_default"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add concept mapping to `core.income-bridge.json`** (if needed):
|
||||
```json
|
||||
"new_metric": {
|
||||
"direct_authoritative_concepts": ["us-gaap:NewMetricConcept"],
|
||||
"direct_source_concepts": ["NewMetricConcept"],
|
||||
"component_surfaces": { "positive": [], "negative": [] },
|
||||
"component_concept_groups": { "positive": [], "negative": [] },
|
||||
"formula": "direct",
|
||||
"not_meaningful_for_pack": false,
|
||||
"warning_codes_when_used": []
|
||||
}
|
||||
```
|
||||
|
||||
3. **Rebuild the Rust sidecar**:
|
||||
```bash
|
||||
cd rust && cargo build --release
|
||||
```
|
||||
|
||||
4. **Re-ingest filings** to populate the new surface
|
||||
|
||||
## Key Surfaces
|
||||
### Income Statement
|
||||
| Key | Order | Description |
|
||||
|-----|------|-------------|
|
||||
| `revenue` | 10 | Top-line revenue |
|
||||
| `cost_of_revenue` | 20 | Cost of revenue/COGS |
|
||||
| `gross_profit` | 30 | Revenue - Cost of Revenue |
|
||||
| `gross_margin` | 35 | Gross Profit / Revenue (percent) |
|
||||
| `operating_expenses` | 40 | Total operating expenses |
|
||||
| `operating_income` | 60 | Gross Profit - Operating Expenses |
|
||||
| `operating_margin` | 65 | Operating Income / Revenue (percent) |
|
||||
| `pretax_income` | 80 | Income before taxes |
|
||||
| `income_tax_expense` | 85 | Income tax provision |
|
||||
| `effective_tax_rate` | 87 | Tax Expense / Pretax Income (percent) |
|
||||
| `ebitda` | 88 | Operating Income + D&A |
|
||||
| `net_income` | 90 | Bottom-line net income |
|
||||
| `diluted_eps` | 100 | Diluted earnings per share |
|
||||
| `basic_eps` | 105 | Basic earnings per share |
|
||||
| `diluted_shares` | 110 | Weighted avg diluted shares |
|
||||
| `basic_shares` | 115 | Weighted avg basic shares |
|
||||
|
||||
### Balance Sheet
|
||||
See `rust/taxonomy/fiscal/v1/core.surface.json` for complete list.
|
||||
|
||||
### Cash Flow Statement
|
||||
See `rust/taxonomy/fiscal/v1/core.surface.json` for complete list.
|
||||
|
||||
## Related Files
|
||||
- `rust/fiscal-xbrl-core/src/surface_mapper.rs` - Surface resolution logic
|
||||
- `rust/fiscal-xbrl-core/src/taxonomy_loader.rs` - JSON loading
|
||||
- `lib/server/repos/filing-taxonomy.ts` - DB operations
|
||||
- `lib/server/financial-taxonomy.ts` - Main entry point
|
||||
292
docs/architecture/taxonomy.md
Normal file
292
docs/architecture/taxonomy.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Taxonomy Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The taxonomy system defines all financial surfaces, computed ratios, and KPIs used throughout the application. The Rust JSON files in `rust/taxonomy/` serve as the **single source of truth** for all financial definitions.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ rust/taxonomy/fiscal/v1/ │
|
||||
│ │
|
||||
│ core.surface.json - Income/Balance/Cash Flow surfaces │
|
||||
│ core.computed.json - Ratio definitions │
|
||||
│ core.kpis.json - Sector-specific KPIs │
|
||||
│ core.income-bridge.json - Income statement mapping rules │
|
||||
│ │
|
||||
│ bank_lender.surface.json - Bank-specific surfaces │
|
||||
│ insurance.surface.json - Insurance-specific surfaces │
|
||||
│ reit_real_estate.surface.json - REIT-specific surfaces │
|
||||
│ broker_asset_manager.surface.json - Asset manager surfaces │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌────────────────┐ ┌──────────────┐
|
||||
│ Rust Sidecar│ │ TS Generator │ │ TypeScript │
|
||||
│ fiscal-xbrl │ │ scripts/ │ │ Runtime │
|
||||
│ │ │ generate- │ │ │
|
||||
│ Parses XBRL │ │ taxonomy.ts │ │ UI/API │
|
||||
│ Maps to │ │ │ │ │
|
||||
│ surfaces │ │ Generates TS │ │ Uses generated│
|
||||
│ Computes │ │ types & consts │ │ definitions │
|
||||
│ ratios │ │ │ │ │
|
||||
└──────┬──────┘ └───────┬────────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ lib/generated│ │
|
||||
│ │ (gitignored) │ │
|
||||
│ │ │ │
|
||||
│ │ surfaces/ │ │
|
||||
│ │ computed/ │ │
|
||||
│ │ kpis/ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ lib/financial-metrics.ts │
|
||||
│ │
|
||||
│ Thin wrapper that: │
|
||||
│ - Re-exports generated types │
|
||||
│ - Provides UI-specific types (GraphableFinancialSurface) │
|
||||
│ - Transforms surfaces to metric definitions │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
rust/taxonomy/fiscal/v1/
|
||||
├── core.surface.json # Core financial surfaces
|
||||
├── core.computed.json # Ratio definitions (32 ratios)
|
||||
├── core.income-bridge.json # Income statement XBRL mapping
|
||||
├── core.kpis.json # Core KPIs (mostly empty)
|
||||
├── universal_income.surface.json
|
||||
│
|
||||
├── bank_lender.surface.json
|
||||
├── bank_lender.income-bridge.json
|
||||
├── bank_lender.kpis.json
|
||||
│
|
||||
├── insurance.surface.json
|
||||
├── insurance.income-bridge.json
|
||||
├── insurance.kpis.json
|
||||
│
|
||||
├── reit_real_estate.surface.json
|
||||
├── reit_real_estate.income-bridge.json
|
||||
├── reit_real_estate.kpis.json
|
||||
│
|
||||
├── broker_asset_manager.surface.json
|
||||
├── broker_asset_manager.income-bridge.json
|
||||
├── broker_asset_manager.kpis.json
|
||||
│
|
||||
└── kpis/
|
||||
└── *.kpis.json
|
||||
|
||||
lib/generated/ # Auto-generated, gitignored
|
||||
├── index.ts
|
||||
├── types.ts
|
||||
├── surfaces/
|
||||
│ ├── index.ts
|
||||
│ ├── income.ts
|
||||
│ ├── balance.ts
|
||||
│ └── cash_flow.ts
|
||||
├── computed/
|
||||
│ ├── index.ts
|
||||
│ └── core.ts
|
||||
└── kpis/
|
||||
├── index.ts
|
||||
└── *.ts
|
||||
```
|
||||
|
||||
## Surface Definitions
|
||||
|
||||
Surfaces represent canonical financial line items. Each surface maps XBRL concepts to a standardized key.
|
||||
|
||||
```json
|
||||
{
|
||||
"surface_key": "revenue",
|
||||
"statement": "income",
|
||||
"label": "Revenue",
|
||||
"category": "surface",
|
||||
"order": 10,
|
||||
"unit": "currency",
|
||||
"rollup_policy": "direct_or_formula",
|
||||
"allowed_source_concepts": [
|
||||
"us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax",
|
||||
"us-gaap:SalesRevenueNet"
|
||||
],
|
||||
"formula_fallback": null
|
||||
}
|
||||
```
|
||||
|
||||
### Surface Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `surface_key` | string | Unique identifier (snake_case) |
|
||||
| `statement` | enum | `income`, `balance`, `cash_flow`, `equity`, `comprehensive_income` |
|
||||
| `label` | string | Human-readable label |
|
||||
| `category` | string | Grouping category |
|
||||
| `order` | number | Display order |
|
||||
| `unit` | enum | `currency`, `percent`, `ratio`, `shares`, `count` |
|
||||
| `rollup_policy` | string | How to aggregate: `direct_only`, `direct_or_formula`, `aggregate_children`, `formula_only` |
|
||||
| `allowed_source_concepts` | string[] | XBRL concepts that map to this surface |
|
||||
| `formula_fallback` | object | Optional formula when no direct mapping |
|
||||
|
||||
## Computed Definitions
|
||||
|
||||
Computed definitions describe ratios and derived metrics. They are split into two phases:
|
||||
|
||||
### Phase 1: Filing-Derived (Rust computes)
|
||||
|
||||
Ratios computable from filing data alone:
|
||||
- **Margins**: gross_margin, operating_margin, ebitda_margin, net_margin, fcf_margin
|
||||
- **Returns**: roa, roe, roic, roce
|
||||
- **Financial Health**: debt_to_equity, net_debt_to_ebitda, cash_to_debt, current_ratio
|
||||
- **Per-Share**: revenue_per_share, fcf_per_share, book_value_per_share
|
||||
- **Growth**: revenue_yoy, net_income_yoy, eps_yoy, fcf_yoy, *_cagr
|
||||
|
||||
### Phase 2: Market-Derived (TypeScript computes)
|
||||
|
||||
Ratios requiring external price data:
|
||||
- **Valuation**: market_cap, enterprise_value, price_to_earnings, price_to_fcf, price_to_book, ev_to_*
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "gross_margin",
|
||||
"label": "Gross Margin",
|
||||
"category": "margins",
|
||||
"order": 10,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "gross_profit",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "price_to_earnings",
|
||||
"label": "Price to Earnings",
|
||||
"category": "valuation",
|
||||
"order": 270,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "price / diluted_eps"
|
||||
},
|
||||
"requires_external_data": ["price"]
|
||||
}
|
||||
```
|
||||
|
||||
### Computation Types
|
||||
|
||||
| Type | Fields | Description |
|
||||
|------|--------|-------------|
|
||||
| `ratio` | numerator, denominator | Simple division |
|
||||
| `yoy_growth` | source | Year-over-year percentage change |
|
||||
| `cagr` | source, years | Compound annual growth rate |
|
||||
| `per_share` | source, shares_key | Divide by share count |
|
||||
| `simple` | formula | Custom formula expression |
|
||||
|
||||
## Pack Inheritance
|
||||
|
||||
Non-core packs inherit balance and cash_flow surfaces from core:
|
||||
|
||||
```rust
|
||||
// taxonomy_loader.rs
|
||||
if !matches!(pack, FiscalPack::Core) {
|
||||
// Inherit balance + cash_flow from core
|
||||
// Override with pack-specific definitions
|
||||
}
|
||||
```
|
||||
|
||||
This ensures consistency across packs while allowing sector-specific income statements.
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
```bash
|
||||
# Generate TypeScript from Rust JSON
|
||||
bun run generate
|
||||
|
||||
# Build Rust sidecar (includes taxonomy)
|
||||
bun run build:sidecar
|
||||
|
||||
# Full build (generates + compiles)
|
||||
bun run build
|
||||
```
|
||||
|
||||
### package.json Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `generate` | Run taxonomy generator |
|
||||
| `build:sidecar` | Build Rust binary |
|
||||
| `build` | Generate + Next.js build |
|
||||
| `lint` | Generate + TypeScript check |
|
||||
|
||||
## Validation
|
||||
|
||||
The generator validates:
|
||||
1. No duplicate surface keys within the same statement
|
||||
2. All ratio numerators/denominators reference existing surfaces
|
||||
3. Required fields present on all definitions
|
||||
4. Valid statement/unit/category values
|
||||
|
||||
Run validation:
|
||||
```bash
|
||||
bun run generate # Validates during generation
|
||||
```
|
||||
|
||||
## Extending the Taxonomy
|
||||
|
||||
### Adding a New Surface
|
||||
|
||||
1. Edit `rust/taxonomy/fiscal/v1/core.surface.json`
|
||||
2. Add surface definition with unique key
|
||||
3. Run `bun run generate` to regenerate TypeScript
|
||||
4. Run `bun run build:sidecar` to rebuild Rust
|
||||
|
||||
### Adding a New Ratio
|
||||
|
||||
1. Edit `rust/taxonomy/fiscal/v1/core.computed.json`
|
||||
2. Add computed definition with computation spec
|
||||
3. If market-derived, add `requires_external_data`
|
||||
4. Run `bun run generate`
|
||||
|
||||
### Adding a New Sector Pack
|
||||
|
||||
1. Create `rust/taxonomy/fiscal/v1/<pack>.surface.json`
|
||||
2. Create `rust/taxonomy/fiscal/v1/<pack>.income-bridge.json`
|
||||
3. Create `rust/taxonomy/fiscal/v1/<pack>.kpis.json` (if needed)
|
||||
4. Add pack to `PACK_ORDER` in `scripts/generate-taxonomy.ts`
|
||||
5. Add pack to `FiscalPack` enum in `rust/fiscal-xbrl-core/src/pack_selector.rs`
|
||||
6. Run `bun run generate && bun run build:sidecar`
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why Rust JSON as Source of Truth?
|
||||
|
||||
1. **Single definition**: XBRL mapping and TypeScript use the same definitions
|
||||
2. **Type safety**: Rust validates JSON at compile time
|
||||
3. **Performance**: No runtime JSON parsing in TypeScript
|
||||
4. **Consistency**: Impossible for Rust and TypeScript to drift
|
||||
|
||||
### Why Gitignore Generated Files?
|
||||
|
||||
1. **Single source of truth**: Forces changes through Rust JSON
|
||||
2. **No merge conflicts**: Generated code never conflicts
|
||||
3. **Smaller repo**: No large generated files in history
|
||||
4. **CI validation**: CI regenerates and validates
|
||||
|
||||
### Why Two-Phase Ratio Computation?
|
||||
|
||||
1. **Filing-derived ratios**: Can be computed at parse time by Rust
|
||||
2. **Market-derived ratios**: Require real-time price data
|
||||
3. **Separation of concerns**: Rust handles XBRL, TypeScript handles market data
|
||||
4. **Same definitions**: Both phases use the same computation specs
|
||||
91
drizzle/0008_research_workspace.sql
Normal file
91
drizzle/0008_research_workspace.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
CREATE TABLE `research_artifact` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text,
|
||||
`ticker` text NOT NULL,
|
||||
`accession_number` text,
|
||||
`kind` text NOT NULL,
|
||||
`source` text NOT NULL DEFAULT 'user',
|
||||
`subtype` text,
|
||||
`title` text,
|
||||
`summary` text,
|
||||
`body_markdown` text,
|
||||
`search_text` text,
|
||||
`visibility_scope` text NOT NULL DEFAULT 'private',
|
||||
`tags` text,
|
||||
`metadata` text,
|
||||
`file_name` text,
|
||||
`mime_type` text,
|
||||
`file_size_bytes` integer,
|
||||
`storage_path` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_ticker_idx` ON `research_artifact` (`user_id`,`ticker`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_kind_idx` ON `research_artifact` (`user_id`,`kind`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_accession_idx` ON `research_artifact` (`user_id`,`accession_number`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_source_idx` ON `research_artifact` (`user_id`,`source`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `research_memo` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text,
|
||||
`ticker` text NOT NULL,
|
||||
`rating` text,
|
||||
`conviction` text,
|
||||
`time_horizon_months` integer,
|
||||
`packet_title` text,
|
||||
`packet_subtitle` text,
|
||||
`thesis_markdown` text NOT NULL DEFAULT '',
|
||||
`variant_view_markdown` text NOT NULL DEFAULT '',
|
||||
`catalysts_markdown` text NOT NULL DEFAULT '',
|
||||
`risks_markdown` text NOT NULL DEFAULT '',
|
||||
`disconfirming_evidence_markdown` text NOT NULL DEFAULT '',
|
||||
`next_actions_markdown` text NOT NULL DEFAULT '',
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `research_memo_ticker_uidx` ON `research_memo` (`user_id`,`ticker`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_memo_updated_idx` ON `research_memo` (`user_id`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `research_memo_evidence` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`memo_id` integer NOT NULL,
|
||||
`artifact_id` integer NOT NULL,
|
||||
`section` text NOT NULL,
|
||||
`annotation` text,
|
||||
`sort_order` integer NOT NULL DEFAULT 0,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`memo_id`) REFERENCES `research_memo`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`artifact_id`) REFERENCES `research_artifact`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`,`section`,`sort_order`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`,`artifact_id`,`section`);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE VIRTUAL TABLE `research_artifact_fts` USING fts5(
|
||||
artifact_id UNINDEXED,
|
||||
user_id UNINDEXED,
|
||||
ticker UNINDEXED,
|
||||
title,
|
||||
summary,
|
||||
body_markdown,
|
||||
search_text,
|
||||
tags_text
|
||||
);
|
||||
45
drizzle/0008_search_rag.sql
Normal file
45
drizzle/0008_search_rag.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS `search_document` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`source_kind` text NOT NULL,
|
||||
`source_ref` text NOT NULL,
|
||||
`scope` text NOT NULL,
|
||||
`user_id` text,
|
||||
`ticker` text,
|
||||
`accession_number` text,
|
||||
`title` text,
|
||||
`content_text` text NOT NULL,
|
||||
`content_hash` text NOT NULL,
|
||||
`metadata` text,
|
||||
`index_status` text NOT NULL DEFAULT 'pending',
|
||||
`indexed_at` text,
|
||||
`last_error` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `search_chunk` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`document_id` integer NOT NULL,
|
||||
`chunk_index` integer NOT NULL,
|
||||
`chunk_text` text NOT NULL,
|
||||
`char_count` integer NOT NULL,
|
||||
`start_offset` integer NOT NULL,
|
||||
`end_offset` integer NOT NULL,
|
||||
`heading_path` text,
|
||||
`citation_label` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`document_id`) REFERENCES `search_document`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `search_document_source_uidx`
|
||||
ON `search_document` (`scope`, ifnull(`user_id`, ''), `source_kind`, `source_ref`);
|
||||
CREATE INDEX IF NOT EXISTS `search_document_scope_idx`
|
||||
ON `search_document` (`scope`, `source_kind`, `ticker`, `updated_at`);
|
||||
CREATE INDEX IF NOT EXISTS `search_document_accession_idx`
|
||||
ON `search_document` (`accession_number`, `source_kind`);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `search_chunk_document_chunk_uidx`
|
||||
ON `search_chunk` (`document_id`, `chunk_index`);
|
||||
CREATE INDEX IF NOT EXISTS `search_chunk_document_idx`
|
||||
ON `search_chunk` (`document_id`);
|
||||
5
drizzle/0009_task_notification_context.sql
Normal file
5
drizzle/0009_task_notification_context.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `task_run` ADD `stage_context` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `task_stage_event` ADD `stage_context` text;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `task_user_updated_idx` ON `task_run` (`user_id`,`updated_at`);
|
||||
79
drizzle/0010_taxonomy_surface_sidecar.sql
Normal file
79
drizzle/0010_taxonomy_surface_sidecar.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `parser_engine` text DEFAULT 'legacy-ts' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `parser_version` text DEFAULT '0.0.0' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `taxonomy_regime` text DEFAULT 'unknown' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `fiscal_pack` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `faithful_rows` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `surface_rows` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `detail_rows` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `kpi_rows` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_snapshot` ADD `normalization_summary` text;
|
||||
--> statement-breakpoint
|
||||
UPDATE `filing_taxonomy_snapshot`
|
||||
SET
|
||||
`faithful_rows` = COALESCE(`faithful_rows`, `statement_rows`),
|
||||
`surface_rows` = COALESCE(`surface_rows`, '{"income":[],"balance":[],"cash_flow":[],"equity":[],"comprehensive_income":[]}'),
|
||||
`detail_rows` = COALESCE(`detail_rows`, '{"income":{},"balance":{},"cash_flow":{},"equity":{},"comprehensive_income":{}}'),
|
||||
`kpi_rows` = COALESCE(`kpi_rows`, '[]');
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `filing_taxonomy_context` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`snapshot_id` integer NOT NULL,
|
||||
`context_id` text NOT NULL,
|
||||
`entity_identifier` text,
|
||||
`entity_scheme` text,
|
||||
`period_start` text,
|
||||
`period_end` text,
|
||||
`period_instant` text,
|
||||
`segment_json` text,
|
||||
`scenario_json` text,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`snapshot_id`) REFERENCES `filing_taxonomy_snapshot`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `filing_taxonomy_context_snapshot_idx` ON `filing_taxonomy_context` (`snapshot_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `filing_taxonomy_context_uidx` ON `filing_taxonomy_context` (`snapshot_id`,`context_id`);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `balance` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `period_type` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `data_type` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `authoritative_concept_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `mapping_method` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `surface_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `detail_parent_surface_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `kpi_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_concept` ADD `residual_flag` integer DEFAULT false NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `data_type` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `authoritative_concept_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `mapping_method` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `surface_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `detail_parent_surface_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `kpi_key` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `residual_flag` integer DEFAULT false NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `precision` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `filing_taxonomy_fact` ADD `nil` integer DEFAULT false NOT NULL;
|
||||
100
drizzle/0011_remove_legacy_xbrl_defaults.sql
Normal file
100
drizzle/0011_remove_legacy_xbrl_defaults.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `__new_filing_taxonomy_snapshot` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`filing_id` integer NOT NULL,
|
||||
`ticker` text NOT NULL,
|
||||
`filing_date` text NOT NULL,
|
||||
`filing_type` text NOT NULL,
|
||||
`parse_status` text NOT NULL,
|
||||
`parse_error` text,
|
||||
`source` text NOT NULL,
|
||||
`parser_engine` text DEFAULT 'fiscal-xbrl' NOT NULL,
|
||||
`parser_version` text DEFAULT 'unknown' NOT NULL,
|
||||
`taxonomy_regime` text DEFAULT 'unknown' NOT NULL,
|
||||
`fiscal_pack` text,
|
||||
`periods` text,
|
||||
`faithful_rows` text,
|
||||
`statement_rows` text,
|
||||
`surface_rows` text,
|
||||
`detail_rows` text,
|
||||
`kpi_rows` text,
|
||||
`derived_metrics` text,
|
||||
`validation_result` text,
|
||||
`normalization_summary` text,
|
||||
`facts_count` integer DEFAULT 0 NOT NULL,
|
||||
`concepts_count` integer DEFAULT 0 NOT NULL,
|
||||
`dimensions_count` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`filing_id`) REFERENCES `filing`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_filing_taxonomy_snapshot` (
|
||||
`id`,
|
||||
`filing_id`,
|
||||
`ticker`,
|
||||
`filing_date`,
|
||||
`filing_type`,
|
||||
`parse_status`,
|
||||
`parse_error`,
|
||||
`source`,
|
||||
`parser_engine`,
|
||||
`parser_version`,
|
||||
`taxonomy_regime`,
|
||||
`fiscal_pack`,
|
||||
`periods`,
|
||||
`faithful_rows`,
|
||||
`statement_rows`,
|
||||
`surface_rows`,
|
||||
`detail_rows`,
|
||||
`kpi_rows`,
|
||||
`derived_metrics`,
|
||||
`validation_result`,
|
||||
`normalization_summary`,
|
||||
`facts_count`,
|
||||
`concepts_count`,
|
||||
`dimensions_count`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`filing_id`,
|
||||
`ticker`,
|
||||
`filing_date`,
|
||||
`filing_type`,
|
||||
`parse_status`,
|
||||
`parse_error`,
|
||||
`source`,
|
||||
`parser_engine`,
|
||||
`parser_version`,
|
||||
`taxonomy_regime`,
|
||||
`fiscal_pack`,
|
||||
`periods`,
|
||||
`faithful_rows`,
|
||||
`statement_rows`,
|
||||
`surface_rows`,
|
||||
`detail_rows`,
|
||||
`kpi_rows`,
|
||||
`derived_metrics`,
|
||||
`validation_result`,
|
||||
`normalization_summary`,
|
||||
`facts_count`,
|
||||
`concepts_count`,
|
||||
`dimensions_count`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `filing_taxonomy_snapshot`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `filing_taxonomy_snapshot`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__new_filing_taxonomy_snapshot` RENAME TO `filing_taxonomy_snapshot`;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `filing_taxonomy_snapshot_filing_uidx` ON `filing_taxonomy_snapshot` (`filing_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `filing_taxonomy_snapshot_ticker_date_idx` ON `filing_taxonomy_snapshot` (`ticker`,`filing_date`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `filing_taxonomy_snapshot_status_idx` ON `filing_taxonomy_snapshot` (`parse_status`);
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
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`);
|
||||
3
drizzle/0013_task_active_resource_unique.sql
Normal file
3
drizzle/0013_task_active_resource_unique.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `task_active_resource_uidx`
|
||||
ON `task_run` (`user_id`, `task_type`, `resource_key`)
|
||||
WHERE `resource_key` IS NOT NULL AND `status` IN ('queued', 'running');
|
||||
@@ -57,6 +57,34 @@
|
||||
"when": 1772863200000,
|
||||
"tag": "0007_company_financial_bundles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1772906400000,
|
||||
"tag": "0008_research_workspace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1773000000000,
|
||||
"tag": "0009_task_notification_context",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1773090000000,
|
||||
"tag": "0010_taxonomy_surface_sidecar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1773180000000,
|
||||
"tag": "0011_remove_legacy_xbrl_defaults",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
236
e2e/analysis.spec.ts
Normal file
236
e2e/analysis.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function buildMockAnalysisPayload(overrides: {
|
||||
ticker?: string;
|
||||
companyName?: string;
|
||||
quote?: { value: number | null; stale: boolean };
|
||||
priceHistory?: { value: Array<{ date: string; close: number }> | null; stale: boolean };
|
||||
benchmarkHistory?: { value: Array<{ date: string; close: number }> | null; stale: boolean };
|
||||
} = {}) {
|
||||
return {
|
||||
company: {
|
||||
ticker: overrides.ticker ?? 'MSFT',
|
||||
companyName: overrides.companyName ?? 'Microsoft Corporation',
|
||||
sector: 'Technology',
|
||||
category: null,
|
||||
tags: [],
|
||||
cik: '0000789019'
|
||||
},
|
||||
quote: overrides.quote ?? { value: 425.12, stale: false },
|
||||
position: null,
|
||||
priceHistory: overrides.priceHistory ?? {
|
||||
value: [
|
||||
{ date: '2025-01-01T00:00:00.000Z', close: 380 },
|
||||
{ date: '2026-01-01T00:00:00.000Z', close: 425.12 }
|
||||
],
|
||||
stale: false
|
||||
},
|
||||
benchmarkHistory: overrides.benchmarkHistory ?? {
|
||||
value: [
|
||||
{ date: '2025-01-01T00:00:00.000Z', close: 5000 },
|
||||
{ date: '2026-01-01T00:00:00.000Z', close: 5400 }
|
||||
],
|
||||
stale: false
|
||||
},
|
||||
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'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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: buildMockAnalysisPayload()
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('shows price chart with live data when quote and history are available', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
|
||||
await page.route('**/api/analysis/company**', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
analysis: buildMockAnalysisPayload()
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/analysis?ticker=MSFT');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('Spot price $425.12')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="interactive-price-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows unavailable message when price data is null', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
|
||||
await page.route('**/api/analysis/company**', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
analysis: buildMockAnalysisPayload({
|
||||
quote: { value: null, stale: false },
|
||||
priceHistory: { value: null, stale: false },
|
||||
benchmarkHistory: { value: null, stale: false }
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/analysis?ticker=FAIL');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('Spot price unavailable')).toBeVisible();
|
||||
await expect(page.getByText('Price data unavailable')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="interactive-price-chart"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('shows stale indicator when quote data is stale', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
|
||||
await page.route('**/api/analysis/company**', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
analysis: buildMockAnalysisPayload({
|
||||
quote: { value: 425.12, stale: true },
|
||||
priceHistory: {
|
||||
value: [
|
||||
{ date: '2025-01-01T00:00:00.000Z', close: 380 },
|
||||
{ date: '2026-01-01T00:00:00.000Z', close: 425.12 }
|
||||
],
|
||||
stale: true
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/analysis?ticker=STALE');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/Spot price.*\(stale\)/)).toBeVisible();
|
||||
await expect(page.locator('[data-testid="interactive-price-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows chart when price history is available but benchmark is null', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
|
||||
await page.route('**/api/analysis/company**', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
analysis: buildMockAnalysisPayload({
|
||||
benchmarkHistory: { value: null, stale: false }
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/analysis?ticker=MSFT');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="interactive-price-chart"]')).toBeVisible();
|
||||
});
|
||||
231
e2e/auth.spec.ts
231
e2e/auth.spec.ts
@@ -1,17 +1,89 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'Sup3rSecure!123';
|
||||
|
||||
test('redirects protected routes to sign in and preserves the return path', async ({ page }) => {
|
||||
await page.goto('/analysis?ticker=nvda');
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
function createDeferred() {
|
||||
let resolve: (() => void) | null = null;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolve?.()
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
async function gotoAuthPage(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async function signUp(page: Page, email: string, path = '/auth/signup') {
|
||||
await gotoAuthPage(page, path);
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright 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();
|
||||
}
|
||||
|
||||
async function signIn(page: Page, email: string, path = '/auth/signin') {
|
||||
await gotoAuthPage(page, path);
|
||||
await page.locator('input[autocomplete="email"]').fill(email);
|
||||
await page.locator('input[autocomplete="current-password"]').fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Sign in with password' }).click();
|
||||
}
|
||||
|
||||
async function expectStableDashboard(page: Page) {
|
||||
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(page.url()).not.toContain('/auth/signin');
|
||||
}
|
||||
|
||||
async function expectStableProtectedRoute(page: Page, pattern: RegExp) {
|
||||
await expect(page).toHaveURL(pattern, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(page.url()).not.toContain('/auth/signin');
|
||||
}
|
||||
|
||||
async function signOut(page: Page) {
|
||||
await page.getByRole('button', { name: 'Sign out' }).first().click();
|
||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
test('preserves the return path while switching between auth screens and shows the expected controls', async ({ page }) => {
|
||||
await gotoAuthPage(page, '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
|
||||
await expect(page).toHaveURL(/\/auth\/signin\?/);
|
||||
await expect(page.getByRole('heading', { name: 'Secure Sign In' })).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get('next')).toBe('/analysis?ticker=nvda');
|
||||
await expect(page.getByText('Use email/password or request a magic link.')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="current-password"]')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign in with password' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Send magic link' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Create one' })).toHaveAttribute('href', '/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
|
||||
await page.getByRole('link', { name: 'Create one' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/auth\/signup\?next=%2Fanalysis%3Fticker%3DNVDA$/);
|
||||
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
|
||||
await expect(page.getByText('Set up your operator profile to access portfolio and filings intelligence.')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="name"]')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="new-password"]').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
});
|
||||
|
||||
test('shows client-side validation when signup passwords do not match', async ({ page }) => {
|
||||
await page.goto('/auth/signup');
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
||||
await page.locator('input[autocomplete="email"]').fill('mismatch@example.com');
|
||||
@@ -22,17 +94,150 @@ test('shows client-side validation when signup passwords do not match', async ({
|
||||
await expect(page.getByText('Passwords do not match.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a new account and lands on the command center', async ({ page }) => {
|
||||
const email = `playwright-${Date.now()}@example.com`;
|
||||
test('shows loading affordances while sign-in is in flight', async ({ page }) => {
|
||||
const gate = createDeferred();
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.route('**/api/auth/sign-in/email', async (route) => {
|
||||
await gate.promise;
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Invalid credentials' })
|
||||
});
|
||||
});
|
||||
|
||||
await gotoAuthPage(page, '/auth/signin');
|
||||
await page.locator('input[autocomplete="email"]').fill('playwright@example.com');
|
||||
await page.locator('input[autocomplete="current-password"]').fill(PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: 'Sign in with password' });
|
||||
const magicLinkButton = page.getByRole('button', { name: 'Send magic link' });
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
|
||||
await expect(magicLinkButton).toBeDisabled();
|
||||
|
||||
gate.resolve();
|
||||
|
||||
await expect(page.getByText('Invalid credentials')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows loading affordances while sign-up is in flight', async ({ page }) => {
|
||||
const gate = createDeferred();
|
||||
|
||||
await page.route('**/api/auth/sign-up/email', async (route) => {
|
||||
await gate.promise;
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Email already exists' })
|
||||
});
|
||||
});
|
||||
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
||||
await page.locator('input[autocomplete="email"]').fill(email);
|
||||
await page.locator('input[autocomplete="email"]').fill(uniqueEmail('playwright-loading'));
|
||||
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).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible();
|
||||
await expect(page.getByText('Quick Links')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Creating account...' })).toBeDisabled();
|
||||
|
||||
gate.resolve();
|
||||
|
||||
await expect(page.getByText('Email already exists')).toBeVisible();
|
||||
});
|
||||
|
||||
test('successful signup reaches the authenticated shell and stays there', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-signup-success'));
|
||||
await expectStableDashboard(page);
|
||||
});
|
||||
|
||||
test('successful signup preserves the requested next path', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-signup-next'), '/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
await expectStableProtectedRoute(page, /\/analysis\?ticker=NVDA$/);
|
||||
});
|
||||
|
||||
test('successful sign-in reaches the authenticated shell and stays there', async ({ page }) => {
|
||||
const email = uniqueEmail('playwright-signin-success');
|
||||
|
||||
await signUp(page, email);
|
||||
await expectStableDashboard(page);
|
||||
await signOut(page);
|
||||
|
||||
await signIn(page, email);
|
||||
await expectStableDashboard(page);
|
||||
});
|
||||
|
||||
test('authenticated users are redirected away from auth pages with hard navigation', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-authenticated-redirect'));
|
||||
await expectStableDashboard(page);
|
||||
|
||||
await page.goto('/auth/signin', { waitUntil: 'domcontentloaded' });
|
||||
await expectStableDashboard(page);
|
||||
|
||||
await page.goto('/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA', { waitUntil: 'domcontentloaded' });
|
||||
await expectStableProtectedRoute(page, /\/analysis\?ticker=NVDA$/);
|
||||
});
|
||||
|
||||
test('shows the handoff state while waiting for the session to become visible', async ({ page }) => {
|
||||
const gate = createDeferred();
|
||||
let holdSession = false;
|
||||
|
||||
await page.route('**/api/auth/get-session**', async (route) => {
|
||||
if (holdSession) {
|
||||
await gate.promise;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
holdSession = true;
|
||||
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
||||
await page.locator('input[autocomplete="email"]').fill(uniqueEmail('playwright-handoff'));
|
||||
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('button', { name: 'Finishing sign-in...' })).toBeDisabled();
|
||||
await expect(page.getByText('Establishing your session and opening the workspace...')).toBeVisible();
|
||||
|
||||
gate.resolve();
|
||||
|
||||
await expectStableDashboard(page);
|
||||
});
|
||||
|
||||
test('shows recovery guidance if session establishment never completes', async ({ page }) => {
|
||||
let forceMissingSession = false;
|
||||
|
||||
await page.route('**/api/auth/get-session**', async (route) => {
|
||||
if (!forceMissingSession) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: 'null'
|
||||
});
|
||||
});
|
||||
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
forceMissingSession = true;
|
||||
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
||||
await page.locator('input[autocomplete="email"]').fill(uniqueEmail('playwright-timeout'));
|
||||
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('button', { name: 'Finishing sign-in...' })).toBeDisabled();
|
||||
await expect(page.getByText('Establishing your session and opening the workspace...')).toBeVisible();
|
||||
await expect(page.getByText('Authentication completed, but the session was not established on this device. Please sign in again.')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/\/auth\/signup$/);
|
||||
await expect(page.getByRole('button', { name: 'Create account' })).toBeEnabled();
|
||||
});
|
||||
|
||||
202
e2e/filings.spec.ts
Normal file
202
e2e/filings.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'Sup3rSecure!123';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
type FilingFixture = {
|
||||
id: number;
|
||||
ticker: string;
|
||||
filing_type: '10-K' | '10-Q' | '8-K';
|
||||
filing_date: string;
|
||||
accession_number: string;
|
||||
cik: string;
|
||||
company_name: string;
|
||||
filing_url: string | null;
|
||||
submission_url: string | null;
|
||||
primary_document: string | null;
|
||||
metrics: {
|
||||
revenue: number | null;
|
||||
netIncome: number | null;
|
||||
totalAssets: number | null;
|
||||
cash: number | null;
|
||||
debt: number | null;
|
||||
} | null;
|
||||
analysis: null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function createFiling(input: {
|
||||
id: number;
|
||||
ticker: string;
|
||||
accessionNumber: string;
|
||||
filingType: '10-K' | '10-Q' | '8-K';
|
||||
filingDate: string;
|
||||
companyName: string;
|
||||
revenue?: number | null;
|
||||
}): FilingFixture {
|
||||
return {
|
||||
id: input.id,
|
||||
ticker: input.ticker,
|
||||
filing_type: input.filingType,
|
||||
filing_date: input.filingDate,
|
||||
accession_number: input.accessionNumber,
|
||||
cik: '0001045810',
|
||||
company_name: input.companyName,
|
||||
filing_url: `https://www.sec.gov/Archives/${input.accessionNumber}.htm`,
|
||||
submission_url: `https://www.sec.gov/submissions/${input.accessionNumber}.json`,
|
||||
primary_document: `${input.accessionNumber}.htm`,
|
||||
metrics: input.revenue === undefined
|
||||
? null
|
||||
: {
|
||||
revenue: input.revenue,
|
||||
netIncome: null,
|
||||
totalAssets: null,
|
||||
cash: null,
|
||||
debt: null
|
||||
},
|
||||
analysis: null,
|
||||
created_at: '2026-03-14T12:00:00.000Z',
|
||||
updated_at: '2026-03-14T12:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
async function signUp(page: Page, email: string) {
|
||||
await page.goto('/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Filings 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 });
|
||||
}
|
||||
|
||||
async function installFilingsRouteStub(
|
||||
page: Page,
|
||||
options?: {
|
||||
unscopedDelayMs?: number;
|
||||
scopedDelayMs?: number;
|
||||
}
|
||||
) {
|
||||
const nvdaFilings = [
|
||||
createFiling({
|
||||
id: 1,
|
||||
ticker: 'NVDA',
|
||||
accessionNumber: '0001045810-26-000001',
|
||||
filingType: '10-K',
|
||||
filingDate: '2026-03-13',
|
||||
companyName: 'NVIDIA Corporation',
|
||||
revenue: 130_500_000_000
|
||||
})
|
||||
];
|
||||
const msftFilings = [
|
||||
createFiling({
|
||||
id: 2,
|
||||
ticker: 'MSFT',
|
||||
accessionNumber: '0000789019-26-000002',
|
||||
filingType: '10-Q',
|
||||
filingDate: '2026-03-12',
|
||||
companyName: 'Microsoft Corporation',
|
||||
revenue: 71_000_000_000
|
||||
})
|
||||
];
|
||||
const mixedFilings = [...nvdaFilings, ...msftFilings];
|
||||
|
||||
await page.route(/\/api\/filings(\?.*)?$/, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const ticker = url.searchParams.get('ticker')?.trim().toUpperCase() ?? null;
|
||||
const delay = ticker === 'NVDA'
|
||||
? options?.scopedDelayMs ?? 0
|
||||
: options?.unscopedDelayMs ?? 0;
|
||||
|
||||
if (delay > 0) {
|
||||
await page.waitForTimeout(delay);
|
||||
}
|
||||
|
||||
const filings = ticker === 'NVDA' ? nvdaFilings : mixedFilings;
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ filings })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function filingsLedger(page: Page) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Filing Ledger' })
|
||||
}).first();
|
||||
}
|
||||
|
||||
test('direct URL entry keeps the filings ledger scoped to the URL ticker', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-filings-direct'));
|
||||
await installFilingsRouteStub(page);
|
||||
|
||||
await page.goto('/filings?ticker=NVDA', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const ledger = await filingsLedger(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/filings\?ticker=NVDA$/);
|
||||
await expect(ledger.getByText('1 records loaded for NVDA. Values shown in Millions (M).')).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('apply and clear keep the URL and visible filings rows aligned', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-filings-apply-clear'));
|
||||
await installFilingsRouteStub(page);
|
||||
|
||||
await page.goto('/filings', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const ledger = await filingsLedger(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/filings$/);
|
||||
await expect(ledger.getByText('2 records loaded. Values shown in Millions (M).')).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Ticker filter').fill('nvda');
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/filings\?ticker=NVDA$/);
|
||||
await expect(ledger.getByText('1 records loaded for NVDA. Values shown in Millions (M).')).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Clear' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/filings$/);
|
||||
await expect(ledger.getByText('2 records loaded. Values shown in Millions (M).')).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a stale global filings response cannot overwrite a newer scoped ledger', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-filings-stale'));
|
||||
await installFilingsRouteStub(page, {
|
||||
unscopedDelayMs: 900,
|
||||
scopedDelayMs: 50
|
||||
});
|
||||
|
||||
await page.goto('/filings', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const ledger = await filingsLedger(page);
|
||||
|
||||
await page.getByPlaceholder('Ticker filter').fill('NVDA');
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/filings\?ticker=NVDA$/);
|
||||
await expect(ledger.getByText('1 records loaded for NVDA. Values shown in Millions (M).')).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1_100);
|
||||
|
||||
await expect(ledger.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(ledger.getByRole('cell', { name: 'Microsoft Corporation' })).toHaveCount(0);
|
||||
});
|
||||
379
e2e/financials.spec.ts
Normal file
379
e2e/financials.spec.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
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-financials-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Financials 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 });
|
||||
}
|
||||
|
||||
function buildFinancialsPayload(ticker: 'MSFT' | 'JPM') {
|
||||
const isBank = ticker === 'JPM';
|
||||
const prefix = ticker.toLowerCase();
|
||||
|
||||
return {
|
||||
financials: {
|
||||
company: {
|
||||
ticker,
|
||||
companyName: isBank ? 'JPMorgan Chase & Co.' : 'Microsoft Corporation',
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
displayModes: ['standardized', 'faithful'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: [
|
||||
{
|
||||
id: `${prefix}-fy24`,
|
||||
filingId: 1,
|
||||
accessionNumber: `0000-${prefix}-1`,
|
||||
filingDate: '2025-02-01',
|
||||
periodStart: '2024-01-01',
|
||||
periodEnd: '2024-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2024'
|
||||
},
|
||||
{
|
||||
id: `${prefix}-fy25`,
|
||||
filingId: 2,
|
||||
accessionNumber: `0000-${prefix}-2`,
|
||||
filingDate: '2026-02-01',
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: '2025-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2025'
|
||||
}
|
||||
],
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: [
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue',
|
||||
category: 'revenue',
|
||||
order: 10,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: 245_000, [`${prefix}-fy25`]: 262_000 },
|
||||
sourceConcepts: ['revenue'],
|
||||
sourceRowKeys: ['revenue'],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'revenue', [`${prefix}-fy25`]: 'revenue' },
|
||||
statement: 'income',
|
||||
resolutionMethod: 'direct',
|
||||
confidence: 'high',
|
||||
warningCodes: []
|
||||
},
|
||||
{
|
||||
key: 'gross_profit',
|
||||
label: 'Gross Profit',
|
||||
category: 'profit',
|
||||
order: 20,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: isBank ? null : 171_000, [`${prefix}-fy25`]: isBank ? null : 185_000 },
|
||||
sourceConcepts: ['gross_profit'],
|
||||
sourceRowKeys: ['gross_profit'],
|
||||
sourceFactIds: [2],
|
||||
formulaKey: isBank ? null : 'revenue_less_cost_of_revenue',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'gross_profit', [`${prefix}-fy25`]: 'gross_profit' },
|
||||
statement: 'income',
|
||||
resolutionMethod: isBank ? 'not_meaningful' : 'formula_derived',
|
||||
confidence: isBank ? 'high' : 'medium',
|
||||
warningCodes: isBank ? ['gross_profit_not_meaningful_bank_pack'] : ['formula_resolved']
|
||||
},
|
||||
{
|
||||
key: 'operating_expenses',
|
||||
label: 'Operating Expenses',
|
||||
category: 'opex',
|
||||
order: 30,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: isBank ? 121_000 : 76_000, [`${prefix}-fy25`]: isBank ? 126_000 : 82_000 },
|
||||
sourceConcepts: ['operating_expenses'],
|
||||
sourceRowKeys: ['operating_expenses'],
|
||||
sourceFactIds: [3],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'operating_expenses', [`${prefix}-fy25`]: 'operating_expenses' },
|
||||
statement: 'income',
|
||||
detailCount: 3,
|
||||
resolutionMethod: 'direct',
|
||||
confidence: 'high',
|
||||
warningCodes: []
|
||||
},
|
||||
{
|
||||
key: 'selling_general_and_administrative',
|
||||
label: 'SG&A',
|
||||
category: 'opex',
|
||||
order: 40,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: isBank ? null : 44_000, [`${prefix}-fy25`]: isBank ? null : 47_500 },
|
||||
sourceConcepts: ['selling_general_and_administrative'],
|
||||
sourceRowKeys: ['selling_general_and_administrative'],
|
||||
sourceFactIds: [4],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'selling_general_and_administrative', [`${prefix}-fy25`]: 'selling_general_and_administrative' },
|
||||
statement: 'income',
|
||||
resolutionMethod: isBank ? 'not_meaningful' : 'direct',
|
||||
confidence: 'high',
|
||||
warningCodes: isBank ? ['expense_breakdown_not_meaningful_bank_pack'] : []
|
||||
},
|
||||
{
|
||||
key: 'research_and_development',
|
||||
label: 'Research Expense',
|
||||
category: 'opex',
|
||||
order: 50,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: isBank ? null : 26_000, [`${prefix}-fy25`]: isBank ? null : 28_000 },
|
||||
sourceConcepts: ['research_and_development'],
|
||||
sourceRowKeys: ['research_and_development'],
|
||||
sourceFactIds: [5],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'research_and_development', [`${prefix}-fy25`]: 'research_and_development' },
|
||||
statement: 'income',
|
||||
resolutionMethod: isBank ? 'not_meaningful' : 'direct',
|
||||
confidence: 'high',
|
||||
warningCodes: isBank ? ['expense_breakdown_not_meaningful_bank_pack'] : []
|
||||
},
|
||||
{
|
||||
key: 'other_operating_expense',
|
||||
label: 'Other Expense',
|
||||
category: 'opex',
|
||||
order: 60,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: isBank ? null : 6_000, [`${prefix}-fy25`]: isBank ? null : 6_500 },
|
||||
sourceConcepts: ['other_operating_expense'],
|
||||
sourceRowKeys: ['other_operating_expense'],
|
||||
sourceFactIds: [6],
|
||||
formulaKey: isBank ? null : 'operating_expenses_residual',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'other_operating_expense', [`${prefix}-fy25`]: 'other_operating_expense' },
|
||||
statement: 'income',
|
||||
resolutionMethod: isBank ? 'not_meaningful' : 'formula_derived',
|
||||
confidence: isBank ? 'high' : 'medium',
|
||||
warningCodes: isBank ? ['expense_breakdown_not_meaningful_bank_pack'] : ['formula_resolved']
|
||||
},
|
||||
{
|
||||
key: 'operating_income',
|
||||
label: 'Operating Income',
|
||||
category: 'profit',
|
||||
order: 70,
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: isBank ? 124_000 : 95_000, [`${prefix}-fy25`]: isBank ? 131_000 : 103_000 },
|
||||
sourceConcepts: ['operating_income'],
|
||||
sourceRowKeys: ['operating_income'],
|
||||
sourceFactIds: [7],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'operating_income', [`${prefix}-fy25`]: 'operating_income' },
|
||||
statement: 'income',
|
||||
resolutionMethod: 'direct',
|
||||
confidence: 'high',
|
||||
warningCodes: []
|
||||
}
|
||||
]
|
||||
},
|
||||
statementDetails: isBank ? null : {
|
||||
selling_general_and_administrative: [
|
||||
{
|
||||
key: 'corporate_sga',
|
||||
parentSurfaceKey: 'selling_general_and_administrative',
|
||||
label: 'Corporate SG&A',
|
||||
conceptKey: 'corporate_sga',
|
||||
qname: 'us-gaap:CorporateSga',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'CorporateSga',
|
||||
unit: 'USD',
|
||||
values: { [`${prefix}-fy24`]: 44_000, [`${prefix}-fy25`]: 47_500 },
|
||||
sourceFactIds: [104],
|
||||
isExtension: false,
|
||||
dimensionsSummary: [],
|
||||
residualFlag: false
|
||||
}
|
||||
],
|
||||
research_and_development: [
|
||||
{
|
||||
key: 'product_rnd',
|
||||
parentSurfaceKey: 'research_and_development',
|
||||
label: 'Product R&D',
|
||||
conceptKey: 'product_rnd',
|
||||
qname: 'us-gaap:ProductResearchAndDevelopment',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'ProductResearchAndDevelopment',
|
||||
unit: 'USD',
|
||||
values: { [`${prefix}-fy24`]: 26_000, [`${prefix}-fy25`]: 28_000 },
|
||||
sourceFactIds: [105],
|
||||
isExtension: false,
|
||||
dimensionsSummary: [],
|
||||
residualFlag: false
|
||||
}
|
||||
],
|
||||
other_operating_expense: [
|
||||
{
|
||||
key: 'other_opex_residual',
|
||||
parentSurfaceKey: 'other_operating_expense',
|
||||
label: 'Other Operating Expense Residual',
|
||||
conceptKey: 'other_opex_residual',
|
||||
qname: 'us-gaap:OtherOperatingExpense',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'OtherOperatingExpense',
|
||||
unit: 'USD',
|
||||
values: { [`${prefix}-fy24`]: 6_000, [`${prefix}-fy25`]: 6_500 },
|
||||
sourceFactIds: [106],
|
||||
isExtension: false,
|
||||
dimensionsSummary: [],
|
||||
residualFlag: false
|
||||
}
|
||||
],
|
||||
unmapped: [
|
||||
{
|
||||
key: 'other_income_unmapped',
|
||||
parentSurfaceKey: 'unmapped',
|
||||
label: 'Other Income Residual',
|
||||
conceptKey: 'other_income_unmapped',
|
||||
qname: 'us-gaap:OtherNonoperatingIncomeExpense',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'OtherNonoperatingIncomeExpense',
|
||||
unit: 'USD',
|
||||
values: { [`${prefix}-fy24`]: 1_200, [`${prefix}-fy25`]: 1_450 },
|
||||
sourceFactIds: [107],
|
||||
isExtension: false,
|
||||
dimensionsSummary: [],
|
||||
residualFlag: true
|
||||
}
|
||||
]
|
||||
},
|
||||
ratioRows: [],
|
||||
kpiRows: null,
|
||||
trendSeries: [
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue',
|
||||
category: 'revenue',
|
||||
unit: 'currency',
|
||||
values: { [`${prefix}-fy24`]: 245_000, [`${prefix}-fy25`]: 262_000 }
|
||||
}
|
||||
],
|
||||
categories: [
|
||||
{ key: 'revenue', label: 'Revenue', count: 1 },
|
||||
{ key: 'profit', label: 'Profit', count: 3 },
|
||||
{ key: 'opex', label: 'Operating Expenses', count: 4 }
|
||||
],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: 2,
|
||||
rows: 8,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: 2,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
normalization: {
|
||||
parserEngine: 'fiscal-xbrl',
|
||||
regime: 'us-gaap',
|
||||
fiscalPack: isBank ? 'bank_lender' : 'core',
|
||||
parserVersion: '0.1.0',
|
||||
surfaceRowCount: 8,
|
||||
detailRowCount: isBank ? 0 : 4,
|
||||
kpiRowCount: 0,
|
||||
unmappedRowCount: isBank ? 0 : 1,
|
||||
materialUnmappedRowCount: 0,
|
||||
warnings: isBank ? [] : ['income_sparse_mapping', 'unmapped_cash_flow_bridge']
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function mockFinancials(page: Page) {
|
||||
await page.route('**/api/financials/company**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const ticker = (url.searchParams.get('ticker') ?? 'MSFT').toUpperCase() as 'MSFT' | 'JPM';
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(buildFinancialsPayload(ticker))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('renders the standardized operating expense tree and inspector details', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
await mockFinancials(page);
|
||||
|
||||
await page.goto('/financials?ticker=MSFT');
|
||||
|
||||
await expect(page.getByText('Normalization Summary')).toBeVisible();
|
||||
await expect(page.getByText('fiscal-xbrl 0.1.0')).toBeVisible();
|
||||
await expect(page.getByText('Parser Warnings')).toBeVisible();
|
||||
await expect(page.getByText('income_sparse_mapping')).toBeVisible();
|
||||
await expect(page.getByText('unmapped_cash_flow_bridge')).toBeVisible();
|
||||
await expect(page.getByText('Parser residual rows are available under the Unmapped / Residual section.')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Expand Operating Expenses details' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Expand Operating Expenses details' }).click();
|
||||
await expect(page.getByRole('button', { name: /^SG&A/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Research Expense/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Other Expense/ })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^SG&A/ }).click();
|
||||
await expect(page.getByText('Row Details')).toBeVisible();
|
||||
await expect(page.getByText('selling_general_and_administrative', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('Corporate SG&A')).toBeVisible();
|
||||
await expect(page.getByText('Unmapped / Residual')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /^Other Income Residual/ }).click();
|
||||
await expect(page.getByText('other_income_unmapped', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Unmapped / Residual', { exact: true }).last()).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows not meaningful expense breakdown rows for bank pack filings', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
await mockFinancials(page);
|
||||
|
||||
await page.goto('/financials?ticker=JPM');
|
||||
|
||||
await page.getByRole('button', { name: 'Expand Operating Expenses details' }).click();
|
||||
const sgaButton = page.getByRole('button', { name: /^SG&A/ });
|
||||
await expect(sgaButton).toBeVisible();
|
||||
await expect(sgaButton).toContainText('N/M');
|
||||
|
||||
await sgaButton.click();
|
||||
await expect(page.getByText('not_meaningful', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('expense_breakdown_not_meaningful_bank_pack')).toBeVisible();
|
||||
});
|
||||
4
e2e/fixtures/sample-research.txt
Normal file
4
e2e/fixtures/sample-research.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Supply-chain diligence notes:
|
||||
- Channel checks remain constructive.
|
||||
- Gross margin watchpoint is still mix-driven.
|
||||
- Need follow-up on inventory normalization pace.
|
||||
337
e2e/graphing.spec.ts
Normal file
337
e2e/graphing.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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-graphing-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Graphing 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 });
|
||||
}
|
||||
|
||||
function createFinancialsPayload(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
cadence: 'annual' | 'quarterly' | 'ltm';
|
||||
surface: string;
|
||||
}) {
|
||||
const fiscalPack = input.ticker === 'JPM'
|
||||
? 'bank_lender'
|
||||
: input.ticker === 'BLK'
|
||||
? 'broker_asset_manager'
|
||||
: 'core';
|
||||
|
||||
return {
|
||||
financials: {
|
||||
company: {
|
||||
ticker: input.ticker,
|
||||
companyName: input.companyName,
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: input.surface,
|
||||
cadence: input.cadence,
|
||||
displayModes: ['standardized'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: [
|
||||
{
|
||||
id: `${input.ticker}-p1`,
|
||||
filingId: 1,
|
||||
accessionNumber: `0000-${input.ticker}-1`,
|
||||
filingDate: '2025-02-01',
|
||||
periodStart: '2024-01-01',
|
||||
periodEnd: '2024-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2024'
|
||||
},
|
||||
{
|
||||
id: `${input.ticker}-p2`,
|
||||
filingId: 2,
|
||||
accessionNumber: `0000-${input.ticker}-2`,
|
||||
filingDate: '2026-02-01',
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: '2025-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2025'
|
||||
}
|
||||
],
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: [
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue',
|
||||
category: 'revenue',
|
||||
order: 10,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 320 : 280,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 360 : 330
|
||||
},
|
||||
sourceConcepts: ['revenue'],
|
||||
sourceRowKeys: ['revenue'],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'revenue',
|
||||
[`${input.ticker}-p2`]: 'revenue'
|
||||
},
|
||||
resolutionMethod: 'direct'
|
||||
},
|
||||
{
|
||||
key: 'gross_profit',
|
||||
label: 'Gross Profit',
|
||||
category: 'profit',
|
||||
order: 15,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'JPM' ? null : input.ticker === 'AAPL' ? 138 : 112,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'JPM' ? null : input.ticker === 'AAPL' ? 156 : 128
|
||||
},
|
||||
sourceConcepts: ['gross_profit'],
|
||||
sourceRowKeys: ['gross_profit'],
|
||||
sourceFactIds: [11],
|
||||
formulaKey: input.ticker === 'JPM' ? null : 'revenue_less_cost_of_revenue',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'gross_profit',
|
||||
[`${input.ticker}-p2`]: 'gross_profit'
|
||||
},
|
||||
resolutionMethod: input.ticker === 'JPM' ? 'not_meaningful' : 'formula_derived'
|
||||
},
|
||||
{
|
||||
key: 'other_operating_expense',
|
||||
label: 'Other Expense',
|
||||
category: 'opex',
|
||||
order: 16,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'BLK' ? null : input.ticker === 'AAPL' ? 12 : 8,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'BLK' ? null : input.ticker === 'AAPL' ? 14 : 10
|
||||
},
|
||||
sourceConcepts: ['other_operating_expense'],
|
||||
sourceRowKeys: ['other_operating_expense'],
|
||||
sourceFactIds: [12],
|
||||
formulaKey: input.ticker === 'BLK' ? null : 'operating_expenses_residual',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'other_operating_expense',
|
||||
[`${input.ticker}-p2`]: 'other_operating_expense'
|
||||
},
|
||||
resolutionMethod: input.ticker === 'BLK' ? 'not_meaningful' : 'formula_derived'
|
||||
},
|
||||
{
|
||||
key: 'total_assets',
|
||||
label: 'Total Assets',
|
||||
category: 'asset',
|
||||
order: 20,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 410 : 380,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 450 : 420
|
||||
},
|
||||
sourceConcepts: ['total_assets'],
|
||||
sourceRowKeys: ['total_assets'],
|
||||
sourceFactIds: [2],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'total_assets',
|
||||
[`${input.ticker}-p2`]: 'total_assets'
|
||||
},
|
||||
resolutionMethod: 'direct'
|
||||
},
|
||||
{
|
||||
key: 'free_cash_flow',
|
||||
label: 'Free Cash Flow',
|
||||
category: 'cash_flow',
|
||||
order: 30,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 95 : 80,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 105 : 92
|
||||
},
|
||||
sourceConcepts: ['free_cash_flow'],
|
||||
sourceRowKeys: ['free_cash_flow'],
|
||||
sourceFactIds: [3],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'free_cash_flow',
|
||||
[`${input.ticker}-p2`]: 'free_cash_flow'
|
||||
},
|
||||
resolutionMethod: 'direct'
|
||||
}
|
||||
]
|
||||
},
|
||||
statementDetails: null,
|
||||
ratioRows: [
|
||||
{
|
||||
key: 'gross_margin',
|
||||
label: 'Gross Margin',
|
||||
category: 'margins',
|
||||
order: 10,
|
||||
unit: 'percent',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 0.43 : 0.39,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 0.45 : 0.41
|
||||
},
|
||||
sourceConcepts: ['gross_margin'],
|
||||
sourceRowKeys: ['gross_margin'],
|
||||
sourceFactIds: [4],
|
||||
formulaKey: 'gross_margin',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: null,
|
||||
[`${input.ticker}-p2`]: null
|
||||
},
|
||||
denominatorKey: 'revenue'
|
||||
}
|
||||
],
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
categories: [],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: 2,
|
||||
rows: 3,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: 2,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
normalization: {
|
||||
parserEngine: 'fiscal-xbrl',
|
||||
regime: 'unknown',
|
||||
fiscalPack,
|
||||
parserVersion: '0.0.0',
|
||||
surfaceRowCount: 0,
|
||||
detailRowCount: 0,
|
||||
kpiRowCount: 0,
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
warnings: []
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function mockGraphingFinancials(page: Page) {
|
||||
await page.route('**/api/financials/company**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const ticker = url.searchParams.get('ticker') ?? 'MSFT';
|
||||
const cadence = (url.searchParams.get('cadence') ?? 'annual') as 'annual' | 'quarterly' | 'ltm';
|
||||
const surface = url.searchParams.get('surface') ?? 'income_statement';
|
||||
|
||||
if (ticker === 'BAD') {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Ticker not found' })
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const companyName = ticker === 'AAPL'
|
||||
? 'Apple Inc.'
|
||||
: ticker === 'NVDA'
|
||||
? 'NVIDIA Corporation'
|
||||
: ticker === 'AMD'
|
||||
? 'Advanced Micro Devices, Inc.'
|
||||
: ticker === 'BLK'
|
||||
? 'BlackRock, Inc.'
|
||||
: ticker === 'JPM'
|
||||
? 'JPMorgan Chase & Co.'
|
||||
: 'Microsoft Corporation';
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createFinancialsPayload({
|
||||
ticker,
|
||||
companyName,
|
||||
cadence,
|
||||
surface
|
||||
}))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('supports graphing compare controls and partial failures', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
await mockGraphingFinancials(page);
|
||||
|
||||
await page.goto('/graphing');
|
||||
|
||||
await expect(page).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
|
||||
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
|
||||
await expect(page).toHaveURL(/surface=balance_sheet/);
|
||||
await expect(page).toHaveURL(/metric=total_assets/);
|
||||
|
||||
await page.getByRole('button', { name: 'Graph cadence Quarterly' }).click();
|
||||
await expect(page).toHaveURL(/cadence=quarterly/);
|
||||
|
||||
await page.getByRole('button', { name: 'Chart type Bar' }).click();
|
||||
await expect(page).toHaveURL(/chart=bar/);
|
||||
|
||||
await page.getByRole('button', { name: 'Remove AAPL' }).click();
|
||||
await expect(page).not.toHaveURL(/AAPL/);
|
||||
|
||||
await page.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
|
||||
await page.getByRole('button', { name: 'Update Compare Set' }).click();
|
||||
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
|
||||
await expect(page.getByText('Advanced Micro Devices, Inc.').first()).toBeVisible();
|
||||
|
||||
await page.goto('/graphing?tickers=MSFT,BAD&surface=income_statement&metric=revenue&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByText('Partial coverage detected.')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('distinguishes not meaningful metrics from missing data in the latest values table', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
await mockGraphingFinancials(page);
|
||||
|
||||
await page.goto('/graphing?tickers=MSFT,BLK&surface=income_statement&metric=other_operating_expense&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByRole('combobox', { name: 'Metric selector' })).toHaveValue('other_operating_expense');
|
||||
await expect(page.getByRole('cell', { name: 'broker_asset_manager' })).toBeVisible();
|
||||
await expect(page.getByText('Not meaningful for this pack')).toBeVisible();
|
||||
|
||||
await page.goto('/graphing?tickers=JPM,MSFT&surface=income_statement&metric=gross_profit&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByText('not meaningful for the selected pack', { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'bank_lender' })).toBeVisible();
|
||||
await expect(page.getByText('Ready')).toBeVisible();
|
||||
});
|
||||
@@ -17,14 +17,59 @@ function toSlug(value: string) {
|
||||
|
||||
async function signUp(page: Page, testInfo: TestInfo) {
|
||||
const email = `playwright-research-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3400';
|
||||
const output = execFileSync('bun', [
|
||||
'-e',
|
||||
`
|
||||
const [email, password] = process.argv.slice(1);
|
||||
const { auth } = await import('./lib/auth');
|
||||
const response = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: 'Playwright Research User',
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/'
|
||||
},
|
||||
asResponse: true
|
||||
});
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Research 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();
|
||||
console.log(JSON.stringify({
|
||||
status: response.status,
|
||||
sessionCookie: response.headers.get('set-cookie')
|
||||
}));
|
||||
`,
|
||||
email,
|
||||
PASSWORD
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: `file:${E2E_DATABASE_PATH}`,
|
||||
BETTER_AUTH_BASE_URL: baseURL,
|
||||
BETTER_AUTH_SECRET: 'playwright-e2e-secret-playwright-e2e-secret',
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: baseURL
|
||||
},
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
const { status, sessionCookie } = JSON.parse(output) as { status: number; sessionCookie: string | null };
|
||||
expect(status).toBe(200);
|
||||
expect(sessionCookie).toBeTruthy();
|
||||
|
||||
const [cookieNameValue] = sessionCookie!.split(';');
|
||||
const separatorIndex = cookieNameValue!.indexOf('=');
|
||||
const cookieName = cookieNameValue!.slice(0, separatorIndex);
|
||||
const cookieValue = cookieNameValue!.slice(separatorIndex + 1);
|
||||
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: cookieValue,
|
||||
url: baseURL,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax'
|
||||
}]);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
return email;
|
||||
}
|
||||
@@ -109,7 +154,9 @@ finally:
|
||||
}
|
||||
|
||||
test('supports the core coverage-to-research workflow', async ({ page }, testInfo) => {
|
||||
test.slow();
|
||||
const accessionNumber = `0001045810-26-${String(Date.now()).slice(-6)}`;
|
||||
const uploadFixture = join(process.cwd(), 'e2e', 'fixtures', 'sample-research.txt');
|
||||
await signUp(page, testInfo);
|
||||
|
||||
seedFiling({
|
||||
@@ -135,34 +182,70 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
|
||||
await page.getByLabel('NVDA priority').selectOption('high');
|
||||
await expect(page.getByLabel('NVDA priority')).toHaveValue('high');
|
||||
|
||||
await page.getByRole('link', { name: /^Analyze/ }).first().click();
|
||||
await page.getByRole('link', { name: /^Open overview/ }).first().click();
|
||||
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
|
||||
await expect(page.getByText('Coverage Workflow')).toBeVisible();
|
||||
await expect(page.getByText('Bull vs Bear')).toBeVisible();
|
||||
await expect(page.getByText('Past 7 Days')).toBeVisible();
|
||||
await expect(page.getByText('Recent Developments')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Journal title').fill('Own-the-stack moat check');
|
||||
await page.getByLabel('Journal body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
|
||||
await page.getByRole('link', { name: 'Research' }).first().click();
|
||||
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
|
||||
await page.getByLabel('Research note title').fill('Own-the-stack moat check');
|
||||
await page.getByLabel('Research note summary').fill('Initial moat checkpoint');
|
||||
await page.getByLabel('Research note body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
|
||||
await page.getByLabel('Research note tags').fill('moat, thesis');
|
||||
await page.getByRole('button', { name: 'Save note' }).click();
|
||||
await expect(page.getByText('Own-the-stack moat check')).toBeVisible();
|
||||
await expect(page.getByText('Saved note to the research library.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Open summary' }).first().click();
|
||||
await page.getByLabel('Upload title').fill('Supply-chain diligence');
|
||||
await page.getByLabel('Upload summary').fill('Vendor and channel-check notes');
|
||||
await page.getByLabel('Upload tags').fill('diligence, channel-check');
|
||||
await page.getByLabel('Upload file').setInputFiles(uploadFixture);
|
||||
await page.locator('button', { hasText: 'Upload file' }).click();
|
||||
await expect(page.getByText('Uploaded research file.')).toBeVisible();
|
||||
|
||||
await page.goto(`/filings?ticker=NVDA`);
|
||||
await page.getByRole('link', { name: 'Summary' }).first().click();
|
||||
await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//);
|
||||
await page.getByRole('button', { name: 'Add to journal' }).click();
|
||||
await expect(page.getByText('Saved to the company research journal.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Save to library' }).click();
|
||||
await expect(page.getByText('Saved to the company research library.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Back to analysis' }).click();
|
||||
await page.goto('/research?ticker=NVDA');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
|
||||
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
|
||||
|
||||
await page.getByLabel('Memo rating').selectOption('buy');
|
||||
await page.getByLabel('Memo conviction').selectOption('high');
|
||||
await page.getByLabel('Memo time horizon').fill('24');
|
||||
await page.getByLabel('Packet title').fill('NVIDIA buy-side packet');
|
||||
await page.getByLabel('Packet subtitle').fill('AI infrastructure compounder');
|
||||
await page.getByLabel('Memo Thesis').fill('Maintain a constructive stance as datacenter demand and platform depth widen the moat.');
|
||||
await page.getByLabel('Memo Catalysts').fill('Blackwell ramp, enterprise inference demand, and sustained operating leverage.');
|
||||
await page.getByLabel('Memo Risks').fill('Customer concentration, competition, and execution on supply.');
|
||||
await page.getByRole('button', { name: 'Save memo' }).click();
|
||||
await expect(page.getByText('Saved investment memo.')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Attach' }).first().click();
|
||||
await expect(page.getByText('Attached evidence to Thesis.')).toBeVisible();
|
||||
|
||||
await page.goto('/analysis?ticker=NVDA');
|
||||
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
|
||||
await expect(page.getByText('10-K AI memo')).toBeVisible();
|
||||
await expect(page.getByText('Bull vs Bear')).toBeVisible();
|
||||
await expect(page.getByText('Past 7 Days')).toBeVisible();
|
||||
await expect(page.getByText('Recent Developments')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Open financials' }).click();
|
||||
await page.goto('/financials?ticker=NVDA');
|
||||
await expect(page).toHaveURL(/\/financials\?ticker=NVDA/);
|
||||
|
||||
await page.getByRole('link', { name: 'Filings' }).first().click();
|
||||
await page.goto('/filings?ticker=NVDA');
|
||||
await expect(page).toHaveURL(/\/filings\?ticker=NVDA/);
|
||||
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /journal/i }).first()).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Summary' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('supports add, edit, and delete holding flows with summary refresh', async ({ page }, testInfo) => {
|
||||
test.slow();
|
||||
await signUp(page, testInfo);
|
||||
|
||||
await page.goto('/portfolio');
|
||||
|
||||
86
e2e/watchlist.spec.ts
Normal file
86
e2e/watchlist.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'Sup3rSecure!123';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
function uniqueEmail(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
async function gotoAuthPage(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async function signUp(page: Page, email: string) {
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Watchlist 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();
|
||||
}
|
||||
|
||||
async function expectStableDashboard(page: Page) {
|
||||
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function countSyncTasks(page: Page, ticker: string) {
|
||||
return await page.evaluate(async (requestedTicker) => {
|
||||
const response = await fetch('/api/tasks?limit=20', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load tasks: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
tasks?: Array<{
|
||||
task_type?: string;
|
||||
payload?: {
|
||||
ticker?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
return (payload.tasks ?? []).filter((task) => (
|
||||
task.task_type === 'sync_filings'
|
||||
&& task.payload?.ticker === requestedTicker
|
||||
)).length;
|
||||
}, ticker);
|
||||
}
|
||||
|
||||
test('coverage save stays metadata-only until sync filings is clicked', async ({ page }) => {
|
||||
await signUp(page, uniqueEmail('playwright-watchlist-sync'));
|
||||
await expectStableDashboard(page);
|
||||
|
||||
await page.goto('/watchlist', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('heading', { name: 'Coverage', exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByLabel('Coverage ticker').fill('NVDA');
|
||||
await page.getByLabel('Coverage company name').fill('NVIDIA Corporation');
|
||||
await page.getByLabel('Coverage sector').fill('Technology');
|
||||
await page.getByLabel('Coverage category').fill('core');
|
||||
await page.getByLabel('Coverage tags').fill('semis, ai');
|
||||
await page.getByRole('button', { name: 'Save coverage' }).click();
|
||||
|
||||
const coverageRow = page.locator('tr').filter({ hasText: 'NVDA' });
|
||||
await expect(coverageRow).toContainText('NVIDIA Corporation');
|
||||
|
||||
const notice = page.getByTestId('watchlist-post-create-notice');
|
||||
await expect(notice).toContainText('NVDA added to coverage. Filing sync has not started yet.');
|
||||
await expect(notice.getByRole('button', { name: 'Sync filings' })).toBeVisible();
|
||||
await expect(coverageRow.getByRole('button', { name: 'Sync filings' })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(await countSyncTasks(page, 'NVDA')).toBe(0);
|
||||
|
||||
await notice.getByRole('button', { name: 'Sync filings' }).click();
|
||||
|
||||
await expect(notice).toContainText('NVDA added to coverage. Filing sync is queued.');
|
||||
await expect.poll(async () => await countSyncTasks(page, 'NVDA')).toBe(1);
|
||||
});
|
||||
60
hooks/use-auth-handoff.ts
Normal file
60
hooks/use-auth-handoff.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const HANDOFF_TIMEOUT_MS = 10_000;
|
||||
|
||||
type AuthSession = {
|
||||
user?: {
|
||||
id?: string;
|
||||
};
|
||||
} | null;
|
||||
|
||||
type UseAuthHandoffOptions = {
|
||||
nextPath: string;
|
||||
session: AuthSession;
|
||||
isPending: boolean;
|
||||
awaitingSession: boolean;
|
||||
onTimeout: () => void;
|
||||
};
|
||||
|
||||
export function useAuthHandoff({
|
||||
nextPath,
|
||||
session,
|
||||
isPending,
|
||||
awaitingSession,
|
||||
onTimeout
|
||||
}: UseAuthHandoffOptions) {
|
||||
const hasNavigatedRef = useRef(false);
|
||||
const hasSession = Boolean(session?.user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || isPending || !hasSession || hasNavigatedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasNavigatedRef.current = true;
|
||||
window.location.replace(nextPath);
|
||||
}, [hasSession, isPending, nextPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !awaitingSession || hasSession || hasNavigatedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
onTimeout();
|
||||
}, HANDOFF_TIMEOUT_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [awaitingSession, hasSession, onTimeout]);
|
||||
|
||||
return {
|
||||
isHandingOff: awaitingSession || (!isPending && hasSession),
|
||||
statusText: awaitingSession
|
||||
? 'Establishing your session and opening the workspace...'
|
||||
: null
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback } from 'react';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import {
|
||||
aiReportQueryOptions,
|
||||
companyAnalysisQueryOptions,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
latestPortfolioInsightQueryOptions,
|
||||
portfolioSummaryQueryOptions,
|
||||
recentTasksQueryOptions,
|
||||
researchWorkspaceQueryOptions,
|
||||
watchlistQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
|
||||
@@ -30,14 +32,19 @@ export function useLinkPrefetch() {
|
||||
}
|
||||
|
||||
const analysisHref = `/analysis?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const researchHref = `/research?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const filingsHref = `/filings?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const financialsHref = `/financials?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const graphingHref = buildGraphingHref(normalizedTicker);
|
||||
|
||||
router.prefetch(analysisHref);
|
||||
router.prefetch(researchHref);
|
||||
router.prefetch(filingsHref);
|
||||
router.prefetch(financialsHref);
|
||||
router.prefetch(graphingHref);
|
||||
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker));
|
||||
void queryClient.prefetchQuery(researchWorkspaceQueryOptions(normalizedTicker));
|
||||
void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({
|
||||
ticker: normalizedTicker,
|
||||
surfaceKind: 'income_statement',
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
listRecentTasks,
|
||||
updateTaskNotificationState
|
||||
} from '@/lib/api';
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
import {
|
||||
buildNotificationEntries,
|
||||
EMPTY_FILING_SYNC_BATCH_STATE,
|
||||
isFilingSyncEntry,
|
||||
notificationEntrySignature,
|
||||
type FilingSyncBatchState
|
||||
} from '@/lib/task-notification-entries';
|
||||
import type { Task, TaskNotificationEntry, TaskStatus } from '@/lib/types';
|
||||
|
||||
const ACTIVE_STATUSES: TaskStatus[] = ['queued', 'running'];
|
||||
const TERMINAL_STATUSES: TaskStatus[] = ['completed', 'failed'];
|
||||
@@ -16,63 +24,133 @@ function isTerminalTask(task: Task) {
|
||||
return TERMINAL_STATUSES.includes(task.status);
|
||||
}
|
||||
|
||||
function taskSignature(task: Task) {
|
||||
return `${task.status}|${task.stage}|${task.stage_detail ?? ''}|${task.error ?? ''}`;
|
||||
function isTerminalEntry(entry: TaskNotificationEntry) {
|
||||
return TERMINAL_STATUSES.includes(entry.status);
|
||||
}
|
||||
|
||||
function taskTitle(task: Task) {
|
||||
switch (task.task_type) {
|
||||
case 'sync_filings':
|
||||
return 'Filing sync';
|
||||
case 'refresh_prices':
|
||||
return 'Price refresh';
|
||||
case 'analyze_filing':
|
||||
return 'Filing analysis';
|
||||
case 'portfolio_insights':
|
||||
return 'Portfolio insight';
|
||||
default:
|
||||
return 'Task';
|
||||
}
|
||||
function shouldNotifyEntry(entry: TaskNotificationEntry) {
|
||||
return entry.notificationSilencedAt === null;
|
||||
}
|
||||
|
||||
function taskDescription(task: Task) {
|
||||
if (task.error && task.status === 'failed') {
|
||||
return task.error;
|
||||
}
|
||||
|
||||
if (task.stage_detail) {
|
||||
return task.stage_detail;
|
||||
}
|
||||
|
||||
switch (task.status) {
|
||||
case 'queued':
|
||||
return 'Queued and waiting for execution.';
|
||||
case 'running':
|
||||
return 'Running in workflow engine.';
|
||||
case 'completed':
|
||||
return 'Task finished successfully.';
|
||||
case 'failed':
|
||||
return 'Task failed.';
|
||||
default:
|
||||
return 'Task status changed.';
|
||||
}
|
||||
function isUnreadEntry(entry: TaskNotificationEntry) {
|
||||
return entry.notificationReadAt === null;
|
||||
}
|
||||
|
||||
function shouldNotifyTask(task: Task) {
|
||||
return !task.notification_silenced_at;
|
||||
function sortTasksByUpdated(tasks: Task[]) {
|
||||
return [...tasks].sort((left, right) => (
|
||||
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
|
||||
));
|
||||
}
|
||||
|
||||
function isUnread(task: Task) {
|
||||
return task.notification_read_at === null;
|
||||
function latestTask(tasks: Task[]) {
|
||||
return sortTasksByUpdated(tasks)[0] ?? null;
|
||||
}
|
||||
|
||||
function taskIdsMatch(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function sameBatchState(left: FilingSyncBatchState, right: FilingSyncBatchState) {
|
||||
return (
|
||||
left.active === right.active
|
||||
&& left.latestTaskId === right.latestTaskId
|
||||
&& left.startedAt === right.startedAt
|
||||
&& left.finishedAt === right.finishedAt
|
||||
&& left.terminalVisible === right.terminalVisible
|
||||
&& taskIdsMatch(left.taskIds, right.taskIds)
|
||||
);
|
||||
}
|
||||
|
||||
function deriveFilingSyncBatch(
|
||||
current: FilingSyncBatchState,
|
||||
activeTasks: Task[],
|
||||
finishedTasks: Task[]
|
||||
) {
|
||||
const activeSyncTasks = sortTasksByUpdated(
|
||||
activeTasks.filter((task) => task.task_type === 'sync_filings')
|
||||
);
|
||||
|
||||
if (activeSyncTasks.length > 0) {
|
||||
const resetBatch = current.terminalVisible || (!current.active && current.taskIds.length === 0);
|
||||
const taskIds = resetBatch
|
||||
? activeSyncTasks.map((task) => task.id)
|
||||
: [...new Set([...current.taskIds, ...activeSyncTasks.map((task) => task.id)])];
|
||||
const newestTask = activeSyncTasks[0] ?? null;
|
||||
const oldestActiveTask = [...activeSyncTasks].sort((left, right) => (
|
||||
new Date(left.created_at).getTime() - new Date(right.created_at).getTime()
|
||||
))[0] ?? null;
|
||||
|
||||
return {
|
||||
active: true,
|
||||
taskIds,
|
||||
latestTaskId: newestTask?.id ?? current.latestTaskId,
|
||||
startedAt: resetBatch ? (oldestActiveTask?.created_at ?? newestTask?.created_at ?? null) : current.startedAt,
|
||||
finishedAt: null,
|
||||
terminalVisible: false
|
||||
} satisfies FilingSyncBatchState;
|
||||
}
|
||||
|
||||
if (current.taskIds.length > 0 && (current.active || current.terminalVisible)) {
|
||||
const batchTaskIds = new Set(current.taskIds);
|
||||
const terminalMembers = finishedTasks.filter((task) => (
|
||||
task.task_type === 'sync_filings' && batchTaskIds.has(task.id)
|
||||
));
|
||||
const newestTerminalTask = latestTask(terminalMembers);
|
||||
|
||||
return {
|
||||
...current,
|
||||
active: false,
|
||||
latestTaskId: newestTerminalTask?.id ?? current.latestTaskId,
|
||||
finishedAt: newestTerminalTask?.updated_at ?? current.finishedAt ?? current.startedAt,
|
||||
terminalVisible: true
|
||||
} satisfies FilingSyncBatchState;
|
||||
}
|
||||
|
||||
return EMPTY_FILING_SYNC_BATCH_STATE;
|
||||
}
|
||||
|
||||
function toastIdForEntry(entry: TaskNotificationEntry) {
|
||||
return isFilingSyncEntry(entry) ? 'toast:filing-sync' : entry.primaryTaskId;
|
||||
}
|
||||
|
||||
function entryProgressLabel(entry: TaskNotificationEntry) {
|
||||
const progress = entry.progress;
|
||||
if (!progress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${progress.current}/${progress.total} ${progress.unit}`;
|
||||
}
|
||||
|
||||
function entryDescription(entry: TaskNotificationEntry) {
|
||||
return [
|
||||
entry.statusLine,
|
||||
entry.detailLine,
|
||||
entryProgressLabel(entry)
|
||||
].filter((value): value is string => Boolean(value)).join(' • ');
|
||||
}
|
||||
|
||||
function terminalToastDescription(entry: TaskNotificationEntry) {
|
||||
const topStat = entry.stats[0];
|
||||
|
||||
return [
|
||||
entry.statusLine,
|
||||
topStat ? `${topStat.label}: ${topStat.value}` : null,
|
||||
entry.detailLine
|
||||
].filter((value): value is string => Boolean(value)).join(' • ');
|
||||
}
|
||||
|
||||
type UseTaskNotificationsCenterResult = {
|
||||
activeTasks: Task[];
|
||||
finishedTasks: Task[];
|
||||
activeEntries: TaskNotificationEntry[];
|
||||
finishedEntries: TaskNotificationEntry[];
|
||||
unreadCount: number;
|
||||
isLoading: boolean;
|
||||
awaitingReviewTasks: Task[];
|
||||
visibleFinishedTasks: Task[];
|
||||
awaitingReviewEntries: TaskNotificationEntry[];
|
||||
visibleFinishedEntries: TaskNotificationEntry[];
|
||||
showReadFinished: boolean;
|
||||
setShowReadFinished: (value: boolean) => void;
|
||||
isPopoverOpen: boolean;
|
||||
@@ -82,15 +160,18 @@ type UseTaskNotificationsCenterResult = {
|
||||
isDetailOpen: boolean;
|
||||
setIsDetailOpen: (value: boolean) => void;
|
||||
openTaskDetails: (taskId: string) => void;
|
||||
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
|
||||
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
|
||||
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||
refreshTasks: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTasks, setActiveTasks] = useState<Task[]>([]);
|
||||
const [finishedTasks, setFinishedTasks] = useState<Task[]>([]);
|
||||
const [filingSyncBatch, setFilingSyncBatch] = useState<FilingSyncBatchState>(EMPTY_FILING_SYNC_BATCH_STATE);
|
||||
const [showReadFinished, setShowReadFinished] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [hasLoadedActive, setHasLoadedActive] = useState(false);
|
||||
@@ -104,6 +185,9 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
const invalidatedTerminalRef = useRef(new Set<string>());
|
||||
const activeSnapshotRef = useRef<Task[]>([]);
|
||||
const finishedSnapshotRef = useRef<Task[]>([]);
|
||||
const filingSyncBatchRef = useRef<FilingSyncBatchState>(EMPTY_FILING_SYNC_BATCH_STATE);
|
||||
const silenceEntryRef = useRef<(entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>>(async () => {});
|
||||
const markEntryReadRef = useRef<(entry: TaskNotificationEntry, read?: boolean) => Promise<void>>(async () => {});
|
||||
const [isDocumentVisible, setIsDocumentVisible] = useState(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
@@ -112,9 +196,23 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return document.visibilityState === 'visible';
|
||||
});
|
||||
|
||||
const applyTaskLocally = useCallback((task: Task) => {
|
||||
setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
||||
setFinishedTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
||||
const syncBatchState = useCallback((nextBatch: FilingSyncBatchState) => {
|
||||
filingSyncBatchRef.current = nextBatch;
|
||||
setFilingSyncBatch((current) => sameBatchState(current, nextBatch) ? current : nextBatch);
|
||||
}, []);
|
||||
|
||||
const mergeTasksLocally = useCallback((tasks: Task[]) => {
|
||||
if (tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskMap = new Map(tasks.map((task) => [task.id, task]));
|
||||
const mergeList = (list: Task[]) => list.map((entry) => taskMap.get(entry.id) ?? entry);
|
||||
|
||||
activeSnapshotRef.current = mergeList(activeSnapshotRef.current);
|
||||
finishedSnapshotRef.current = mergeList(finishedSnapshotRef.current);
|
||||
setActiveTasks((current) => mergeList(current));
|
||||
setFinishedTasks((current) => mergeList(current));
|
||||
}, []);
|
||||
|
||||
const invalidateForTerminalTask = useCallback((task: Task) => {
|
||||
@@ -159,111 +257,221 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const silenceTask = useCallback(async (taskId: string, silenced = true) => {
|
||||
try {
|
||||
const { task } = await updateTaskNotificationState(taskId, { silenced });
|
||||
applyTaskLocally(task);
|
||||
toast.dismiss(taskId);
|
||||
} catch {
|
||||
toast.error('Unable to update notification state');
|
||||
}
|
||||
}, [applyTaskLocally]);
|
||||
const openTaskAction = useCallback((entry: TaskNotificationEntry, actionId?: string | null) => {
|
||||
const action = actionId
|
||||
? entry.actions.find((candidate) => candidate.id === actionId)
|
||||
: entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details')
|
||||
?? entry.actions.find((candidate) => candidate.id !== 'open_details')
|
||||
?? null;
|
||||
|
||||
const markTaskRead = useCallback(async (taskId: string, read = true) => {
|
||||
try {
|
||||
const { task } = await updateTaskNotificationState(taskId, { read });
|
||||
applyTaskLocally(task);
|
||||
if (read) {
|
||||
toast.dismiss(taskId);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Unable to update notification state');
|
||||
}
|
||||
}, [applyTaskLocally]);
|
||||
|
||||
const emitTaskToast = useCallback((task: Task) => {
|
||||
if (!shouldNotifyTask(task)) {
|
||||
toast.dismiss(task.id);
|
||||
if (!action || action.id === 'open_details' || !action.href) {
|
||||
openTaskDetails(entry.primaryTaskId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
toast(taskTitle(task), {
|
||||
id: task.id,
|
||||
setIsPopoverOpen(false);
|
||||
router.push(action.href);
|
||||
}, [openTaskDetails, router]);
|
||||
|
||||
const emitEntryToast = useCallback((entry: TaskNotificationEntry) => {
|
||||
const toastId = toastIdForEntry(entry);
|
||||
|
||||
if (!shouldNotifyEntry(entry)) {
|
||||
toast.dismiss(toastId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.status === 'queued' || entry.status === 'running') {
|
||||
toast(entry.title, {
|
||||
id: toastId,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
description: taskDescription(task),
|
||||
description: entryDescription(entry),
|
||||
action: {
|
||||
label: 'Open details',
|
||||
onClick: () => openTaskDetails(task.id)
|
||||
onClick: () => openTaskDetails(entry.primaryTaskId)
|
||||
},
|
||||
cancel: {
|
||||
label: 'Silence',
|
||||
onClick: () => {
|
||||
void silenceTask(task.id, true);
|
||||
void silenceEntryRef.current(entry, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toastBuilder = task.status === 'completed' ? toast.success : toast.error;
|
||||
const toastBuilder = entry.status === 'completed' ? toast.success : toast.error;
|
||||
const primaryAction = entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details')
|
||||
?? entry.actions.find((candidate) => candidate.id !== 'open_details')
|
||||
?? null;
|
||||
|
||||
toastBuilder(taskTitle(task), {
|
||||
id: task.id,
|
||||
toastBuilder(entry.title, {
|
||||
id: toastId,
|
||||
duration: 10_000,
|
||||
description: taskDescription(task),
|
||||
description: terminalToastDescription(entry),
|
||||
action: {
|
||||
label: 'Open details',
|
||||
onClick: () => openTaskDetails(task.id)
|
||||
label: primaryAction?.label ?? 'Open details',
|
||||
onClick: () => {
|
||||
if (primaryAction) {
|
||||
openTaskAction(entry, primaryAction.id);
|
||||
return;
|
||||
}
|
||||
|
||||
openTaskDetails(entry.primaryTaskId);
|
||||
}
|
||||
},
|
||||
cancel: {
|
||||
label: 'Mark read',
|
||||
onClick: () => {
|
||||
void markTaskRead(task.id, true);
|
||||
void markEntryReadRef.current(entry, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [markTaskRead, openTaskDetails, silenceTask]);
|
||||
|
||||
const processSnapshots = useCallback(() => {
|
||||
const active = activeSnapshotRef.current;
|
||||
const finished = finishedSnapshotRef.current;
|
||||
const all = [...active, ...finished];
|
||||
}, [openTaskAction, openTaskDetails]);
|
||||
|
||||
const processSnapshots = useCallback((nextBatch = filingSyncBatchRef.current) => {
|
||||
if (!activeLoadedRef.current || !finishedLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = buildNotificationEntries({
|
||||
activeTasks: activeSnapshotRef.current,
|
||||
finishedTasks: finishedSnapshotRef.current,
|
||||
filingSyncBatch: nextBatch
|
||||
});
|
||||
|
||||
if (stateSignaturesRef.current.size === 0) {
|
||||
for (const task of all) {
|
||||
stateSignaturesRef.current.set(task.id, taskSignature(task));
|
||||
for (const entry of entries) {
|
||||
stateSignaturesRef.current.set(entry.id, notificationEntrySignature(entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of all) {
|
||||
const signature = taskSignature(task);
|
||||
const previousSignature = stateSignaturesRef.current.get(task.id);
|
||||
for (const entry of entries) {
|
||||
const signature = notificationEntrySignature(entry);
|
||||
const previousSignature = stateSignaturesRef.current.get(entry.id);
|
||||
const wasKnown = previousSignature !== undefined;
|
||||
|
||||
if (!wasKnown || previousSignature !== signature) {
|
||||
emitTaskToast(task);
|
||||
emitEntryToast(entry);
|
||||
|
||||
if (isTerminalTask(task)) {
|
||||
invalidateForTerminalTask(task);
|
||||
if (!isFilingSyncEntry(entry) && isTerminalEntry(entry)) {
|
||||
const terminalTask = [
|
||||
...activeSnapshotRef.current,
|
||||
...finishedSnapshotRef.current
|
||||
].find((task) => task.id === entry.primaryTaskId);
|
||||
|
||||
if (terminalTask) {
|
||||
invalidateForTerminalTask(terminalTask);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFilingSyncEntry(entry) && isTerminalEntry(entry)) {
|
||||
for (const task of [...activeSnapshotRef.current, ...finishedSnapshotRef.current]) {
|
||||
if (task.task_type === 'sync_filings' && entry.taskIds.includes(task.id) && isTerminalTask(task)) {
|
||||
invalidateForTerminalTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stateSignaturesRef.current.set(task.id, signature);
|
||||
stateSignaturesRef.current.set(entry.id, signature);
|
||||
}
|
||||
|
||||
const currentIds = new Set(all.map((task) => task.id));
|
||||
const currentIds = new Set(entries.map((entry) => entry.id));
|
||||
for (const knownId of [...stateSignaturesRef.current.keys()]) {
|
||||
if (!currentIds.has(knownId)) {
|
||||
toast.dismiss(knownId);
|
||||
const toastId = knownId === 'filing-sync:active' ? 'toast:filing-sync' : knownId;
|
||||
toast.dismiss(toastId);
|
||||
stateSignaturesRef.current.delete(knownId);
|
||||
}
|
||||
}
|
||||
}, [emitTaskToast, invalidateForTerminalTask]);
|
||||
}, [emitEntryToast, invalidateForTerminalTask]);
|
||||
|
||||
const applySnapshotState = useCallback((
|
||||
nextActiveTasks: Task[],
|
||||
nextFinishedTasks: Task[],
|
||||
loaded: { active?: boolean; finished?: boolean } = {}
|
||||
) => {
|
||||
activeSnapshotRef.current = nextActiveTasks;
|
||||
finishedSnapshotRef.current = nextFinishedTasks;
|
||||
|
||||
if (loaded.active) {
|
||||
activeLoadedRef.current = true;
|
||||
setHasLoadedActive(true);
|
||||
}
|
||||
|
||||
if (loaded.finished) {
|
||||
finishedLoadedRef.current = true;
|
||||
setHasLoadedFinished(true);
|
||||
}
|
||||
|
||||
setActiveTasks(nextActiveTasks);
|
||||
setFinishedTasks(nextFinishedTasks);
|
||||
|
||||
const nextBatch = deriveFilingSyncBatch(
|
||||
filingSyncBatchRef.current,
|
||||
nextActiveTasks,
|
||||
nextFinishedTasks
|
||||
);
|
||||
|
||||
syncBatchState(nextBatch);
|
||||
processSnapshots(nextBatch);
|
||||
}, [processSnapshots, syncBatchState]);
|
||||
|
||||
const updateEntryNotification = useCallback(async (
|
||||
entry: TaskNotificationEntry,
|
||||
input: { read?: boolean; silenced?: boolean }
|
||||
) => {
|
||||
const results = await Promise.allSettled(
|
||||
entry.taskIds.map((taskId) => updateTaskNotificationState(taskId, input))
|
||||
);
|
||||
const updatedTasks = results.flatMap((result) => (
|
||||
result.status === 'fulfilled' ? [result.value.task] : []
|
||||
));
|
||||
|
||||
if (updatedTasks.length > 0) {
|
||||
mergeTasksLocally(updatedTasks);
|
||||
}
|
||||
|
||||
let nextBatch = deriveFilingSyncBatch(
|
||||
filingSyncBatchRef.current,
|
||||
activeSnapshotRef.current,
|
||||
finishedSnapshotRef.current
|
||||
);
|
||||
|
||||
if (
|
||||
isFilingSyncEntry(entry)
|
||||
&& isTerminalEntry(entry)
|
||||
&& (input.read || input.silenced)
|
||||
&& results.every((result) => result.status === 'fulfilled')
|
||||
) {
|
||||
nextBatch = EMPTY_FILING_SYNC_BATCH_STATE;
|
||||
}
|
||||
|
||||
syncBatchState(nextBatch);
|
||||
processSnapshots(nextBatch);
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
toast.error('Unable to update notification state');
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.read || input.silenced) {
|
||||
toast.dismiss(toastIdForEntry(entry));
|
||||
}
|
||||
}, [mergeTasksLocally, processSnapshots, syncBatchState]);
|
||||
|
||||
const silenceEntry = useCallback(async (entry: TaskNotificationEntry, silenced = true) => {
|
||||
await updateEntryNotification(entry, { silenced });
|
||||
}, [updateEntryNotification]);
|
||||
|
||||
const markEntryRead = useCallback(async (entry: TaskNotificationEntry, read = true) => {
|
||||
await updateEntryNotification(entry, { read });
|
||||
}, [updateEntryNotification]);
|
||||
|
||||
silenceEntryRef.current = silenceEntry;
|
||||
markEntryReadRef.current = markEntryRead;
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
try {
|
||||
@@ -278,19 +486,11 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
})
|
||||
]);
|
||||
|
||||
activeSnapshotRef.current = activeRes.tasks;
|
||||
finishedSnapshotRef.current = finishedRes.tasks;
|
||||
activeLoadedRef.current = true;
|
||||
finishedLoadedRef.current = true;
|
||||
setHasLoadedActive(true);
|
||||
setHasLoadedFinished(true);
|
||||
setActiveTasks(activeRes.tasks);
|
||||
setFinishedTasks(finishedRes.tasks);
|
||||
processSnapshots();
|
||||
applySnapshotState(activeRes.tasks, finishedRes.tasks, { active: true, finished: true });
|
||||
} catch {
|
||||
// ignore transient polling failures
|
||||
}
|
||||
}, [processSnapshots]);
|
||||
}, [applySnapshotState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -334,7 +534,13 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return 4_000;
|
||||
}
|
||||
|
||||
if (finishedSnapshotRef.current.some((task) => isUnread(task))) {
|
||||
const terminalEntries = buildNotificationEntries({
|
||||
activeTasks: activeSnapshotRef.current,
|
||||
finishedTasks: finishedSnapshotRef.current,
|
||||
filingSyncBatch: filingSyncBatchRef.current
|
||||
}).filter(isTerminalEntry);
|
||||
|
||||
if (terminalEntries.some((entry) => isUnreadEntry(entry))) {
|
||||
return 15_000;
|
||||
}
|
||||
|
||||
@@ -356,11 +562,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSnapshotRef.current = response.tasks;
|
||||
activeLoadedRef.current = true;
|
||||
setHasLoadedActive(true);
|
||||
setActiveTasks(response.tasks);
|
||||
processSnapshots();
|
||||
applySnapshotState(response.tasks, finishedSnapshotRef.current, { active: true });
|
||||
} catch {
|
||||
// ignore transient polling failures
|
||||
}
|
||||
@@ -383,13 +585,27 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return;
|
||||
}
|
||||
|
||||
finishedSnapshotRef.current = response.tasks;
|
||||
finishedLoadedRef.current = true;
|
||||
setHasLoadedFinished(true);
|
||||
setFinishedTasks(response.tasks);
|
||||
processSnapshots();
|
||||
applySnapshotState(activeSnapshotRef.current, response.tasks, { finished: true });
|
||||
|
||||
const signature = response.tasks
|
||||
.map((task) => notificationEntrySignature({
|
||||
id: task.id,
|
||||
kind: 'single',
|
||||
status: task.status,
|
||||
title: task.notification.title,
|
||||
statusLine: task.notification.statusLine,
|
||||
detailLine: task.notification.detailLine,
|
||||
progress: task.notification.progress,
|
||||
stats: task.notification.stats,
|
||||
updatedAt: task.updated_at,
|
||||
primaryTaskId: task.id,
|
||||
taskIds: [task.id],
|
||||
actions: task.notification.actions,
|
||||
notificationReadAt: task.notification_read_at,
|
||||
notificationSilencedAt: task.notification_silenced_at
|
||||
}))
|
||||
.join('||');
|
||||
|
||||
const signature = response.tasks.map((task) => taskSignature(task)).join('||');
|
||||
if (signature === previousTerminalSignature) {
|
||||
stableTerminalPolls += 1;
|
||||
} else {
|
||||
@@ -415,43 +631,52 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
clearTimeout(terminalTimer);
|
||||
}
|
||||
};
|
||||
}, [isDetailOpen, isDocumentVisible, isPopoverOpen, processSnapshots]);
|
||||
}, [applySnapshotState, isDetailOpen, isDocumentVisible, isPopoverOpen]);
|
||||
|
||||
const normalizedActiveTasks = useMemo(() => {
|
||||
return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
|
||||
}, [activeTasks]);
|
||||
const entries = useMemo(() => buildNotificationEntries({
|
||||
activeTasks,
|
||||
finishedTasks,
|
||||
filingSyncBatch
|
||||
}), [activeTasks, filingSyncBatch, finishedTasks]);
|
||||
|
||||
const normalizedFinishedTasks = useMemo(() => {
|
||||
return finishedTasks.filter((task) => TERMINAL_STATUSES.includes(task.status));
|
||||
}, [finishedTasks]);
|
||||
const activeEntries = useMemo(() => {
|
||||
return entries.filter((entry) => ACTIVE_STATUSES.includes(entry.status));
|
||||
}, [entries]);
|
||||
|
||||
const awaitingReviewTasks = useMemo(() => {
|
||||
return normalizedFinishedTasks.filter((task) => isUnread(task));
|
||||
}, [normalizedFinishedTasks]);
|
||||
const normalizedFinishedEntries = useMemo(() => {
|
||||
return entries.filter((entry) => TERMINAL_STATUSES.includes(entry.status));
|
||||
}, [entries]);
|
||||
|
||||
const visibleFinishedTasks = useMemo(() => {
|
||||
const awaitingReviewEntries = useMemo(() => {
|
||||
return normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry));
|
||||
}, [normalizedFinishedEntries]);
|
||||
|
||||
const visibleFinishedEntries = useMemo(() => {
|
||||
if (showReadFinished) {
|
||||
return normalizedFinishedTasks;
|
||||
return normalizedFinishedEntries;
|
||||
}
|
||||
|
||||
return awaitingReviewTasks;
|
||||
}, [awaitingReviewTasks, normalizedFinishedTasks, showReadFinished]);
|
||||
return awaitingReviewEntries;
|
||||
}, [awaitingReviewEntries, normalizedFinishedEntries, showReadFinished]);
|
||||
|
||||
const unreadCount = useMemo(() => {
|
||||
const unreadTerminal = normalizedFinishedTasks.filter((task) => isUnread(task)).length;
|
||||
const unreadActive = normalizedActiveTasks.filter((task) => isUnread(task) && !task.notification_silenced_at).length;
|
||||
const unreadTerminal = normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry)).length;
|
||||
const unreadActive = activeEntries.filter((entry) => (
|
||||
isUnreadEntry(entry) && entry.notificationSilencedAt === null
|
||||
)).length;
|
||||
|
||||
return unreadTerminal + unreadActive;
|
||||
}, [normalizedActiveTasks, normalizedFinishedTasks]);
|
||||
}, [activeEntries, normalizedFinishedEntries]);
|
||||
|
||||
const isLoading = !hasLoadedActive || !hasLoadedFinished;
|
||||
|
||||
return {
|
||||
activeTasks: normalizedActiveTasks,
|
||||
finishedTasks: normalizedFinishedTasks,
|
||||
activeEntries,
|
||||
finishedEntries: normalizedFinishedEntries,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
awaitingReviewTasks,
|
||||
visibleFinishedTasks,
|
||||
awaitingReviewEntries,
|
||||
visibleFinishedEntries,
|
||||
showReadFinished,
|
||||
setShowReadFinished,
|
||||
isPopoverOpen,
|
||||
@@ -461,8 +686,9 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
isDetailOpen,
|
||||
setIsDetailOpen,
|
||||
openTaskDetails,
|
||||
markTaskRead,
|
||||
silenceTask,
|
||||
openTaskAction,
|
||||
markEntryRead,
|
||||
silenceEntry,
|
||||
refreshTasks
|
||||
};
|
||||
}
|
||||
|
||||
273
lib/api.ts
273
lib/api.ts
@@ -12,8 +12,22 @@ import type {
|
||||
Holding,
|
||||
PortfolioInsight,
|
||||
PortfolioSummary,
|
||||
ResearchArtifact,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchJournalEntry,
|
||||
ResearchJournalEntryType,
|
||||
SearchAnswerResponse,
|
||||
SearchResult,
|
||||
SearchSource,
|
||||
ResearchLibraryResponse,
|
||||
ResearchMemo,
|
||||
ResearchMemoConviction,
|
||||
ResearchMemoEvidenceLink,
|
||||
ResearchMemoRating,
|
||||
ResearchMemoSection,
|
||||
ResearchPacket,
|
||||
ResearchWorkspace,
|
||||
Task,
|
||||
TaskStatus,
|
||||
TaskTimeline,
|
||||
@@ -105,7 +119,7 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
|
||||
|
||||
async function requestJson<T>(input: {
|
||||
path: string;
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
}, fallback: string) {
|
||||
const response = await fetch(`${API_BASE}${input.path}`, {
|
||||
@@ -154,7 +168,7 @@ export async function upsertWatchlistItem(input: {
|
||||
lastReviewedAt?: string;
|
||||
}) {
|
||||
const result = await client.api.watchlist.post(input);
|
||||
return await unwrapData<{ item: WatchlistItem }>(result, 'Unable to save watchlist item');
|
||||
return await unwrapData<{ item: WatchlistItem; autoFilingSyncQueued: boolean }>(result, 'Unable to save watchlist item');
|
||||
}
|
||||
|
||||
export async function updateWatchlistItem(id: number, input: {
|
||||
@@ -206,6 +220,208 @@ export async function createResearchJournalEntry(input: {
|
||||
}, 'Unable to create journal entry');
|
||||
}
|
||||
|
||||
export async function getResearchWorkspace(ticker: string) {
|
||||
return await requestJson<{ workspace: ResearchWorkspace }>({
|
||||
path: `/api/research/workspace?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research workspace');
|
||||
}
|
||||
|
||||
export async function listResearchLibrary(input: {
|
||||
ticker: string;
|
||||
q?: string;
|
||||
kind?: ResearchArtifactKind;
|
||||
tag?: string;
|
||||
source?: ResearchArtifactSource;
|
||||
linkedToMemo?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
});
|
||||
|
||||
if (input.q?.trim()) {
|
||||
params.set('q', input.q.trim());
|
||||
}
|
||||
|
||||
if (input.kind) {
|
||||
params.set('kind', input.kind);
|
||||
}
|
||||
|
||||
if (input.tag?.trim()) {
|
||||
params.set('tag', input.tag.trim());
|
||||
}
|
||||
|
||||
if (input.source) {
|
||||
params.set('source', input.source);
|
||||
}
|
||||
|
||||
if (input.linkedToMemo !== undefined) {
|
||||
params.set('linkedToMemo', input.linkedToMemo ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (typeof input.limit === 'number') {
|
||||
params.set('limit', String(input.limit));
|
||||
}
|
||||
|
||||
return await requestJson<ResearchLibraryResponse>({
|
||||
path: `/api/research/library?${params.toString()}`
|
||||
}, 'Unable to fetch research library');
|
||||
}
|
||||
|
||||
export async function createResearchArtifact(input: {
|
||||
ticker: string;
|
||||
accessionNumber?: string;
|
||||
kind: ResearchArtifactKind;
|
||||
source?: ResearchArtifactSource;
|
||||
subtype?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
bodyMarkdown?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return await requestJson<{ artifact: ResearchArtifact }>({
|
||||
path: '/api/research/library',
|
||||
method: 'POST',
|
||||
body: {
|
||||
...input,
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
}
|
||||
}, 'Unable to create research artifact');
|
||||
}
|
||||
|
||||
export async function updateResearchArtifact(id: number, input: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
bodyMarkdown?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return await requestJson<{ artifact: ResearchArtifact }>({
|
||||
path: `/api/research/library/${id}`,
|
||||
method: 'PATCH',
|
||||
body: input
|
||||
}, 'Unable to update research artifact');
|
||||
}
|
||||
|
||||
export async function deleteResearchArtifact(id: number) {
|
||||
return await requestJson<{ success: boolean }>({
|
||||
path: `/api/research/library/${id}`,
|
||||
method: 'DELETE'
|
||||
}, 'Unable to delete research artifact');
|
||||
}
|
||||
|
||||
export async function uploadResearchArtifact(input: {
|
||||
ticker: string;
|
||||
file: File;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
}) {
|
||||
const form = new FormData();
|
||||
form.set('ticker', input.ticker.trim().toUpperCase());
|
||||
form.set('file', input.file);
|
||||
|
||||
if (input.title?.trim()) {
|
||||
form.set('title', input.title.trim());
|
||||
}
|
||||
|
||||
if (input.summary?.trim()) {
|
||||
form.set('summary', input.summary.trim());
|
||||
}
|
||||
|
||||
if (input.tags && input.tags.length > 0) {
|
||||
form.set('tags', input.tags.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/research/library/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: form
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
extractErrorMessage({ value: payload }, 'Unable to upload research file'),
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new ApiError('Unable to upload research file', response.status);
|
||||
}
|
||||
|
||||
return payload as { artifact: ResearchArtifact };
|
||||
}
|
||||
|
||||
export function getResearchArtifactFileUrl(id: number) {
|
||||
return `${API_BASE}/api/research/library/${id}/file`;
|
||||
}
|
||||
|
||||
export async function getResearchMemo(ticker: string) {
|
||||
return await requestJson<{ memo: ResearchMemo | null }>({
|
||||
path: `/api/research/memo?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research memo');
|
||||
}
|
||||
|
||||
export async function upsertResearchMemo(input: {
|
||||
ticker: string;
|
||||
rating?: ResearchMemoRating | null;
|
||||
conviction?: ResearchMemoConviction | null;
|
||||
timeHorizonMonths?: number | null;
|
||||
packetTitle?: string;
|
||||
packetSubtitle?: string;
|
||||
thesisMarkdown?: string;
|
||||
variantViewMarkdown?: string;
|
||||
catalystsMarkdown?: string;
|
||||
risksMarkdown?: string;
|
||||
disconfirmingEvidenceMarkdown?: string;
|
||||
nextActionsMarkdown?: string;
|
||||
}) {
|
||||
return await requestJson<{ memo: ResearchMemo }>({
|
||||
path: '/api/research/memo',
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...input,
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
}
|
||||
}, 'Unable to save research memo');
|
||||
}
|
||||
|
||||
export async function addResearchMemoEvidence(input: {
|
||||
memoId: number;
|
||||
artifactId: number;
|
||||
section: ResearchMemoSection;
|
||||
annotation?: string;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
return await requestJson<{ evidence: ResearchMemoEvidenceLink[] }>({
|
||||
path: `/api/research/memo/${input.memoId}/evidence`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
artifactId: input.artifactId,
|
||||
section: input.section,
|
||||
annotation: input.annotation,
|
||||
sortOrder: input.sortOrder
|
||||
}
|
||||
}, 'Unable to attach memo evidence');
|
||||
}
|
||||
|
||||
export async function deleteResearchMemoEvidence(memoId: number, linkId: number) {
|
||||
return await requestJson<{ success: boolean }>({
|
||||
path: `/api/research/memo/${memoId}/evidence/${linkId}`,
|
||||
method: 'DELETE'
|
||||
}, 'Unable to delete memo evidence');
|
||||
}
|
||||
|
||||
export async function getResearchPacket(ticker: string) {
|
||||
return await requestJson<{ packet: ResearchPacket }>({
|
||||
path: `/api/research/packet?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research packet');
|
||||
}
|
||||
|
||||
export async function updateResearchJournalEntry(id: number, input: {
|
||||
title?: string;
|
||||
bodyMarkdown?: string;
|
||||
@@ -295,10 +511,59 @@ export async function listFilings(query?: { ticker?: string; limit?: number }) {
|
||||
return await unwrapData<{ filings: Filing[] }>(result, 'Unable to fetch filings');
|
||||
}
|
||||
|
||||
export async function getCompanyAnalysis(ticker: string) {
|
||||
export async function searchKnowledge(input: {
|
||||
query: string;
|
||||
ticker?: string;
|
||||
sources?: SearchSource[];
|
||||
limit?: number;
|
||||
}) {
|
||||
const result = await client.api.search.get({
|
||||
$query: {
|
||||
q: input.query.trim(),
|
||||
...(input.ticker?.trim()
|
||||
? { ticker: input.ticker.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(input.sources && input.sources.length > 0
|
||||
? { sources: input.sources }
|
||||
: {}),
|
||||
...(typeof input.limit === 'number'
|
||||
? { limit: input.limit }
|
||||
: {})
|
||||
}
|
||||
});
|
||||
|
||||
return await unwrapData<{ results: SearchResult[] }>(result, 'Unable to search indexed sources');
|
||||
}
|
||||
|
||||
export async function getSearchAnswer(input: {
|
||||
query: string;
|
||||
ticker?: string;
|
||||
sources?: SearchSource[];
|
||||
limit?: number;
|
||||
}) {
|
||||
return await requestJson<SearchAnswerResponse>({
|
||||
path: '/api/search/answer',
|
||||
method: 'POST',
|
||||
body: {
|
||||
query: input.query.trim(),
|
||||
...(input.ticker?.trim()
|
||||
? { ticker: input.ticker.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(input.sources && input.sources.length > 0
|
||||
? { sources: input.sources }
|
||||
: {}),
|
||||
...(typeof input.limit === 'number'
|
||||
? { limit: input.limit }
|
||||
: {})
|
||||
}
|
||||
}, 'Unable to generate cited answer');
|
||||
}
|
||||
|
||||
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' } : {})
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
87
lib/financial-metrics.ts
Normal file
87
lib/financial-metrics.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind,
|
||||
FinancialUnit,
|
||||
RatioRow
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
type ComputedDefinition,
|
||||
type SurfaceDefinition,
|
||||
ALL_COMPUTED,
|
||||
INCOME_SURFACES,
|
||||
BALANCE_SURFACES,
|
||||
CASH_FLOW_SURFACES,
|
||||
RATIO_CATEGORIES
|
||||
} from '@/lib/generated';
|
||||
|
||||
export type GraphableFinancialSurfaceKind = Extract<
|
||||
FinancialSurfaceKind,
|
||||
'income_statement' | 'balance_sheet' | 'cash_flow_statement' | 'ratios'
|
||||
>;
|
||||
|
||||
export type StatementMetricDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
};
|
||||
|
||||
export type RatioMetricDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: RatioRow['unit'];
|
||||
denominatorKey: string | null;
|
||||
supportedCadences?: readonly FinancialCadence[];
|
||||
};
|
||||
|
||||
export const GRAPHABLE_FINANCIAL_SURFACES: readonly GraphableFinancialSurfaceKind[] = [
|
||||
'income_statement',
|
||||
'balance_sheet',
|
||||
'cash_flow_statement',
|
||||
'ratios'
|
||||
] as const;
|
||||
|
||||
function surfaceToMetric(surface: SurfaceDefinition): StatementMetricDefinition {
|
||||
return {
|
||||
key: surface.surface_key,
|
||||
label: surface.label,
|
||||
category: surface.category,
|
||||
order: surface.order,
|
||||
unit: surface.unit
|
||||
};
|
||||
}
|
||||
|
||||
function computedToRatioMetric(computed: ComputedDefinition): RatioMetricDefinition {
|
||||
const denominatorKey = computed.computation.type === 'ratio'
|
||||
? computed.computation.denominator
|
||||
: computed.computation.type === 'per_share'
|
||||
? computed.computation.shares_key
|
||||
: null;
|
||||
|
||||
return {
|
||||
key: computed.key,
|
||||
label: computed.label,
|
||||
category: computed.category,
|
||||
order: computed.order,
|
||||
unit: computed.unit as RatioRow['unit'],
|
||||
denominatorKey,
|
||||
supportedCadences: computed.supported_cadences as readonly FinancialCadence[] | undefined
|
||||
};
|
||||
}
|
||||
|
||||
export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
INCOME_SURFACES.map(surfaceToMetric);
|
||||
|
||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
BALANCE_SURFACES.map(surfaceToMetric);
|
||||
|
||||
export const CASH_FLOW_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
CASH_FLOW_SURFACES.map(surfaceToMetric);
|
||||
|
||||
export const RATIO_DEFINITIONS: RatioMetricDefinition[] =
|
||||
ALL_COMPUTED.map(computedToRatioMetric);
|
||||
|
||||
export { RATIO_CATEGORIES, type RatioCategory } from '@/lib/generated';
|
||||
185
lib/financials/page-merge.test.ts
Normal file
185
lib/financials/page-merge.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { __financialPageMergeInternals } from './page-merge';
|
||||
import type { CompanyFinancialStatementsResponse } from '@/lib/types';
|
||||
|
||||
function createResponse(partial: Partial<CompanyFinancialStatementsResponse>): CompanyFinancialStatementsResponse {
|
||||
return {
|
||||
company: {
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft Corporation',
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
displayModes: ['standardized', 'faithful'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: [],
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: []
|
||||
},
|
||||
statementDetails: null,
|
||||
ratioRows: null,
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
categories: [],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: 0,
|
||||
rows: 0,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: 0,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
normalization: {
|
||||
parserEngine: 'fiscal-xbrl',
|
||||
regime: 'us-gaap',
|
||||
fiscalPack: 'core',
|
||||
parserVersion: '0.1.0',
|
||||
surfaceRowCount: 0,
|
||||
detailRowCount: 0,
|
||||
kpiRowCount: 0,
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
warnings: []
|
||||
},
|
||||
dimensionBreakdown: null,
|
||||
...partial
|
||||
};
|
||||
}
|
||||
|
||||
describe('financial page merge helpers', () => {
|
||||
it('merges detail maps safely when legacy detail rows are missing arrays', () => {
|
||||
const merged = __financialPageMergeInternals.mergeDetailMaps(
|
||||
{
|
||||
revenue: [{
|
||||
key: 'detail',
|
||||
parentSurfaceKey: 'revenue',
|
||||
label: 'Detail',
|
||||
conceptKey: 'detail',
|
||||
qname: 'us-gaap:Detail',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'Detail',
|
||||
unit: 'iso4217:USD',
|
||||
values: { p1: 1 },
|
||||
sourceFactIds: undefined,
|
||||
isExtension: false,
|
||||
dimensionsSummary: undefined,
|
||||
residualFlag: false
|
||||
} as never]
|
||||
},
|
||||
{
|
||||
revenue: [{
|
||||
key: 'detail',
|
||||
parentSurfaceKey: 'revenue',
|
||||
label: 'Detail',
|
||||
conceptKey: 'detail',
|
||||
qname: 'us-gaap:Detail',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'Detail',
|
||||
unit: 'iso4217:USD',
|
||||
values: { p2: 2 },
|
||||
sourceFactIds: [2],
|
||||
isExtension: false,
|
||||
dimensionsSummary: ['region:americas'],
|
||||
residualFlag: false
|
||||
}]
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged?.revenue?.[0]).toMatchObject({
|
||||
values: { p1: 1, p2: 2 },
|
||||
sourceFactIds: [2],
|
||||
dimensionsSummary: ['region:americas']
|
||||
});
|
||||
});
|
||||
|
||||
it('merges paged financial responses safely when row arrays are partially missing', () => {
|
||||
const base = createResponse({
|
||||
periods: [{
|
||||
id: 'p1',
|
||||
filingId: 1,
|
||||
accessionNumber: '0001',
|
||||
filingDate: '2025-01-01',
|
||||
periodStart: '2024-01-01',
|
||||
periodEnd: '2024-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2024'
|
||||
}],
|
||||
statementRows: {
|
||||
faithful: undefined as never,
|
||||
standardized: [{
|
||||
key: 'revenue',
|
||||
label: 'Revenue',
|
||||
category: 'revenue',
|
||||
order: 10,
|
||||
unit: 'currency',
|
||||
values: { p1: 1 },
|
||||
sourceConcepts: [],
|
||||
sourceRowKeys: [],
|
||||
sourceFactIds: [],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: { p1: 'revenue' }
|
||||
}]
|
||||
}
|
||||
});
|
||||
const next = createResponse({
|
||||
periods: [{
|
||||
id: 'p2',
|
||||
filingId: 2,
|
||||
accessionNumber: '0002',
|
||||
filingDate: '2026-01-01',
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: '2025-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2025'
|
||||
}],
|
||||
statementRows: {
|
||||
faithful: [{
|
||||
key: 'rev',
|
||||
label: 'Revenue',
|
||||
conceptKey: 'rev',
|
||||
qname: 'us-gaap:Revenue',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'Revenue',
|
||||
isExtension: false,
|
||||
statement: 'income',
|
||||
roleUri: 'income',
|
||||
order: 10,
|
||||
depth: 0,
|
||||
parentKey: null,
|
||||
values: { p2: 2 },
|
||||
units: { p2: 'iso4217:USD' },
|
||||
hasDimensions: false,
|
||||
sourceFactIds: []
|
||||
}],
|
||||
standardized: undefined as never
|
||||
}
|
||||
});
|
||||
|
||||
const merged = __financialPageMergeInternals.mergeFinancialPages(base, next);
|
||||
|
||||
expect(merged.periods.map((period) => period.id)).toEqual(['p1', 'p2']);
|
||||
expect(merged.statementRows).toMatchObject({
|
||||
faithful: [{ key: 'rev' }],
|
||||
standardized: [{ key: 'revenue', values: { p1: 1 } }]
|
||||
});
|
||||
});
|
||||
});
|
||||
98
lib/financials/page-merge.ts
Normal file
98
lib/financials/page-merge.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse
|
||||
} from '@/lib/types';
|
||||
|
||||
export function mergeDetailMaps(
|
||||
base: CompanyFinancialStatementsResponse['statementDetails'],
|
||||
next: CompanyFinancialStatementsResponse['statementDetails']
|
||||
) {
|
||||
if (!base) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const merged: NonNullable<CompanyFinancialStatementsResponse['statementDetails']> = structuredClone(base);
|
||||
for (const [surfaceKey, detailRows] of Object.entries(next)) {
|
||||
const existingRows = merged[surfaceKey] ?? [];
|
||||
const rowMap = new Map(existingRows.map((row) => [row.key, row]));
|
||||
|
||||
for (const detailRow of detailRows) {
|
||||
const existing = rowMap.get(detailRow.key);
|
||||
if (!existing) {
|
||||
rowMap.set(detailRow.key, structuredClone(detailRow));
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.values = {
|
||||
...existing.values,
|
||||
...detailRow.values
|
||||
};
|
||||
existing.sourceFactIds = [...new Set([...(existing.sourceFactIds ?? []), ...(detailRow.sourceFactIds ?? [])])];
|
||||
existing.dimensionsSummary = [...new Set([...(existing.dimensionsSummary ?? []), ...(detailRow.dimensionsSummary ?? [])])];
|
||||
}
|
||||
|
||||
merged[surfaceKey] = [...rowMap.values()];
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function mergeFinancialPages(
|
||||
base: CompanyFinancialStatementsResponse | null,
|
||||
next: CompanyFinancialStatementsResponse
|
||||
) {
|
||||
if (!base) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const periods = [...base.periods, ...next.periods]
|
||||
.filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index)
|
||||
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
|
||||
|
||||
const mergeRows = <T extends { key: string; values: Record<string, number | null> }>(rows: T[]) => {
|
||||
const map = new Map<string, T>();
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.key);
|
||||
if (!existing) {
|
||||
map.set(row.key, structuredClone(row));
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.values = {
|
||||
...existing.values,
|
||||
...row.values
|
||||
};
|
||||
}
|
||||
|
||||
return [...map.values()];
|
||||
};
|
||||
|
||||
return {
|
||||
...next,
|
||||
periods,
|
||||
statementRows: next.statementRows && base.statementRows
|
||||
? {
|
||||
faithful: mergeRows([...(base.statementRows.faithful ?? []), ...(next.statementRows.faithful ?? [])]),
|
||||
standardized: mergeRows([...(base.statementRows.standardized ?? []), ...(next.statementRows.standardized ?? [])])
|
||||
}
|
||||
: next.statementRows,
|
||||
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
|
||||
ratioRows: next.ratioRows && base.ratioRows
|
||||
? mergeRows([...(base.ratioRows ?? []), ...(next.ratioRows ?? [])])
|
||||
: next.ratioRows,
|
||||
kpiRows: next.kpiRows && base.kpiRows
|
||||
? mergeRows([...(base.kpiRows ?? []), ...(next.kpiRows ?? [])])
|
||||
: next.kpiRows,
|
||||
trendSeries: next.trendSeries,
|
||||
categories: next.categories,
|
||||
dimensionBreakdown: next.dimensionBreakdown ?? base.dimensionBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
export const __financialPageMergeInternals = {
|
||||
mergeDetailMaps,
|
||||
mergeFinancialPages
|
||||
};
|
||||
317
lib/financials/statement-view-model.test.ts
Normal file
317
lib/financials/statement-view-model.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildStatementTree,
|
||||
resolveStatementSelection
|
||||
} from '@/lib/financials/statement-view-model';
|
||||
import type { DetailFinancialRow, SurfaceFinancialRow } from '@/lib/types';
|
||||
|
||||
function createSurfaceRow(input: Partial<SurfaceFinancialRow> & Pick<SurfaceFinancialRow, 'key' | 'label' | 'values'>): SurfaceFinancialRow {
|
||||
return {
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
category: input.category ?? 'revenue',
|
||||
order: input.order ?? 10,
|
||||
unit: input.unit ?? 'currency',
|
||||
values: input.values,
|
||||
sourceConcepts: input.sourceConcepts ?? [input.key],
|
||||
sourceRowKeys: input.sourceRowKeys ?? [input.key],
|
||||
sourceFactIds: input.sourceFactIds ?? [1],
|
||||
formulaKey: input.formulaKey ?? null,
|
||||
hasDimensions: input.hasDimensions ?? false,
|
||||
resolvedSourceRowKeys: input.resolvedSourceRowKeys ?? Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, input.key])),
|
||||
statement: input.statement ?? 'income',
|
||||
detailCount: input.detailCount,
|
||||
resolutionMethod: input.resolutionMethod,
|
||||
confidence: input.confidence,
|
||||
warningCodes: input.warningCodes
|
||||
};
|
||||
}
|
||||
|
||||
function createDetailRow(input: Partial<DetailFinancialRow> & Pick<DetailFinancialRow, 'key' | 'label' | 'parentSurfaceKey' | 'values'>): DetailFinancialRow {
|
||||
return {
|
||||
key: input.key,
|
||||
parentSurfaceKey: input.parentSurfaceKey,
|
||||
label: input.label,
|
||||
conceptKey: input.conceptKey ?? input.key,
|
||||
qname: input.qname ?? `us-gaap:${input.key}`,
|
||||
namespaceUri: input.namespaceUri ?? 'http://fasb.org/us-gaap/2024',
|
||||
localName: input.localName ?? input.key,
|
||||
unit: input.unit ?? 'USD',
|
||||
values: input.values,
|
||||
sourceFactIds: input.sourceFactIds ?? [100],
|
||||
isExtension: input.isExtension ?? false,
|
||||
dimensionsSummary: input.dimensionsSummary ?? [],
|
||||
residualFlag: input.residualFlag ?? false
|
||||
};
|
||||
}
|
||||
|
||||
describe('statement view model', () => {
|
||||
const categories = [{ key: 'opex', label: 'Operating Expenses', count: 4 }];
|
||||
|
||||
it('builds a root-only tree when there are no configured children or details', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
],
|
||||
statementDetails: null,
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(1);
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'revenue' },
|
||||
expandable: false
|
||||
});
|
||||
});
|
||||
|
||||
it('nests the operating expense child surfaces under the parent row', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } }),
|
||||
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 30, values: { p1: 12 } }),
|
||||
createSurfaceRow({ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 40, values: { p1: 8 } })
|
||||
],
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['operating_expenses'])
|
||||
});
|
||||
|
||||
const parent = model.sections[0]?.nodes[0];
|
||||
expect(parent?.kind).toBe('surface');
|
||||
const childKeys = parent?.kind === 'surface'
|
||||
? parent.children.map((node) => node.row.key)
|
||||
: [];
|
||||
expect(childKeys).toEqual([
|
||||
'selling_general_and_administrative',
|
||||
'research_and_development',
|
||||
'other_operating_expense'
|
||||
]);
|
||||
});
|
||||
|
||||
it('nests raw detail rows under the matching child surface row', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } })
|
||||
],
|
||||
statementDetails: {
|
||||
selling_general_and_administrative: [
|
||||
createDetailRow({ key: 'corporate_sga', label: 'Corporate SG&A', parentSurfaceKey: 'selling_general_and_administrative', values: { p1: 20 } })
|
||||
]
|
||||
},
|
||||
categories,
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['operating_expenses', 'selling_general_and_administrative'])
|
||||
});
|
||||
|
||||
const child = model.sections[0]?.nodes[0];
|
||||
expect(child?.kind).toBe('surface');
|
||||
const sgaNode = child?.kind === 'surface' ? child.children[0] : null;
|
||||
expect(sgaNode?.kind).toBe('surface');
|
||||
const detailNode = sgaNode?.kind === 'surface' ? sgaNode.children[0] : null;
|
||||
expect(detailNode).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'corporate_sga' }
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-expands the parent chain when search matches a child surface or detail row', () => {
|
||||
const rows = [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 20, values: { p1: 12 } })
|
||||
];
|
||||
|
||||
const childSearch = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: 'research',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(childSearch.autoExpandedKeys.has('operating_expenses')).toBe(true);
|
||||
expect(childSearch.sections[0]?.nodes[0]?.kind === 'surface' && childSearch.sections[0]?.nodes[0].expanded).toBe(true);
|
||||
|
||||
const detailSearch = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: {
|
||||
research_and_development: [
|
||||
createDetailRow({ key: 'ai_lab_expense', label: 'AI Lab Expense', parentSurfaceKey: 'research_and_development', values: { p1: 12 } })
|
||||
]
|
||||
},
|
||||
categories,
|
||||
searchQuery: 'ai lab',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
const parent = detailSearch.sections[0]?.nodes[0];
|
||||
const child = parent?.kind === 'surface' ? parent.children[0] : null;
|
||||
expect(parent?.kind === 'surface' && parent.expanded).toBe(true);
|
||||
expect(child?.kind === 'surface' && child.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it('does not throw when legacy surface rows are missing source arrays', () => {
|
||||
const malformedRow = {
|
||||
...createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } }),
|
||||
sourceConcepts: undefined,
|
||||
sourceRowKeys: undefined
|
||||
} as unknown as SurfaceFinancialRow;
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [malformedRow],
|
||||
statementDetails: null,
|
||||
categories: [],
|
||||
searchQuery: 'revenue',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'revenue' }
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps not meaningful rows visible and resolves selections for surface and detail nodes', () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: 'gross_profit',
|
||||
label: 'Gross Profit',
|
||||
category: 'profit',
|
||||
values: { p1: null },
|
||||
resolutionMethod: 'not_meaningful',
|
||||
warningCodes: ['gross_profit_not_meaningful_bank_pack']
|
||||
})
|
||||
];
|
||||
const details = {
|
||||
gross_profit: [
|
||||
createDetailRow({ key: 'gp_unmapped', label: 'Gross Profit residual', parentSurfaceKey: 'gross_profit', values: { p1: null }, residualFlag: true })
|
||||
]
|
||||
};
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['gross_profit'])
|
||||
});
|
||||
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'gross_profit', resolutionMethod: 'not_meaningful' }
|
||||
});
|
||||
|
||||
const surfaceSelection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: 'surface', key: 'gross_profit' }
|
||||
});
|
||||
expect(surfaceSelection?.kind).toBe('surface');
|
||||
|
||||
const detailSelection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: 'detail', key: 'gp_unmapped', parentKey: 'gross_profit' }
|
||||
});
|
||||
expect(detailSelection).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'gp_unmapped', residualFlag: true }
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unmapped detail rows in a dedicated residual section and counts them', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
],
|
||||
statementDetails: {
|
||||
unmapped: [
|
||||
createDetailRow({
|
||||
key: 'unmapped_other_income',
|
||||
label: 'Other income residual',
|
||||
parentSurfaceKey: 'unmapped',
|
||||
values: { p1: 5 },
|
||||
residualFlag: true
|
||||
})
|
||||
]
|
||||
},
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(2);
|
||||
expect(model.sections[1]).toMatchObject({
|
||||
key: 'unmapped_residual',
|
||||
label: 'Unmapped / Residual'
|
||||
});
|
||||
expect(model.sections[1]?.nodes[0]).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'unmapped_other_income', parentSurfaceKey: 'unmapped' }
|
||||
});
|
||||
expect(model.visibleNodeCount).toBe(2);
|
||||
expect(model.totalNodeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('matches search and resolves selection for unmapped detail rows without a real parent surface', () => {
|
||||
const rows = [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
];
|
||||
const statementDetails = {
|
||||
unmapped: [
|
||||
createDetailRow({
|
||||
key: 'unmapped_fx_gain',
|
||||
label: 'FX gain residual',
|
||||
parentSurfaceKey: 'unmapped',
|
||||
values: { p1: 2 },
|
||||
residualFlag: true
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails,
|
||||
categories: [],
|
||||
searchQuery: 'fx gain',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(1);
|
||||
expect(model.sections[0]).toMatchObject({
|
||||
key: 'unmapped_residual',
|
||||
label: 'Unmapped / Residual'
|
||||
});
|
||||
expect(model.visibleNodeCount).toBe(1);
|
||||
expect(model.totalNodeCount).toBe(2);
|
||||
|
||||
const selection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails,
|
||||
selection: { kind: 'detail', key: 'unmapped_fx_gain', parentKey: 'unmapped' }
|
||||
});
|
||||
|
||||
expect(selection).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'unmapped_fx_gain', parentSurfaceKey: 'unmapped' },
|
||||
parentSurfaceRow: null
|
||||
});
|
||||
});
|
||||
});
|
||||
367
lib/financials/statement-view-model.ts
Normal file
367
lib/financials/statement-view-model.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type {
|
||||
DetailFinancialRow,
|
||||
FinancialCategory,
|
||||
FinancialSurfaceKind,
|
||||
SurfaceDetailMap,
|
||||
SurfaceFinancialRow
|
||||
} from '@/lib/types';
|
||||
|
||||
const SURFACE_CHILDREN: Partial<Record<Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>, Record<string, string[]>>> = {
|
||||
income_statement: {
|
||||
operating_expenses: [
|
||||
'selling_general_and_administrative',
|
||||
'research_and_development',
|
||||
'other_operating_expense'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export type StatementInspectorSelection = {
|
||||
kind: 'surface' | 'detail';
|
||||
key: string;
|
||||
parentKey?: string;
|
||||
};
|
||||
|
||||
export type StatementTreeDetailNode = {
|
||||
kind: 'detail';
|
||||
id: string;
|
||||
level: number;
|
||||
row: DetailFinancialRow;
|
||||
parentSurfaceKey: string;
|
||||
matchesSearch: boolean;
|
||||
};
|
||||
|
||||
export type StatementTreeSurfaceNode = {
|
||||
kind: 'surface';
|
||||
id: string;
|
||||
level: number;
|
||||
row: SurfaceFinancialRow;
|
||||
childSurfaceKeys: string[];
|
||||
directDetailCount: number;
|
||||
children: StatementTreeNode[];
|
||||
expandable: boolean;
|
||||
expanded: boolean;
|
||||
autoExpanded: boolean;
|
||||
matchesSearch: boolean;
|
||||
};
|
||||
|
||||
export type StatementTreeNode = StatementTreeSurfaceNode | StatementTreeDetailNode;
|
||||
|
||||
export type StatementTreeSection = {
|
||||
key: string;
|
||||
label: string | null;
|
||||
nodes: StatementTreeNode[];
|
||||
};
|
||||
|
||||
export type StatementTreeModel = {
|
||||
sections: StatementTreeSection[];
|
||||
autoExpandedKeys: Set<string>;
|
||||
visibleNodeCount: number;
|
||||
totalNodeCount: number;
|
||||
};
|
||||
|
||||
export type ResolvedStatementSelection =
|
||||
| {
|
||||
kind: 'surface';
|
||||
row: SurfaceFinancialRow;
|
||||
childSurfaceRows: SurfaceFinancialRow[];
|
||||
detailRows: DetailFinancialRow[];
|
||||
}
|
||||
| {
|
||||
kind: 'detail';
|
||||
row: DetailFinancialRow;
|
||||
parentSurfaceRow: SurfaceFinancialRow | null;
|
||||
};
|
||||
|
||||
type Categories = Array<{
|
||||
key: FinancialCategory;
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
|
||||
const UNMAPPED_DETAIL_GROUP_KEY = 'unmapped';
|
||||
const UNMAPPED_SECTION_KEY = 'unmapped_residual';
|
||||
const UNMAPPED_SECTION_LABEL = 'Unmapped / Residual';
|
||||
|
||||
function surfaceConfigForKind(surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>) {
|
||||
return SURFACE_CHILDREN[surfaceKind] ?? {};
|
||||
}
|
||||
|
||||
function detailNodeId(parentKey: string, row: DetailFinancialRow) {
|
||||
return `detail:${parentKey}:${row.key}`;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function searchTextForSurface(row: SurfaceFinancialRow) {
|
||||
return [
|
||||
row.label,
|
||||
row.key,
|
||||
...(row.sourceConcepts ?? []),
|
||||
...(row.sourceRowKeys ?? []),
|
||||
...(row.warningCodes ?? [])
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function searchTextForDetail(row: DetailFinancialRow) {
|
||||
return [
|
||||
row.label,
|
||||
row.key,
|
||||
row.parentSurfaceKey,
|
||||
row.conceptKey,
|
||||
row.qname,
|
||||
row.localName,
|
||||
...(row.dimensionsSummary ?? [])
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function sortSurfaceRows(left: SurfaceFinancialRow, right: SurfaceFinancialRow) {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
}
|
||||
|
||||
function sortDetailRows(left: DetailFinancialRow, right: DetailFinancialRow) {
|
||||
return left.label.localeCompare(right.label);
|
||||
}
|
||||
|
||||
function buildUnmappedDetailNodes(input: {
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
searchQuery: string;
|
||||
}) {
|
||||
const normalizedSearch = normalize(input.searchQuery);
|
||||
|
||||
return [...(input.statementDetails?.[UNMAPPED_DETAIL_GROUP_KEY] ?? [])]
|
||||
.sort(sortDetailRows)
|
||||
.filter((detail) => normalizedSearch.length === 0 || searchTextForDetail(detail).includes(normalizedSearch))
|
||||
.map((detail) => ({
|
||||
kind: 'detail',
|
||||
id: detailNodeId(UNMAPPED_DETAIL_GROUP_KEY, detail),
|
||||
level: 0,
|
||||
row: detail,
|
||||
parentSurfaceKey: UNMAPPED_DETAIL_GROUP_KEY,
|
||||
matchesSearch: normalizedSearch.length > 0 && searchTextForDetail(detail).includes(normalizedSearch)
|
||||
}) satisfies StatementTreeDetailNode);
|
||||
}
|
||||
|
||||
function countNodes(nodes: StatementTreeNode[]) {
|
||||
let count = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
count += 1;
|
||||
if (node.kind === 'surface') {
|
||||
count += countNodes(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function buildStatementTree(input: {
|
||||
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
categories: Categories;
|
||||
searchQuery: string;
|
||||
expandedRowKeys: Set<string>;
|
||||
}): StatementTreeModel {
|
||||
const config = surfaceConfigForKind(input.surfaceKind);
|
||||
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||
const childKeySet = new Set<string>();
|
||||
|
||||
for (const children of Object.values(config)) {
|
||||
for (const childKey of children) {
|
||||
if (rowByKey.has(childKey)) {
|
||||
childKeySet.add(childKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSearch = normalize(input.searchQuery);
|
||||
const autoExpandedKeys = new Set<string>();
|
||||
|
||||
const buildSurfaceNode = (row: SurfaceFinancialRow, level: number): StatementTreeSurfaceNode | null => {
|
||||
const childSurfaceRows = (config[row.key] ?? [])
|
||||
.map((key) => rowByKey.get(key))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||
.sort(sortSurfaceRows);
|
||||
const detailRows = [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows);
|
||||
const childSurfaceNodes = childSurfaceRows
|
||||
.map((childRow) => buildSurfaceNode(childRow, level + 1))
|
||||
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||
const detailNodes = detailRows
|
||||
.filter((detail) => normalizedSearch.length === 0 || searchTextForDetail(detail).includes(normalizedSearch))
|
||||
.map((detail) => ({
|
||||
kind: 'detail',
|
||||
id: detailNodeId(row.key, detail),
|
||||
level: level + 1,
|
||||
row: detail,
|
||||
parentSurfaceKey: row.key,
|
||||
matchesSearch: normalizedSearch.length > 0 && searchTextForDetail(detail).includes(normalizedSearch)
|
||||
}) satisfies StatementTreeDetailNode);
|
||||
const children = [...childSurfaceNodes, ...detailNodes];
|
||||
const matchesSearch = normalizedSearch.length > 0 && searchTextForSurface(row).includes(normalizedSearch);
|
||||
const hasMatchingDescendant = normalizedSearch.length > 0 && children.length > 0;
|
||||
|
||||
if (normalizedSearch.length > 0 && !matchesSearch && !hasMatchingDescendant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childSurfaceKeys = childSurfaceRows.map((candidate) => candidate.key);
|
||||
const directDetailCount = detailRows.length;
|
||||
const autoExpanded = normalizedSearch.length > 0 && !matchesSearch && children.length > 0;
|
||||
const expanded = children.length > 0 && (input.expandedRowKeys.has(row.key) || autoExpanded);
|
||||
|
||||
if (autoExpanded) {
|
||||
autoExpandedKeys.add(row.key);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'surface',
|
||||
id: row.key,
|
||||
level,
|
||||
row,
|
||||
childSurfaceKeys,
|
||||
directDetailCount,
|
||||
children,
|
||||
expandable: children.length > 0,
|
||||
expanded,
|
||||
autoExpanded,
|
||||
matchesSearch
|
||||
};
|
||||
};
|
||||
|
||||
const rootNodes = input.rows
|
||||
.filter((row) => !childKeySet.has(row.key))
|
||||
.sort(sortSurfaceRows)
|
||||
.map((row) => buildSurfaceNode(row, 0))
|
||||
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||
|
||||
if (input.categories.length === 0) {
|
||||
const sections: StatementTreeSection[] = rootNodes.length > 0
|
||||
? [{ key: 'ungrouped', label: null, nodes: rootNodes }]
|
||||
: [];
|
||||
const unmappedNodes = buildUnmappedDetailNodes({
|
||||
statementDetails: input.statementDetails,
|
||||
searchQuery: input.searchQuery
|
||||
});
|
||||
|
||||
if (unmappedNodes.length > 0) {
|
||||
sections.push({
|
||||
key: UNMAPPED_SECTION_KEY,
|
||||
label: UNMAPPED_SECTION_LABEL,
|
||||
nodes: unmappedNodes
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sections,
|
||||
autoExpandedKeys,
|
||||
visibleNodeCount: sections.reduce((sum, section) => sum + countNodes(section.nodes), 0),
|
||||
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
const sections: StatementTreeSection[] = [];
|
||||
const categoriesByKey = new Map(input.categories.map((category) => [category.key, category.label]));
|
||||
|
||||
for (const category of input.categories) {
|
||||
const nodes = rootNodes.filter((node) => node.row.category === category.key);
|
||||
if (nodes.length > 0) {
|
||||
sections.push({
|
||||
key: category.key,
|
||||
label: category.label,
|
||||
nodes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uncategorized = rootNodes.filter((node) => !categoriesByKey.has(node.row.category));
|
||||
if (uncategorized.length > 0) {
|
||||
sections.push({
|
||||
key: 'uncategorized',
|
||||
label: null,
|
||||
nodes: uncategorized
|
||||
});
|
||||
}
|
||||
|
||||
const unmappedNodes = buildUnmappedDetailNodes({
|
||||
statementDetails: input.statementDetails,
|
||||
searchQuery: input.searchQuery
|
||||
});
|
||||
if (unmappedNodes.length > 0) {
|
||||
sections.push({
|
||||
key: UNMAPPED_SECTION_KEY,
|
||||
label: UNMAPPED_SECTION_LABEL,
|
||||
nodes: unmappedNodes
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sections,
|
||||
autoExpandedKeys,
|
||||
visibleNodeCount: sections.reduce((sum, section) => sum + countNodes(section.nodes), 0),
|
||||
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStatementSelection(input: {
|
||||
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
selection: StatementInspectorSelection | null;
|
||||
}): ResolvedStatementSelection | null {
|
||||
const selection = input.selection;
|
||||
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||
const config = surfaceConfigForKind(input.surfaceKind);
|
||||
|
||||
if (selection.kind === 'surface') {
|
||||
const row = rowByKey.get(selection.key);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childSurfaceRows = (config[row.key] ?? [])
|
||||
.map((key) => rowByKey.get(key))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||
.sort(sortSurfaceRows);
|
||||
|
||||
return {
|
||||
kind: 'surface',
|
||||
row,
|
||||
childSurfaceRows,
|
||||
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows)
|
||||
};
|
||||
}
|
||||
|
||||
const parentSurfaceKey = selection.parentKey ?? null;
|
||||
const detailRows = parentSurfaceKey === UNMAPPED_DETAIL_GROUP_KEY
|
||||
? input.statementDetails?.[UNMAPPED_DETAIL_GROUP_KEY] ?? []
|
||||
: parentSurfaceKey
|
||||
? input.statementDetails?.[parentSurfaceKey] ?? []
|
||||
: Object.values(input.statementDetails ?? {}).flat();
|
||||
const row = detailRows.find((candidate) => candidate.key === selection.key) ?? null;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'detail',
|
||||
row,
|
||||
parentSurfaceRow: rowByKey.get(row.parentSurfaceKey) ?? null
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { formatCurrencyByScale, formatScaledNumber } from './format';
|
||||
import {
|
||||
formatCurrencyByScale,
|
||||
formatFinancialStatementValue,
|
||||
formatScaledNumber
|
||||
} from './format';
|
||||
|
||||
describe('formatScaledNumber', () => {
|
||||
it('keeps values below one thousand unscaled', () => {
|
||||
@@ -45,3 +49,59 @@ describe('formatCurrencyByScale', () => {
|
||||
expect(formatCurrencyByScale(-2_500_000, 'millions')).toBe('-$2.5M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFinancialStatementValue', () => {
|
||||
it('renders null statement values as em dashes', () => {
|
||||
expect(formatFinancialStatementValue({
|
||||
value: null,
|
||||
unit: 'currency',
|
||||
scale: 'millions',
|
||||
rowKey: 'revenue',
|
||||
surfaceKind: 'income_statement'
|
||||
})).toBe('—');
|
||||
});
|
||||
|
||||
it('formats scaled currency values without a currency symbol', () => {
|
||||
expect(formatFinancialStatementValue({
|
||||
value: 15_940_899_000,
|
||||
unit: 'currency',
|
||||
scale: 'millions',
|
||||
rowKey: 'revenue',
|
||||
surfaceKind: 'income_statement'
|
||||
})).toBe('15,940.9');
|
||||
expect(formatFinancialStatementValue({
|
||||
value: 1_100_000_000,
|
||||
unit: 'currency',
|
||||
scale: 'millions',
|
||||
rowKey: 'long_term_debt_issued',
|
||||
surfaceKind: 'cash_flow_statement'
|
||||
})).toBe('1,100');
|
||||
});
|
||||
|
||||
it('keeps extra precision for per-share rows', () => {
|
||||
expect(formatFinancialStatementValue({
|
||||
value: 14.64,
|
||||
unit: 'currency',
|
||||
scale: 'millions',
|
||||
rowKey: 'diluted_eps',
|
||||
surfaceKind: 'income_statement'
|
||||
})).toBe('14.64');
|
||||
});
|
||||
|
||||
it('formats statement percents and ratios with trimmed precision', () => {
|
||||
expect(formatFinancialStatementValue({
|
||||
value: 0.233,
|
||||
unit: 'percent',
|
||||
scale: 'millions',
|
||||
rowKey: 'effective_tax_rate',
|
||||
surfaceKind: 'income_statement'
|
||||
})).toBe('23.3%');
|
||||
expect(formatFinancialStatementValue({
|
||||
value: 1.5,
|
||||
unit: 'ratio',
|
||||
scale: 'millions',
|
||||
rowKey: 'debt_to_equity',
|
||||
surfaceKind: 'ratios'
|
||||
})).toBe('1.5x');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
FinancialSurfaceKind,
|
||||
FinancialUnit
|
||||
} from './types';
|
||||
|
||||
export function asNumber(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
@@ -37,6 +42,37 @@ type FormatScaledCurrencyOptions = {
|
||||
maximumFractionDigits?: number;
|
||||
};
|
||||
|
||||
type FormatFinancialStatementValueInput = {
|
||||
value: string | number | null | undefined;
|
||||
unit: FinancialUnit;
|
||||
scale: NumberScaleUnit;
|
||||
rowKey?: string | null;
|
||||
surfaceKind?: FinancialSurfaceKind | null;
|
||||
isPercentChange?: boolean;
|
||||
isCommonSize?: boolean;
|
||||
};
|
||||
|
||||
function isPerShareRowKey(rowKey: string | null | undefined) {
|
||||
if (!rowKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rowKey.includes('eps') || rowKey.includes('per_share');
|
||||
}
|
||||
|
||||
function formatPlainNumber(
|
||||
value: number,
|
||||
options: {
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
} = {}
|
||||
) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: options.maximumFractionDigits ?? 1
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatScaledNumber(
|
||||
value: string | number | null | undefined,
|
||||
options: FormatScaledNumberOptions = {}
|
||||
@@ -99,6 +135,40 @@ export function formatCurrencyByScale(
|
||||
return `${formatted}${suffix}`;
|
||||
}
|
||||
|
||||
export function formatFinancialStatementValue(input: FormatFinancialStatementValueInput) {
|
||||
if (input.value === null || input.value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const numeric = typeof input.value === 'number' ? input.value : Number(input.value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (input.isPercentChange || input.isCommonSize || input.unit === 'percent') {
|
||||
return `${formatPlainNumber(numeric * 100, { minimumFractionDigits: 0, maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
if (input.unit === 'ratio') {
|
||||
return `${formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}x`;
|
||||
}
|
||||
|
||||
if (input.unit === 'currency' && isPerShareRowKey(input.rowKey)) {
|
||||
return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
if (input.unit === 'currency') {
|
||||
const scaled = numeric / NUMBER_SCALE_UNITS[input.scale].divisor;
|
||||
return formatPlainNumber(scaled, { minimumFractionDigits: 0, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
if (input.unit === 'shares' || input.unit === 'count') {
|
||||
return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export function formatCurrency(value: string | number | null | undefined) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
|
||||
60
lib/graphing/catalog.test.ts
Normal file
60
lib/graphing/catalog.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
DEFAULT_GRAPH_TICKERS,
|
||||
buildGraphingHref,
|
||||
metricsForSurfaceAndCadence,
|
||||
normalizeGraphTickers,
|
||||
parseGraphingParams
|
||||
} from '@/lib/graphing/catalog';
|
||||
|
||||
describe('graphing catalog', () => {
|
||||
it('normalizes compare set tickers with dedupe and max five', () => {
|
||||
expect(normalizeGraphTickers(' msft, aapl, msft, nvda, amd, goog, meta ')).toEqual([
|
||||
'MSFT',
|
||||
'AAPL',
|
||||
'NVDA',
|
||||
'AMD',
|
||||
'GOOG'
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to defaults when params are missing or invalid', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=invalid&metric=made_up&chart=nope'));
|
||||
|
||||
expect(state.tickers).toEqual([...DEFAULT_GRAPH_TICKERS]);
|
||||
expect(state.surface).toBe('income_statement');
|
||||
expect(state.metric).toBe('revenue');
|
||||
expect(state.chart).toBe('line');
|
||||
expect(state.scale).toBe('millions');
|
||||
});
|
||||
|
||||
it('filters annual-only ratio metrics for non-annual views', () => {
|
||||
const quarterlyMetricKeys = metricsForSurfaceAndCadence('ratios', 'quarterly').map((metric) => metric.key);
|
||||
|
||||
expect(quarterlyMetricKeys).not.toContain('3y_revenue_cagr');
|
||||
expect(quarterlyMetricKeys).not.toContain('5y_eps_cagr');
|
||||
expect(quarterlyMetricKeys).toContain('gross_margin');
|
||||
});
|
||||
|
||||
it('includes other operating expense in the income statement metric catalog', () => {
|
||||
const metricKeys = metricsForSurfaceAndCadence('income_statement', 'annual').map((metric) => metric.key);
|
||||
|
||||
expect(metricKeys).toContain('operating_expenses');
|
||||
expect(metricKeys).toContain('selling_general_and_administrative');
|
||||
expect(metricKeys).toContain('research_and_development');
|
||||
expect(metricKeys).toContain('other_operating_expense');
|
||||
});
|
||||
|
||||
it('replaces invalid metrics after surface and cadence normalization', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));
|
||||
|
||||
expect(state.surface).toBe('ratios');
|
||||
expect(state.cadence).toBe('quarterly');
|
||||
expect(state.metric).toBe('gross_margin');
|
||||
expect(state.tickers).toEqual(['MSFT', 'AAPL']);
|
||||
});
|
||||
|
||||
it('builds graphing hrefs with the primary ticker leading the compare set', () => {
|
||||
expect(buildGraphingHref('amd')).toContain('tickers=AMD%2CMSFT%2CAAPL%2CNVDA');
|
||||
});
|
||||
});
|
||||
238
lib/graphing/catalog.ts
Normal file
238
lib/graphing/catalog.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialUnit,
|
||||
NumberScaleUnit
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
|
||||
GRAPHABLE_FINANCIAL_SURFACES,
|
||||
type GraphableFinancialSurfaceKind,
|
||||
INCOME_STATEMENT_METRIC_DEFINITIONS,
|
||||
RATIO_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
type SearchParamsLike = {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
|
||||
export type GraphChartKind = 'line' | 'bar';
|
||||
|
||||
export type GraphMetricDefinition = {
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
supportedCadences: readonly FinancialCadence[];
|
||||
};
|
||||
|
||||
export type GraphingUrlState = {
|
||||
tickers: string[];
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
metric: string;
|
||||
cadence: FinancialCadence;
|
||||
chart: GraphChartKind;
|
||||
scale: NumberScaleUnit;
|
||||
};
|
||||
|
||||
export const DEFAULT_GRAPH_TICKERS = ['MSFT', 'AAPL', 'NVDA'] as const;
|
||||
export const DEFAULT_GRAPH_SURFACE: GraphableFinancialSurfaceKind = 'income_statement';
|
||||
export const DEFAULT_GRAPH_CADENCE: FinancialCadence = 'annual';
|
||||
export const DEFAULT_GRAPH_CHART: GraphChartKind = 'line';
|
||||
export const DEFAULT_GRAPH_SCALE: NumberScaleUnit = 'millions';
|
||||
|
||||
export const GRAPH_SURFACE_LABELS: Record<GraphableFinancialSurfaceKind, string> = {
|
||||
income_statement: 'Income Statement',
|
||||
balance_sheet: 'Balance Sheet',
|
||||
cash_flow_statement: 'Cash Flow Statement',
|
||||
ratios: 'Ratios'
|
||||
};
|
||||
|
||||
export const GRAPH_CADENCE_OPTIONS: Array<{ value: FinancialCadence; label: string }> = [
|
||||
{ value: 'annual', label: 'Annual' },
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'ltm', label: 'LTM' }
|
||||
];
|
||||
|
||||
export const GRAPH_CHART_OPTIONS: Array<{ value: GraphChartKind; label: string }> = [
|
||||
{ value: 'line', label: 'Line' },
|
||||
{ value: 'bar', label: 'Bar' }
|
||||
];
|
||||
|
||||
export const GRAPH_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||
{ value: 'thousands', label: 'Thousands (K)' },
|
||||
{ value: 'millions', label: 'Millions (M)' },
|
||||
{ value: 'billions', label: 'Billions (B)' }
|
||||
];
|
||||
|
||||
function buildStatementMetrics(
|
||||
surface: Extract<GraphableFinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>,
|
||||
metrics: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
}>
|
||||
) {
|
||||
return metrics.map((metric) => ({
|
||||
...metric,
|
||||
surface,
|
||||
supportedCadences: ['annual', 'quarterly', 'ltm'] as const
|
||||
})) satisfies GraphMetricDefinition[];
|
||||
}
|
||||
|
||||
export const GRAPH_METRIC_CATALOG: Record<GraphableFinancialSurfaceKind, GraphMetricDefinition[]> = {
|
||||
income_statement: buildStatementMetrics('income_statement', INCOME_STATEMENT_METRIC_DEFINITIONS),
|
||||
balance_sheet: buildStatementMetrics('balance_sheet', BALANCE_SHEET_METRIC_DEFINITIONS),
|
||||
cash_flow_statement: buildStatementMetrics('cash_flow_statement', CASH_FLOW_STATEMENT_METRIC_DEFINITIONS),
|
||||
ratios: RATIO_DEFINITIONS.map((metric) => ({
|
||||
surface: 'ratios',
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
category: metric.category,
|
||||
order: metric.order,
|
||||
unit: metric.unit,
|
||||
supportedCadences: metric.supportedCadences ?? ['annual', 'quarterly', 'ltm']
|
||||
}))
|
||||
};
|
||||
|
||||
export const DEFAULT_GRAPH_METRIC_BY_SURFACE: Record<GraphableFinancialSurfaceKind, string> = {
|
||||
income_statement: 'revenue',
|
||||
balance_sheet: 'total_assets',
|
||||
cash_flow_statement: 'free_cash_flow',
|
||||
ratios: 'gross_margin'
|
||||
};
|
||||
|
||||
export function normalizeGraphTickers(value: string | null | undefined) {
|
||||
const raw = (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const ticker of raw) {
|
||||
unique.add(ticker);
|
||||
if (unique.size >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
export function isGraphSurfaceKind(value: string | null | undefined): value is GraphableFinancialSurfaceKind {
|
||||
return GRAPHABLE_FINANCIAL_SURFACES.includes(value as GraphableFinancialSurfaceKind);
|
||||
}
|
||||
|
||||
export function isGraphCadence(value: string | null | undefined): value is FinancialCadence {
|
||||
return value === 'annual' || value === 'quarterly' || value === 'ltm';
|
||||
}
|
||||
|
||||
export function isGraphChartKind(value: string | null | undefined): value is GraphChartKind {
|
||||
return value === 'line' || value === 'bar';
|
||||
}
|
||||
|
||||
export function isNumberScaleUnit(value: string | null | undefined): value is NumberScaleUnit {
|
||||
return value === 'thousands' || value === 'millions' || value === 'billions';
|
||||
}
|
||||
|
||||
export function metricsForSurfaceAndCadence(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence
|
||||
) {
|
||||
return GRAPH_METRIC_CATALOG[surface].filter((metric) => metric.supportedCadences.includes(cadence));
|
||||
}
|
||||
|
||||
export function resolveGraphMetric(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence,
|
||||
metric: string | null | undefined
|
||||
) {
|
||||
const metrics = metricsForSurfaceAndCadence(surface, cadence);
|
||||
const normalizedMetric = metric?.trim() ?? '';
|
||||
const match = metrics.find((candidate) => candidate.key === normalizedMetric);
|
||||
|
||||
if (match) {
|
||||
return match.key;
|
||||
}
|
||||
|
||||
const surfaceDefault = metrics.find((candidate) => candidate.key === DEFAULT_GRAPH_METRIC_BY_SURFACE[surface]);
|
||||
return surfaceDefault?.key ?? metrics[0]?.key ?? DEFAULT_GRAPH_METRIC_BY_SURFACE[surface];
|
||||
}
|
||||
|
||||
export function getGraphMetricDefinition(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence,
|
||||
metric: string
|
||||
) {
|
||||
return metricsForSurfaceAndCadence(surface, cadence).find((candidate) => candidate.key === metric) ?? null;
|
||||
}
|
||||
|
||||
export function defaultGraphingState(): GraphingUrlState {
|
||||
return {
|
||||
tickers: [...DEFAULT_GRAPH_TICKERS],
|
||||
surface: DEFAULT_GRAPH_SURFACE,
|
||||
metric: DEFAULT_GRAPH_METRIC_BY_SURFACE[DEFAULT_GRAPH_SURFACE],
|
||||
cadence: DEFAULT_GRAPH_CADENCE,
|
||||
chart: DEFAULT_GRAPH_CHART,
|
||||
scale: DEFAULT_GRAPH_SCALE
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGraphingParams(searchParams: SearchParamsLike): GraphingUrlState {
|
||||
const tickers = normalizeGraphTickers(searchParams.get('tickers'));
|
||||
const surface = isGraphSurfaceKind(searchParams.get('surface'))
|
||||
? searchParams.get('surface') as GraphableFinancialSurfaceKind
|
||||
: DEFAULT_GRAPH_SURFACE;
|
||||
const cadence = isGraphCadence(searchParams.get('cadence'))
|
||||
? searchParams.get('cadence') as FinancialCadence
|
||||
: DEFAULT_GRAPH_CADENCE;
|
||||
const metric = resolveGraphMetric(surface, cadence, searchParams.get('metric'));
|
||||
const chart = isGraphChartKind(searchParams.get('chart'))
|
||||
? searchParams.get('chart') as GraphChartKind
|
||||
: DEFAULT_GRAPH_CHART;
|
||||
const scale = isNumberScaleUnit(searchParams.get('scale'))
|
||||
? searchParams.get('scale') as NumberScaleUnit
|
||||
: DEFAULT_GRAPH_SCALE;
|
||||
|
||||
return {
|
||||
tickers: tickers.length > 0 ? tickers : [...DEFAULT_GRAPH_TICKERS],
|
||||
surface,
|
||||
metric,
|
||||
cadence,
|
||||
chart,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGraphingParams(state: GraphingUrlState) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tickers', state.tickers.join(','));
|
||||
params.set('surface', state.surface);
|
||||
params.set('metric', state.metric);
|
||||
params.set('cadence', state.cadence);
|
||||
params.set('chart', state.chart);
|
||||
params.set('scale', state.scale);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function withPrimaryGraphTicker(ticker: string | null | undefined) {
|
||||
const normalized = ticker?.trim().toUpperCase() ?? '';
|
||||
if (!normalized) {
|
||||
return [...DEFAULT_GRAPH_TICKERS];
|
||||
}
|
||||
|
||||
return normalizeGraphTickers([normalized, ...DEFAULT_GRAPH_TICKERS].join(','));
|
||||
}
|
||||
|
||||
export function buildGraphingHref(primaryTicker?: string | null) {
|
||||
const tickers = withPrimaryGraphTicker(primaryTicker);
|
||||
const params = serializeGraphingParams({
|
||||
...defaultGraphingState(),
|
||||
tickers
|
||||
});
|
||||
return `/graphing?${params}`;
|
||||
}
|
||||
272
lib/graphing/series.test.ts
Normal file
272
lib/graphing/series.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildGraphingComparisonData } from '@/lib/graphing/series';
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialStatementPeriod,
|
||||
RatioRow,
|
||||
SurfaceFinancialRow
|
||||
} from '@/lib/types';
|
||||
|
||||
function createPeriod(input: {
|
||||
id: string;
|
||||
filingId: number;
|
||||
filingDate: string;
|
||||
periodEnd: string;
|
||||
filingType?: '10-K' | '10-Q';
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
filingId: input.filingId,
|
||||
accessionNumber: `0000-${input.filingId}`,
|
||||
filingDate: input.filingDate,
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: input.periodEnd,
|
||||
filingType: input.filingType ?? '10-Q',
|
||||
periodLabel: input.id
|
||||
} satisfies FinancialStatementPeriod;
|
||||
}
|
||||
|
||||
function createStatementRow(key: string, values: Record<string, number | null>, unit: SurfaceFinancialRow['unit'] = 'currency') {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
category: 'test',
|
||||
order: 10,
|
||||
unit,
|
||||
values,
|
||||
sourceConcepts: [key],
|
||||
sourceRowKeys: [key],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key])),
|
||||
statement: 'income'
|
||||
} satisfies SurfaceFinancialRow;
|
||||
}
|
||||
|
||||
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
|
||||
return {
|
||||
...createStatementRow(key, values, unit),
|
||||
denominatorKey: null
|
||||
} satisfies RatioRow;
|
||||
}
|
||||
|
||||
function createFinancials(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
periods: FinancialStatementPeriod[];
|
||||
statementRows?: SurfaceFinancialRow[];
|
||||
ratioRows?: RatioRow[];
|
||||
fiscalPack?: string | null;
|
||||
}) {
|
||||
return {
|
||||
company: {
|
||||
ticker: input.ticker,
|
||||
companyName: input.companyName,
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
displayModes: ['standardized'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: input.periods,
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: input.statementRows ?? []
|
||||
},
|
||||
statementDetails: null,
|
||||
ratioRows: input.ratioRows ?? [],
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
categories: [],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: input.periods.length,
|
||||
rows: input.statementRows?.length ?? 0,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: input.periods.length,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
normalization: {
|
||||
parserEngine: 'fiscal-xbrl',
|
||||
regime: 'unknown',
|
||||
fiscalPack: input.fiscalPack ?? null,
|
||||
parserVersion: '0.0.0',
|
||||
surfaceRowCount: 0,
|
||||
detailRowCount: 0,
|
||||
kpiRowCount: 0,
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
warnings: []
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
} satisfies CompanyFinancialStatementsResponse;
|
||||
}
|
||||
|
||||
describe('graphing series', () => {
|
||||
it('aligns multiple companies onto a union date axis', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'MSFT',
|
||||
financials: createFinancials({
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft',
|
||||
periods: [
|
||||
createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
|
||||
createPeriod({ id: 'msft-q2', filingId: 2, filingDate: '2025-04-28', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'msft-q1': 100, 'msft-q2': 120 })]
|
||||
})
|
||||
},
|
||||
{
|
||||
ticker: 'AAPL',
|
||||
financials: createFinancials({
|
||||
ticker: 'AAPL',
|
||||
companyName: 'Apple',
|
||||
periods: [
|
||||
createPeriod({ id: 'aapl-q1', filingId: 3, filingDate: '2025-02-02', periodEnd: '2025-01-31' }),
|
||||
createPeriod({ id: 'aapl-q2', filingId: 4, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'aapl-q1': 90, 'aapl-q2': 130 })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.chartData.map((entry) => entry.dateKey)).toEqual([
|
||||
'2024-12-31',
|
||||
'2025-01-31',
|
||||
'2025-03-31'
|
||||
]);
|
||||
expect(data.chartData[0]?.MSFT).toBe(100);
|
||||
expect(data.chartData[0]?.AAPL).toBeNull();
|
||||
expect(data.chartData[1]?.AAPL).toBe(90);
|
||||
});
|
||||
|
||||
it('preserves partial failures without blanking the whole chart', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'MSFT',
|
||||
financials: createFinancials({
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft',
|
||||
periods: [createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
|
||||
statementRows: [createStatementRow('revenue', { 'msft-q1': 100 })]
|
||||
})
|
||||
},
|
||||
{
|
||||
ticker: 'FAIL',
|
||||
error: 'Ticker not found'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.hasAnyData).toBe(true);
|
||||
expect(data.hasPartialData).toBe(true);
|
||||
expect(data.latestRows.find((row) => row.ticker === 'FAIL')?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('marks companies with missing metric values as no metric data', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'ratios',
|
||||
metric: 'gross_margin',
|
||||
results: [
|
||||
{
|
||||
ticker: 'NVDA',
|
||||
financials: createFinancials({
|
||||
ticker: 'NVDA',
|
||||
companyName: 'NVIDIA',
|
||||
periods: [createPeriod({ id: 'nvda-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
|
||||
ratioRows: [createRatioRow('gross_margin', { 'nvda-q1': null })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]?.status).toBe('no_metric_data');
|
||||
expect(data.hasAnyData).toBe(false);
|
||||
});
|
||||
|
||||
it('marks not meaningful standardized rows separately from missing metric data', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'gross_profit',
|
||||
results: [
|
||||
{
|
||||
ticker: 'JPM',
|
||||
financials: createFinancials({
|
||||
ticker: 'JPM',
|
||||
companyName: 'JPMorgan Chase & Co.',
|
||||
fiscalPack: 'bank_lender',
|
||||
periods: [createPeriod({ id: 'jpm-fy', filingId: 1, filingDate: '2026-02-13', periodEnd: '2025-12-31', filingType: '10-K' })],
|
||||
statementRows: [{
|
||||
...createStatementRow('gross_profit', { 'jpm-fy': null }),
|
||||
resolutionMethod: 'not_meaningful'
|
||||
}]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]).toMatchObject({
|
||||
ticker: 'JPM',
|
||||
fiscalPack: 'bank_lender',
|
||||
status: 'not_meaningful',
|
||||
errorMessage: 'Not meaningful for this pack.'
|
||||
});
|
||||
expect(data.hasAnyData).toBe(false);
|
||||
expect(data.hasPartialData).toBe(true);
|
||||
});
|
||||
|
||||
it('derives latest and prior values for the summary table', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'AMD',
|
||||
financials: createFinancials({
|
||||
ticker: 'AMD',
|
||||
companyName: 'AMD',
|
||||
periods: [
|
||||
createPeriod({ id: 'amd-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
|
||||
createPeriod({ id: 'amd-q2', filingId: 2, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'amd-q1': 50, 'amd-q2': 70 })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]).toMatchObject({
|
||||
ticker: 'AMD',
|
||||
fiscalPack: null,
|
||||
latestValue: 70,
|
||||
priorValue: 50,
|
||||
changeValue: 20,
|
||||
latestDateKey: '2025-03-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user