Skip to content

Frontend

The frontend is a Vite + React 18 SPA that consumes the Ogur REST API. Source lives in frontend/.

This doc is a technical overview. For visual language, component contracts, badge system, confidence composites, and the "what goes on which page" authority, see design/ux-spec.md. If something below conflicts with the UX spec, the UX spec wins.


Stack

Layer Choice Why
Bundler Vite 5 Fast cold starts, first-class TS, /api dev proxy
Framework React 18 + TypeScript 5 Team familiarity
Routing react-router-dom 6 Client-side routing, nested routes for Asset Detail tabs
Server state TanStack Query 5 Cache, polling, background refetch, SSR-ready
Client state Zustand 4 Inspector panel state, UI-only — not persisted
Virtualization TanStack Virtual 3 Large signal lists (~1,500 rows)
Styling Tailwind 3 Design tokens → utility classes
Components shadcn/ui (Radix primitives) Dialog, Tabs, Tooltip, ScrollArea, Collapsible
Animation Framer Motion 11 Inspector slide-in, tab transitions
Charts Recharts 2 Trial timelines, phase distribution
Icons lucide-react Consistent stroke weight
Command palette cmdk ⌘K anywhere → command search
Dates date-fns Timezone-safe, tree-shakeable

Full manifest: frontend/package.json.


File layout

frontend/
  index.html
  vite.config.ts          ← /api/* → http://localhost:8000 proxy
  tailwind.config.ts
  tsconfig.json
  src/
    main.tsx              ← Entry, TanStack Query client, router
    App.tsx               ← Route tree + top-level layout
    index.css             ← Tailwind directives + design tokens
    api/                  ← One hook per endpoint (useBriefing, useSignals, useAsk, …)
      client.ts           ← fetch wrapper, shared error handling
      useSignals.ts
      useBriefing.ts
      useAssetBriefing.ts
      useAssetOverview.ts
      useAssetTrials.ts
      useAssetCompetitive.ts
      useAsk.ts
    components/
      ask/                ← AskPanel, CitationChip
      asset/              ← LandscapeTab (shared across tabs)
      briefing/           ← BriefingDocument, EntityChip, ParsedProse,
                            PredictionCard, SignalAnalysisCard, KIQAnswersSection
      portfolio/          ← AssetTable, FranchiseAssetTable
      shell/              ← CommandPalette, Inspector, LeftRail, TopBar
      signals/            ← SignalRow, SourceFilterChips, TimelineScrubber
    lib/                  ← cn, entity-parser (legacy regex), entity-registry,
                            entity-resolver, severity helpers
    mocks/                ← Static data for offline / demo mode
    store/
      inspector.ts        ← Zustand store for the right-rail Inspector
    types/
      index.ts            ← Shared TS types
    views/                ← One per top-level route
      Portfolio.tsx
      FranchisePortfolio.tsx
      AssetDetail.tsx     ← 6 tabs per ux-spec.md
      GlobalSignals.tsx
      GlobalAsk.tsx
      Deals.tsx

Top-level views

The shell entry point is <ModeSelector> at /, which routes the analyst to one of two modes — Monitor (continuous franchise watching) or Explore (one-shot landscape mapping). The two modes wrap the principle of "do not hide synthesis behind a chatbot" differently — see design/ux-spec.md §1.5 for the full distinction.

View Route (App.tsx) Mode Purpose
Mode selector / Two-tile entry point: Monitor / Explore
Explore /explore Explore Two tabs: Workspaces (saved landscapes + new-research wizard) and Asset catalog (browseable drug table)
Franchise portfolio /franchise/:id Monitor Pre-seeded franchise view — LOE calendar, competitive threats, live signals
Asset Detail /asset/:drug Monitor 6 tabs: Overview, Trials, Competitive, Landscape, Evidence, Ask
Global Signals /signals Monitor Timeline-scrubbed feed of every signal across landscapes
Global Ask /ask Monitor Free-form Q&A against /api/ask
Deals /deals Monitor Licensing / M&A / investment signals filtered from SEC EDGAR

Backend caveat for Explore. The intelligence engine is tuned for Monitor mode (pre-seeded landscapes). Explore mode currently runs on frontend mock data — see architecture.md §1. Treat frontend/src/mocks/explore.ts as the source of truth for Explore views until the candidate-retrieval stage is built.


The Inspector pattern

A 380 px right rail that context-switches content based on the selected object (drug / trial / signal / company). Driven by a single Zustand store at store/inspector.ts:

type InspectorState =
  | { kind: "drug"; drugName: string }
  | { kind: "trial"; nctId: string }
  | { kind: "signal"; signalId: string }
  | { kind: "company"; normalizedName: string }
  | null;

Clicking any EntityChip in a briefing swaps the Inspector content without navigating away. This is the key interaction pattern in the UX spec — users stay in flow while drilling into provenance.


Entity rendering — structured refs vs. legacy regex

Briefings now carry structured entity references alongside prose. Each signal_analyses[] and predictions[] entry can include optional drug_id, company_id, target_id, trial_nct_id fields, and kiq_answers[].evidence_refs[] is a list of those same shapes. The synthesizer emits IDs from the ENTITY CATALOG (see architecture.md §4.4).

Two paths render entities, picked per claim:

  1. Structured path (preferred). When a card has any of the four ID fields populated, entity-registry.ts resolves them:
ID prefix Routes to
drug-<slug> Asset Detail page if the drug is in useAssetIndex(); else Inspector
co-<slug> Inspector → company panel
tgt-<slug> Inspector → target panel
NCT\d{8} External: https://clinicaltrials.gov/study/{nct}

The card renders a chip row above the prose — <EntityChip resolved={…} /> — and clicks dispatch via react-router-dom href, external <a>, or the Zustand inspector store depending on ResolvedEntity.type.

  1. Legacy regex path. Falls back when no IDs are emitted (older briefings, or claims the synthesizer left untyped). entity-parser.ts parses the prose itself, finds drug/trial mentions, and renders <EntityChip text={…} entity={…} />. This path is on the deprecation list — it's fragile (regex over prose) and the structured emitter has been live for several pipeline runs.

<EntityChip> (components/briefing/EntityChip.tsx) discriminates the two modes by prop shape. PredictionCard and SignalAnalysisCard resolve all four ID fields, render the chip row if any resolved, and fall back to ParsedProse for the body.

KIQ Answers

KIQAnswersSection.tsx renders the kiq_answers[] block. One card per active KIQ, each with:

  • The KIQ question as a header
  • finding, evidence, uncertainty, implication prose blocks
  • A <ConfidenceBadge> (high / medium / low)
  • A row of <EntityChip>s for evidence_refs[] — uses the same structured-path resolver as above

Drug briefings render only drug-specific KIQs (those keyed on the drug-composite landscape ID); class-level KIQs appear only in landscape briefings. See architecture.md §2 — Path C.


Section comments (OGUR-72)

Three ways to leave a comment on a report section, all writing the same (variant, section_id)-anchored feedback row:

Affordance Where Notes
Trigger in the section heading SectionComments.tsx Always rendered — a bordered Comment pill, not a hover-only glyph. Shows a count once the section has comments.
c useSectionCommentShortcut.ts Opens the composer for the section under the reading line (a third of the way down the viewport), resolved from the DOM at press time. Ignored while typing and under any modifier, so ⌘C stays copy.
End-of-section nudge SectionCommentNudge.tsx An IntersectionObserver sentinel at each section's end offers a comment once the reader finishes it.

Below the document there are two boxes, not one: <ReportFeedback> for general feedback, and <SectionCommentsList> for every section comment on the report (all variants, grouped by section, with a jump link where the section is on screen). They partition the server list — no row on both surfaces, none on neither. Both are <CollapsibleCard>s (components/ui/), open by default and carrying their count in the header so a collapsed box still says what is in it.

The nudge is deliberately restrained: once per section per rendered document (the store resets on variant or landscape change, so it is not once per session), never over an open composer, never on a section already commented on — and only once that section's comment list has settled, so the offer cannot appear and then retract. Auto-retired after 9s. Open/nudge state is a small zustand store (store/sectionComments.ts) because the shortcut and the nudge both open a popover they do not own.

Data fetching

Every backend endpoint has a matching TanStack Query hook in src/api/. Convention:

export function useBriefing(landscapeId: string) {
  return useQuery({
    queryKey: ["briefing", landscapeId],
    queryFn: () => client.get<BriefingOut>(`/api/briefing/${landscapeId}`),
    staleTime: 60_000,  // 1 min
  });
}

POST endpoints use useMutation — see useFeedback.ts or useLogin.ts. The briefing/analyzer trigger mutations and useTriggerBriefing.ts were removed in OGUR-76 along with the unauthenticated, unmetered generation routes they called; briefings are produced offline.

The client.ts wrapper: - Handles JSON parsing + error surfacing - Throws on non-2xx so TanStack Query populates error - Session-cookie auth: useCurrentUser.ts reads /api/auth/me (with retry: false — load-bearing, a retry-scheduled query pauses forever when hidden/offline), useLogin.ts posts credentials, RequireAuth.tsx gates the routed surfaces


Dev workflow

make dev              # backend, terminal 1 → :8000
make frontend         # frontend, terminal 2 → :5173

/api/* requests from the frontend proxy to :8000 via frontend/vite.config.ts. There is no CORS setup in production because we assume same-origin deployment.

Running against mock data

For demos / offline work, each view can import from src/mocks/. This is done by swapping the hook inside the view file — not driven by an env var. Cleaner separation is on the backlog.


What's built since this doc was first written (MVP-1, v1.1.0)

  • Auth/session. Login page, RequireAuth route guard, useCurrentUser / useLogin hooks, sign-out (OGUR-64, #241/#256). Cross-client access to an ungranted landscape renders a 404 state, not an error toast (OGUR-77).
  • Report-pack surfaces. Served landscape reports and figures (useExploreReport), evidence-depth banner (OGUR-78), whole-report feedback box with explicit states (OGUR-71, #251).
  • Demo-surface gate. The default client build ships with all mock/demo surfaces off; VITE_DEMO_SURFACES=true restores them (OGUR-70, #253).
  • Frontend tests. 368 Vitest tests across 37 files, run in CI (npm test → Vitest; npm run build typechecks).

What's not built (yet)

  • No error boundary strategy beyond TanStack's default. A 500 from the backend surfaces as a toast; no retry UI.
  • No analytics instrumentation. Unplanned.