56 Commits

Author SHA1 Message Date
4313058d65 Fix P0 issues in financial ingestion architecture
Some checks failed
PR Checks / typecheck-and-build (pull_request) Has been cancelled
Taxonomy Sidecar / taxonomy-sidecar (pull_request) Has been cancelled
- Wrap snapshot updates in transactions with error context for each child table
- Add sidecar retry with exponential backoff (3 attempts, 2s base, 10s max, 30% jitter)
- Add HTTP timeout (30s per request) and SEC rate limiting (10 req/s) in Rust
- Add XBRL validation with status reporting (checks root element, tag balance)
2026-03-15 16:51:32 -04:00
edf1cfb421 Format surface_mapper tests 2026-03-15 15:24:24 -04:00
24aa8e33d4 Consolidate metric definitions with Rust JSON as single source of truth
- Add core.computed.json with 32 ratio definitions (filing + market derived)
- Add Rust types for ComputedDefinition and ComputationSpec
- Create generate-taxonomy.ts to generate TypeScript from Rust JSON
- Generate lib/generated/ (gitignored) with surfaces, computed, kpis
- Update financial-metrics.ts to use generated definitions
- Add build-time generation via 'bun run generate'
- Add taxonomy architecture documentation

Two-phase ratio computation:
- Filing-derived: margins, returns, per-share, growth (Rust computes)
- Market-derived: valuation ratios (TypeScript computes with price data)

All 32 ratios defined in core.computed.json:
- Margins: gross, operating, ebitda, net, fcf
- Returns: roa, roe, roic, roce
- Financial health: debt_to_equity, net_debt_to_ebitda, cash_to_debt, current_ratio
- Per-share: revenue, fcf, book_value
- Growth: yoy metrics + 3y/5y cagr
- Valuation: market_cap, ev, p/e, p/fcf, p/b, ev/sales, ev/ebitda, ev/fcf
2026-03-15 15:22:51 -04:00
ed4420b8db Add atomic task deduplication with partial unique index
- Add partial unique index for active resource-scoped tasks
- Implement createTaskRunRecordAtomic for race-free task creation
- Update findOrEnqueueTask to use atomic insert first
- Add tests for concurrent task creation deduplication
2026-03-15 14:40:38 -04:00
a7f7be50b4 Remove legacy TypeScript financial surface mapping, make Rust JSON single source of truth
- Delete standard-template.ts, surface.ts, materialize.ts (dead code)
- Delete financial-taxonomy.test.ts (relied on removed code)
- Add missing income statement surfaces to core.surface.json
- Add cost_of_revenue mapping to core.income-bridge.json
- Refactor standardize.ts to remove template dependency
- Simplify financial-taxonomy.ts to use only DB snapshots
- Add architecture documentation
2026-03-15 14:38:48 -04:00
7a42d73a48 Fix filing taxonomy schema mismatch by adding explicit column verification
The filing_taxonomy_snapshot table was missing parser_engine and related columns
on databases created before the taxonomy surface sidecar migration. This caused
filing sync workflows to fail with 'table has no column named parser_engine'.

Changes:
- Add TAXONOMY_SNAPSHOT_REQUIRED_COLUMNS constant for required columns
- Add verifyCriticalSchema() to fail fast at startup if schema is incompatible
- Reorder ensureTaxonomySnapshotCompat to check table existence before column ops
- Add explicit column verification after ALTER TABLE attempts
- Add regression tests for missing column detection

Fixes #2
2026-03-15 13:15:01 -04:00
529437c760 Stop substituting synthetic market data when providers fail
- Replace synthetic fallback in getQuote()/getPriceHistory() with null returns
- Add QuoteResult/PriceHistoryResult types with { value, stale } structure
- Implement stale-while-revalidate: return cached value with stale=true on live fetch failure
- Cache failures for 30s to avoid hammering provider
- Update CompanyAnalysis type to use PriceData<T> wrapper
- Update task-processors to track failed/stale tickers explicitly
- Update price-history-card UI to show unavailable state and stale indicator
- Add comprehensive tests for failure cases
- Add e2e tests for null data, stale data, and live data scenarios

Resolves #14
2026-03-14 23:37:12 -04:00
5b68333a07 add agent.md 2026-03-14 22:39:40 -04:00
69b45f35e3 Make coverage filing sync explicit 2026-03-14 19:54:59 -04:00
0d6c684227 Collapse filing sync notifications into one batch surface 2026-03-14 19:32:09 -04:00
61b072d31f Fix filings ticker scope consistency 2026-03-14 19:16:04 -04:00
ac3b036c93 Fix post-auth session handoff flow 2026-03-14 19:12:35 -04:00
b735b864d2 Fix SQLite taxonomy schema bootstrap drift 2026-03-14 19:00:29 -04:00
f5730597f4 Update e2e report with workflow failure root cause 2026-03-14 14:05:46 -04:00
f9bf1adb07 Add e2e and UX review report 2026-03-14 14:05:16 -04:00
72bf64aeec Merge branch 't3code/company-overview-loading-cache' 2026-03-13 19:05:25 -04:00
0394f4e795 Add company overview skeleton and cache 2026-03-13 19:05:17 -04:00
c4b3a9105f Merge branch 't3code/fix-financials-ingestion-spread-error' 2026-03-13 19:02:18 -04:00
30977dc15f Fix financial taxonomy snapshot normalization 2026-03-13 19:01:56 -04:00
a46777619a Add design system documentation 2026-03-13 15:00:17 -04:00
b1c9c0ef08 Merge branch 't3code/ui-yahoo-finance-description' 2026-03-13 00:28:41 -04:00
61282ec380 Use Yahoo Finance company descriptions 2026-03-13 00:28:24 -04:00
17f0613c7f Merge branch 't3code/fix-sp500-compare-gaps' 2026-03-13 00:25:49 -04:00
54172f9e8b Fix compare chart date alignment 2026-03-13 00:25:20 -04:00
1052bdfa85 Merge branch 't3code/investigate-unmapped-details' 2026-03-13 00:20:36 -04:00
e5141238fb WIP main worktree changes before merge 2026-03-13 00:20:22 -04:00
58bf80189d Use triangle markers for bull and bear items 2026-03-13 00:20:12 -04:00
34fa020eca Add bull and bear direction arrows 2026-03-13 00:17:43 -04:00
a3d4c97f4e Use sharp lines in comparison price chart 2026-03-13 00:15:19 -04:00
5998066524 Fix residual detail row aggregation 2026-03-13 00:14:30 -04:00
01199d489a Add untracked chart and schema files 2026-03-13 00:11:59 -04:00
8a8c4f7177 🎨 style: remove ambient grid background from app shells
Remove the decorative grid background layer from both the main app shell
and authentication shell to create a cleaner, more minimal UI appearance.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-12 21:33:16 -04:00
9d926e9710 Merge branch 't3code/improve-statement-parsing-duplicates' 2026-03-12 21:30:49 -04:00
c222179170 🎨 style(analysis): improve UI clarity and visual hierarchy
Enhance the company analysis overview page with better data presentation
and visual design:

- Fix business description display by filtering raw API data artifacts
- Improve metadata layout with consolidated single-line format
- Fix price chart Y-axis scaling to auto-scale to data range
- Replace 'n/a' with cleaner em dash (—) for empty states
- Add visual indicators and color-coded backgrounds to bull/bear sections
- Improve empty state messaging with centered icons

These changes improve information density, visual hierarchy, and overall
user experience across the analysis page.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-12 21:29:03 -04:00
1efbffa347 Commit remaining Rust parser updates 2026-03-12 21:17:37 -04:00
7a7a78340f Expand backend financial statement parsers 2026-03-12 21:15:54 -04:00
ba385586bc Rebuild company overview analysis page 2026-03-12 20:39:30 -04:00
b9a1d8ba40 Fix sidebar hydration flash 2026-03-12 16:29:28 -04:00
b39bc9eccd Refine sidebar collapsed and expanded spacing 2026-03-12 16:13:47 -04:00
df26299bdf Hide sidebar branding when collapsed 2026-03-12 15:47:22 -04:00
1b545cfffd feat(shell): add collapsible sidebar rail 2026-03-12 15:39:44 -04:00
33ce48f53c feat(financials): add compact surface UI and graphing states 2026-03-12 15:25:21 -04:00
c274f4d55b refactor(taxonomy): remove legacy parser and add rollout checks 2026-03-12 15:25:06 -04:00
58061af006 feat(taxonomy): add rust sidecar compact surface pipeline 2026-03-12 15:23:10 -04:00
f2c25fb9c6 Improve workflow error messaging 2026-03-09 23:51:37 -04:00
fa2de3e259 Improve financial statement value formatting 2026-03-09 18:58:15 -04:00
fae8c54121 Merge branch 't3code/improve-job-status-notification-details' 2026-03-09 18:54:02 -04:00
12a9741eca Improve job status notifications 2026-03-09 18:53:41 -04:00
9f972305e6 Fix annual financial selector and QCOM standardization 2026-03-09 18:50:59 -04:00
1a18ac825d Merge branch 't3code/clean-flat-ui-layout' 2026-03-08 22:48:45 -04:00
c3f3c3d5a9 Refresh app theme and update dependencies 2026-03-08 11:09:36 -04:00
2f7933f4a3 Improve Coolify deploy build caching 2026-03-08 10:43:55 -04:00
21246df434 Flatten dashboard sections and emphasize data regions 2026-03-08 10:38:27 -04:00
7a70545f09 Merge branch 't3code/expand-research-management-plan'
# Conflicts:
#	app/analysis/page.tsx
#	app/watchlist/page.tsx
#	components/shell/app-shell.tsx
#	lib/api.ts
#	lib/query/options.ts
#	lib/server/api/app.ts
#	lib/server/db/index.test.ts
#	lib/server/db/index.ts
#	lib/server/db/schema.ts
#	lib/server/repos/research-journal.ts
#	lib/types.ts
2026-03-07 20:39:49 -05:00
e20aba998b Add search and RAG workspace flows 2026-03-07 20:34:00 -05:00
62bacdf104 Add research workspace and graphing flows 2026-03-07 16:52:35 -05:00
254 changed files with 52126 additions and 5354 deletions

View File

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

View File

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

View 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
View 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
View File

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

View File

@@ -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
View 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`.

View File

@@ -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
View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()}

View File

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

View File

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

View File

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

808
bun.lock

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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.

View 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

View 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

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

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

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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',

View File

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

View File

@@ -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
View 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';

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

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

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

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

View File

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

View File

@@ -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',

View 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
View 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
View 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