feat: fullscreen chart expand + market open/close vertical markers
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled

- Click 'Expand' button to view the trend chart fullscreen
- Press Escape or click outside to close
- Fullscreen uses full viewport height for better readability
- Green dashed vertical lines at 9:30 AM ET (market open)
- Red dashed vertical lines at 4:00 PM ET (market close)
- Markers only shown on intraday and 1d windows (hidden on 7d+)
- Chart content extracted into shared function to avoid duplication
This commit is contained in:
Celes Renata
2026-04-30 22:44:00 +00:00
parent 2f2ea65fb4
commit 1f08820f11
+125 -73
View File
@@ -1,5 +1,5 @@
import { useParams, useNavigate, Link } from '@tanstack/react-router'; import { useParams, useNavigate, Link } from '@tanstack/react-router';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
useCompany, useCompany,
useCompanySources, useCompanySources,
@@ -750,6 +750,34 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90
const hasPrice = chartData.some((pt) => pt.price != null); const hasPrice = chartData.some((pt) => pt.price != null);
// Compute market open/close vertical markers for intraday and 1d windows
const showMarketMarkers = selectedWindow === 'intraday' || selectedWindow === '1d';
const marketMarkers: { ts: number; label: string }[] = [];
if (showMarketMarkers && chartData.length > 0) {
const minTs = chartData[0].timestamp;
const maxTs = chartData[chartData.length - 1].timestamp;
// Walk each day in the range and compute 9:30 AM ET (open) and 4:00 PM ET (close)
const dayMs = 86400_000;
const startDay = new Date(minTs);
startDay.setUTCHours(0, 0, 0, 0);
for (let d = startDay.getTime(); d <= maxTs + dayMs; d += dayMs) {
const date = new Date(d);
const dow = date.getUTCDay();
if (dow === 0 || dow === 6) continue; // skip weekends
// ET offset: EDT = UTC-4, EST = UTC-5. Approximate with -4 (summer).
// 9:30 AM ET = 13:30 UTC (EDT)
const openTs = d + 13 * 3600_000 + 30 * 60_000;
// 4:00 PM ET = 20:00 UTC (EDT)
const closeTs = d + 20 * 3600_000;
if (openTs >= minTs && openTs <= maxTs) {
marketMarkers.push({ ts: openTs, label: 'Open' });
}
if (closeTs >= minTs && closeTs <= maxTs) {
marketMarkers.push({ ts: closeTs, label: 'Close' });
}
}
}
// Available windows from the data (check both history and latest) // Available windows from the data (check both history and latest)
const allTrends = [...(trends ?? []), ...(latestTrends ?? [])]; const allTrends = [...(trends ?? []), ...(latestTrends ?? [])];
const availableWindows = [...new Set(allTrends.filter((t) => t.entity_id === ticker).map((t) => t.window))]; const availableWindows = [...new Set(allTrends.filter((t) => t.entity_id === ticker).map((t) => t.window))];
@@ -761,40 +789,32 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90
.sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime()); .sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime());
const latest = latestForWindow[0] ?? (filtered.length > 0 ? filtered[filtered.length - 1] : null); const latest = latestForWindow[0] ?? (filtered.length > 0 ? filtered[filtered.length - 1] : null);
return ( const [fullscreen, setFullscreen] = useState(false);
<div className="space-y-4">
{/* Window selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Window:</span>
{(availableWindows.length > 0 ? availableWindows : WINDOW_ORDER).map((w) => (
<button
key={w}
onClick={() => onWindowChange(w)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
selectedWindow === w
? 'bg-brand-600 text-white'
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
>
{w}
</button>
))}
</div>
{chartData.length === 0 ? ( // Close fullscreen on Escape key
<Card> useEffect(() => {
<p className="text-sm text-gray-500">No trend history for {ticker} / {selectedWindow}</p> if (!fullscreen) return;
</Card> const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setFullscreen(false); };
) : ( window.addEventListener('keydown', handler);
<> return () => window.removeEventListener('keydown', handler);
{/* Trend Strength & Confidence Chart */} }, [fullscreen]);
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400"> // Shared chart content — rendered at different sizes
Trend Strength & Confidence {ticker} / {selectedWindow} const chartContent = (height: number) => (
</h2> <ResponsiveContainer width="100%" height={height}>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 40, left: 0 }}> <LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 40, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" /> <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
{marketMarkers.map((m, i) => (
<ReferenceLine
key={`market-${i}`}
x={m.ts}
stroke={m.label === 'Open' ? '#22c55e' : '#ef4444'}
strokeDasharray="4 4"
strokeWidth={1}
strokeOpacity={0.5}
label={{ value: m.label, position: 'top', fill: m.label === 'Open' ? '#22c55e' : '#ef4444', fontSize: 9 }}
/>
))}
<XAxis <XAxis
dataKey="timestamp" dataKey="timestamp"
type="number" type="number"
@@ -846,53 +866,85 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90
label={{ value: `90d Low $${range90d.low.toFixed(2)}`, position: 'insideBottomRight', fill: '#ef4444', fontSize: 10 }} label={{ value: `90d Low $${range90d.low.toFixed(2)}`, position: 'insideBottomRight', fill: '#ef4444', fontSize: 10 }}
/> />
)} )}
<Line <Line yAxisId="left" type="monotone" dataKey="strength" name="Trend Strength" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3, fill: '#3b82f6' }} activeDot={{ r: 5 }} />
yAxisId="left" <Line yAxisId="left" type="monotone" dataKey="confidence" name="Confidence" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} activeDot={{ r: 5 }} />
type="monotone" <Line yAxisId="left" type="monotone" dataKey="contradiction" name="Contradiction" stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="5 5" dot={{ r: 2, fill: '#f59e0b' }} />
dataKey="strength"
name="Trend Strength"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3, fill: '#3b82f6' }}
activeDot={{ r: 5 }}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="confidence"
name="Confidence"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3, fill: '#10b981' }}
activeDot={{ r: 5 }}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="contradiction"
name="Contradiction"
stroke="#f59e0b"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={{ r: 2, fill: '#f59e0b' }}
/>
{hasPrice && ( {hasPrice && (
<Line <Line yAxisId="right" type="monotone" dataKey="price" name="Price" stroke="#e879f9" strokeWidth={2} dot={{ r: 3, fill: '#e879f9' }} activeDot={{ r: 5 }} connectNulls />
yAxisId="right"
type="monotone"
dataKey="price"
name="Price"
stroke="#e879f9"
strokeWidth={2}
dot={{ r: 3, fill: '#e879f9' }}
activeDot={{ r: 5 }}
connectNulls
/>
)} )}
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
);
return (
<div className="space-y-4">
{/* Window selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Window:</span>
{(availableWindows.length > 0 ? availableWindows : WINDOW_ORDER).map((w) => (
<button
key={w}
onClick={() => onWindowChange(w)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
selectedWindow === w
? 'bg-brand-600 text-white'
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
>
{w}
</button>
))}
</div>
{chartData.length === 0 ? (
<Card>
<p className="text-sm text-gray-500">No trend history for {ticker} / {selectedWindow}</p>
</Card>
) : (
<>
{/* Trend Strength & Confidence Chart */}
<Card>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-400">
Trend Strength & Confidence {ticker} / {selectedWindow}
</h2>
<button
onClick={() => setFullscreen(true)}
className="rounded-md border border-surface-700 px-2 py-1 text-xs text-gray-400 hover:bg-surface-800 hover:text-gray-200"
title="Expand chart"
>
Expand
</button>
</div>
{chartContent(280)}
</Card> </Card>
{/* Fullscreen overlay */}
{fullscreen && (
<div
className="fixed inset-0 z-50 flex flex-col bg-surface-950/95 p-6"
onClick={(e) => { if (e.target === e.currentTarget) setFullscreen(false); }}
role="dialog"
aria-label="Expanded chart"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-200">
Trend Strength & Confidence {ticker} / {selectedWindow}
</h2>
<button
onClick={() => setFullscreen(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800 hover:text-gray-200"
>
Close
</button>
</div>
<div className="flex-1 min-h-0">
{chartContent(Math.max(400, window.innerHeight - 160))}
</div>
</div>
)}
{/* Direction Timeline */}
{/* Direction Timeline */} {/* Direction Timeline */}
<Card> <Card>
<h2 className="mb-3 text-sm font-medium text-gray-400"> <h2 className="mb-3 text-sm font-medium text-gray-400">