summaryrefslogtreecommitdiff
path: root/frontend/lib
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/lib')
-rw-r--r--frontend/lib/overview.ts134
1 files changed, 134 insertions, 0 deletions
diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts
new file mode 100644
index 0000000..db30050
--- /dev/null
+++ b/frontend/lib/overview.ts
@@ -0,0 +1,134 @@
+import { fmtCurrency, fmtLarge, fmtNumber, fmtPct } from "@/lib/format";
+import type { MarketIndex, Signal, TickerOverview, WatchlistItem } from "@/types/api";
+
+export type KpiItem = {
+ key: string;
+ value: string;
+ sublabel: string;
+ missing?: boolean;
+};
+
+export type NavItem = {
+ key: string;
+ label: string;
+ icon: string;
+ disabled?: boolean;
+};
+
+export const OVERVIEW_NAV_ITEMS: NavItem[] = [
+ { key: "overview", label: "Overview", icon: "chart" },
+ { key: "financials", label: "Financials", icon: "ledger", disabled: true },
+ { key: "valuation", label: "Valuation", icon: "dollar", disabled: true },
+ { key: "options", label: "Options", icon: "window", disabled: true },
+ { key: "insiders", label: "Insiders", icon: "pulse", disabled: true },
+ { key: "filings", label: "Filings", icon: "folder", disabled: true },
+ { key: "news", label: "News", icon: "terminal", disabled: true }
+];
+
+export function buildIdentityLine(overview: TickerOverview): string {
+ const parts = [overview.profile.sector, overview.profile.industry, overview.profile.exchange].filter(Boolean);
+ return parts.length ? parts.join(" · ") : "Profile details unavailable";
+}
+
+export function buildKpis(overview: TickerOverview): KpiItem[] {
+ return [
+ {
+ key: "Market Cap",
+ value: fmtLarge(overview.stats.market_cap),
+ sublabel: `${fmtNumber(overview.stats.volume, 0)} volume`,
+ missing: overview.stats.market_cap == null
+ },
+ {
+ key: "P / E",
+ value: overview.stats.trailing_pe == null ? "Unavailable" : `${fmtNumber(overview.stats.trailing_pe)}x`,
+ sublabel: `EPS ${fmtCurrency(overview.stats.trailing_eps)}`,
+ missing: overview.stats.trailing_pe == null
+ },
+ {
+ key: "EPS · TTM",
+ value: fmtCurrency(overview.stats.trailing_eps),
+ sublabel: `Prev close ${fmtCurrency(overview.quote.prev_close)}`,
+ missing: overview.stats.trailing_eps == null
+ },
+ {
+ key: "52W Position",
+ value: formatRangePosition(overview),
+ sublabel: `${fmtCurrency(overview.range_52w.low)} to ${fmtCurrency(overview.range_52w.high)}`,
+ missing: rangePercent(overview) == null
+ },
+ {
+ key: "Short Float",
+ value: fmtPct(overview.short_interest.short_percent_of_float),
+ sublabel: `${fmtNumber(overview.short_interest.short_ratio)} days cover`,
+ missing: overview.short_interest.short_percent_of_float == null
+ },
+ {
+ key: "Beta",
+ value: fmtNumber(overview.stats.beta),
+ sublabel: `Avg vol ${fmtNumber(overview.stats.average_volume, 0)}`,
+ missing: overview.stats.beta == null
+ }
+ ];
+}
+
+export function formatRangePosition(overview: TickerOverview): string {
+ const pct = rangePercent(overview);
+ if (pct == null) return "Unavailable";
+ return `${pct.toFixed(0)}%`;
+}
+
+export function rangePercent(overview: TickerOverview): number | null {
+ const low = overview.range_52w.low;
+ const high = overview.range_52w.high;
+ const price = overview.range_52w.price ?? overview.quote.price;
+ if (low == null || high == null || price == null || high <= low) return null;
+ return Math.max(0, Math.min(100, ((price - low) / (high - low)) * 100));
+}
+
+export function unavailableFields(overview: TickerOverview): string[] {
+ return Object.entries(overview.meta.field_availability)
+ .filter(([, available]) => !available)
+ .map(([field]) => field);
+}
+
+export function availableFieldSummary(overview: TickerOverview): string {
+ const fields = Object.values(overview.meta.field_availability);
+ if (!fields.length) return "Availability metadata unavailable";
+ const available = fields.filter(Boolean).length;
+ return `${available}/${fields.length} tracked fields available`;
+}
+
+export function watchlistSubtitle(item: WatchlistItem): string {
+ return new Date(item.created_at).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric"
+ });
+}
+
+export function sortIndices(indices: MarketIndex[]): MarketIndex[] {
+ return [...indices].slice(0, 4);
+}
+
+export function signalTone(signal: Signal["state"]): "pos" | "warn" | "neg" | "neu" {
+ return signal;
+}
+
+export function marketClock(now = new Date()) {
+ const eastern = new Date(now.toLocaleString("en-US", { timeZone: "America/New_York" }));
+ const day = eastern.getDay();
+ const minutes = eastern.getHours() * 60 + eastern.getMinutes();
+ const isWeekday = day >= 1 && day <= 5;
+ const open = isWeekday && minutes >= 570 && minutes < 960;
+
+ return {
+ isOpen: open,
+ status: open ? "US Market Open" : "US Market Closed",
+ time: new Intl.DateTimeFormat("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ timeZone: "America/New_York",
+ timeZoneName: "short"
+ }).format(now)
+ };
+}
+