| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { request } from "../lib/portfolio-api"; |
| 5 | |
| 6 | export type Opportunity = { |
| 7 | global_instrument_id: string; company_name?: string; symbol?: string; current_price: number | null; |
| 8 | opportunity_score: number | null; opportunity_confidence: number; score_coverage: number; |
| 9 | new_investor_action: string; existing_holder_action: string; current_short_action: string; current_long_action: string; |
| 10 | short_entry_low: number | null; short_entry_high: number | null; short_target_1: number | null; |
| 11 | short_target_2: number | null; short_invalidation: number | null; long_entry_low: number | null; |
| 12 | long_entry_high: number | null; long_fair_value: number | null; long_target: number | null; long_invalidation: number | null; |
| 13 | top_positive_reasons: string[]; top_negative_reasons: string[]; data_state: string; |
| 14 | missing_areas: string[]; stale_areas: string[]; short_horizon: string; long_horizon: string; |
| 15 | short_term_state: string; long_term_state: string; lifecycle_reasons?: string[]; evaluation_status?: string; |
| 16 | }; |
| 17 | export type Radar = { generated_at: string | null; best_buy_today: Opportunity | null; |
| 18 | top_short_term: Opportunity[]; top_long_term: Opportunity[]; previous_recommendations: Opportunity[] }; |
| 19 | |
| 20 | export function price(value: number | null | undefined): string { |
| 21 | return value == null ? "Insufficient evidence" : `₹${value.toLocaleString("en-IN", { maximumFractionDigits: 2 })}`; |
| 22 | } |
| 23 | function range(low: number | null, high: number | null): string { |
| 24 | return low == null || high == null ? "Insufficient evidence" : `${price(low)} – ${price(high)}`; |
| 25 | } |
| 26 | function label(value: string | undefined) { return value?.replaceAll("_", " ") ?? "Unavailable"; } |
| 27 | |
| 28 | export function OpportunityCard({ item, horizon, held, watchlisted }: { |
| 29 | item: Opportunity; horizon: "short" | "long"; held: boolean; watchlisted: boolean; |
| 30 | }) { |
| 31 | return <article className="opportunity-card"> |
| 32 | <h4>{item.company_name ?? item.symbol} <small>{item.symbol}</small></h4> |
| 33 | <p>{price(item.current_price)} {held ? " · Held" : ""}{watchlisted ? " · Watchlisted" : ""}</p> |
| 34 | <p>Opportunity {item.opportunity_score?.toFixed(1) ?? "Unavailable"} · Confidence {item.opportunity_confidence.toFixed(1)}% · Coverage {item.score_coverage.toFixed(1)}%</p> |
| 35 | <p>New investor: {label(item.new_investor_action)}<br />Existing holder: {label(item.existing_holder_action)}</p> |
| 36 | <strong>{label(horizon === "short" ? item.current_short_action : item.current_long_action)}</strong> |
| 37 | <dl>{horizon === "short" ? <> |
| 38 | <dt>Entry range</dt><dd>{range(item.short_entry_low, item.short_entry_high)}</dd> |
| 39 | <dt>Target 1</dt><dd>{price(item.short_target_1)}</dd><dt>Target 2</dt><dd>{price(item.short_target_2)}</dd> |
| 40 | <dt>Invalidation</dt><dd>{price(item.short_invalidation)}</dd><dt>Horizon</dt><dd>{item.short_horizon}</dd> |
| 41 | </> : <> |
| 42 | <dt>Accumulation range</dt><dd>{range(item.long_entry_low, item.long_entry_high)}</dd> |
| 43 | <dt>Fair value</dt><dd>{price(item.long_fair_value)}</dd><dt>Target</dt><dd>{price(item.long_target)}</dd> |
| 44 | <dt>Invalidation</dt><dd>{price(item.long_invalidation)}</dd><dt>Horizon</dt><dd>{item.long_horizon}</dd> |
| 45 | </>}</dl> |
| 46 | <p><strong>Why</strong></p><ul>{item.top_positive_reasons.slice(0, 4).map(r => <li key={r}>{label(r)}</li>)}</ul> |
| 47 | <p><strong>Risks</strong></p><ul>{item.top_negative_reasons.slice(0, 3).map(r => <li key={r}>{label(r)}</li>)}</ul> |
| 48 | <details><summary>Data: {label(item.data_state)}</summary> |
| 49 | <p>Missing: {item.missing_areas.join(", ") || "None reported"}</p><p>Stale: {item.stale_areas.join(", ") || "None reported"}</p> |
| 50 | </details> |
| 51 | </article>; |
| 52 | } |
| 53 | |
| 54 | export function RadarContent({ data, heldIds = [], watchlistedIds = [] }: { data: Radar; heldIds?: string[]; watchlistedIds?: string[] }) { |
| 55 | const card = (item: Opportunity, horizon: "short" | "long") => <OpportunityCard key={item.global_instrument_id} item={item} horizon={horizon} |
| 56 | held={heldIds.includes(item.global_instrument_id)} watchlisted={watchlistedIds.includes(item.global_instrument_id)} />; |
| 57 | return <> |
| 58 | <p>{data.generated_at ? `Last cycle: ${new Date(data.generated_at).toLocaleString()}` : "No persisted opportunity cycle yet."}</p> |
| 59 | <h3>BEST BUY TODAY</h3> |
| 60 | {data.best_buy_today ? <p>{data.best_buy_today.company_name ?? data.best_buy_today.symbol} · {price(data.best_buy_today.current_price)} · {label(data.best_buy_today.new_investor_action)}</p> : <p>No qualifying buy candidate.</p>} |
| 61 | <h3>TOP SHORT-TERM OPPORTUNITIES</h3> |
| 62 | <div className="opportunity-cards">{data.top_short_term.map(item => card(item, "short"))}</div> |
| 63 | {!data.top_short_term.length && <p>No qualifying short-term opportunities.</p>} |
| 64 | <h3>TOP LONG-TERM OPPORTUNITIES</h3> |
| 65 | <div className="opportunity-cards">{data.top_long_term.map(item => card(item, "long"))}</div> |
| 66 | {!data.top_long_term.length && <p>No qualifying long-term opportunities.</p>} |
| 67 | <h3>PREVIOUS RECOMMENDATIONS</h3> |
| 68 | {!data.previous_recommendations.length ? <p>No previous recommendations.</p> : <ul>{data.previous_recommendations.map(item => |
| 69 | <li key={item.global_instrument_id}>{item.company_name ?? item.symbol} · Short: {label(item.current_short_action)} ({label(item.short_term_state)}) · Long: {label(item.current_long_action)} ({label(item.long_term_state)}) |
| 70 | <p>{item.lifecycle_reasons?.map(label).join(" · ")}{item.evaluation_status ? ` · ${label(item.evaluation_status)}` : ""}</p> |
| 71 | </li>)}</ul>} |
| 72 | </>; |
| 73 | } |
| 74 | |
| 75 | export function OpportunityRadar({ heldIds, watchlistedIds }: { heldIds: string[]; watchlistedIds: string[] }) { |
| 76 | const [data, setData] = useState<Radar | null>(null); |
| 77 | const [error, setError] = useState(false); |
| 78 | useEffect(() => { |
| 79 | let active = true; |
| 80 | request<Radar>("/api/v1/research/opportunities/current").then(value => { if (active) setData(value); }).catch(() => { if (active) setError(true); }); |
| 81 | return () => { active = false; }; |
| 82 | }, []); |
| 83 | return <section className="opportunity-radar" aria-label="Global opportunity radar"> |
| 84 | <h2>GLOBAL OPPORTUNITY RADAR</h2> |
| 85 | {error ? <p role="alert">Opportunity radar unavailable.</p> : data ? <RadarContent data={data} heldIds={heldIds} watchlistedIds={watchlistedIds} /> : <p>Loading persisted opportunities…</p>} |
| 86 | </section>; |
| 87 | } |