diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 76fdb74..3de648a 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -16,8 +16,7 @@ "stop": [ { "command": ".cursor/hooks/stop-quality-check.sh", - "timeout": 600, - "loop_limit": 0 + "timeout": 600 } ] } diff --git a/.cursor/hooks/after-file-edit.sh b/.cursor/hooks/after-file-edit.sh index cd8e797..cdadaf6 100755 --- a/.cursor/hooks/after-file-edit.sh +++ b/.cursor/hooks/after-file-edit.sh @@ -4,6 +4,9 @@ set -euo pipefail +# Cursor hook processes often inherit a minimal PATH; bun is usually in ~/.bun/bin. +export PATH="${HOME}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:${PATH:-}" + input=$(cat) file_path=$( printf '%s' "$input" | python3 -c "import sys, json; print(json.load(sys.stdin).get('file_path', ''))" diff --git a/.cursor/hooks/stop-quality-check.sh b/.cursor/hooks/stop-quality-check.sh index ea07395..736558d 100755 --- a/.cursor/hooks/stop-quality-check.sh +++ b/.cursor/hooks/stop-quality-check.sh @@ -3,6 +3,9 @@ set -euo pipefail +# Cursor hook processes often inherit a minimal PATH; bun is usually in ~/.bun/bin. +export PATH="${HOME}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:${PATH:-}" + cat >/dev/null project_root="${CURSOR_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" diff --git a/app/(main)/home-page-client.tsx b/app/(main)/home-page-client.tsx index 64f33b7..69d5178 100644 --- a/app/(main)/home-page-client.tsx +++ b/app/(main)/home-page-client.tsx @@ -1,7 +1,7 @@ "use client"; import dynamic from "next/dynamic"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { SymbolData, @@ -23,6 +23,12 @@ import { EXPLANATIONS_PROVIDER_CHANGED_EVENT } from "@/lib/explanation-provider" import { fetchAIPredictionForCurrentProvider } from "@/lib/local-ollama-ai-prediction"; import { fetchStockOfTheDayForCurrentProvider } from "@/lib/local-ollama-stock-of-the-day"; import { MARKET_UI_COPY } from "@/lib/market-ui-copy"; +import { + fetchHistoricalData, + fetchPrimarySymbolData, + fetchSecondarySymbolData, +} from "@/lib/home-page-symbol-fetch"; +import { normalizeMarketSymbol } from "@/lib/market-symbol"; import { AIPredictionPanel } from "@/components/AIPredictionPanel"; import { HomeHub } from "@/components/HomeHub"; import { StockOfTheDayPanel } from "@/components/StockOfTheDayPanel"; @@ -122,6 +128,8 @@ export function HomePageClient() { const [activeTab, setActiveTab] = useState("overview"); const [symbolData, setSymbolData] = useState(null); const [historicalData, setHistoricalData] = useState([]); + const [loadedHistoryRange, setLoadedHistoryRange] = useState("1M"); + const [historyLoading, setHistoryLoading] = useState(false); const [timeRange, setTimeRange] = useState("1M"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -147,11 +155,7 @@ export function HomePageClient() { ); useEffect(() => { - if (symbolFromUrl && symbolFromUrl.trim()) { - setSelectedSymbol(symbolFromUrl.trim().toUpperCase()); - } else { - setSelectedSymbol(null); - } + setSelectedSymbol(normalizeMarketSymbol(symbolFromUrl)); }, [symbolFromUrl]); const clearSymbol = () => { @@ -159,77 +163,81 @@ export function HomePageClient() { router.replace("/", { scroll: false }); }; - useEffect(() => { - const fetchSymbolData = async () => { - if (!selectedSymbol) return; - - setLoading(true); - setError(null); + const loadedSymbolRef = useRef(null); - try { - const symbolResponse = await fetch( - `/api/market/symbol/${selectedSymbol}` - ); - if (!symbolResponse.ok) { - const body = (await symbolResponse.json().catch(() => ({}))) as { - error?: string; - }; - throw new Error(body.error ?? MARKET_UI_COPY.load.symbolData); - } - const symbolResult = await symbolResponse.json(); - setSymbolData(symbolResult.data); - - const historicalResponse = await fetch( - `/api/market/historical/${selectedSymbol}?range=${timeRange}` - ); - if (!historicalResponse.ok) { - throw new Error(MARKET_UI_COPY.load.historicalData); - } - const historicalResult = await historicalResponse.json(); - setHistoricalData(historicalResult.data); + useEffect(() => { + if (!selectedSymbol) { + loadedSymbolRef.current = null; + return; + } - const indicatorsResponse = await fetch( - `/api/market/indicators/${selectedSymbol}` - ); - if (indicatorsResponse.ok) { - const indicatorsResult = await indicatorsResponse.json(); - setTechnicalIndicators(indicatorsResult.data); + let cancelled = false; + const symbolChanged = loadedSymbolRef.current !== selectedSymbol; + loadedSymbolRef.current = selectedSymbol; + + const load = async () => { + if (symbolChanged) { + setLoading(true); + setError(null); + setSymbolData(null); + setHistoricalData([]); + setTechnicalIndicators(null); + setForecastData(null); + setSeasonalData(null); + setFinancialData(null); + + try { + const primary = await fetchPrimarySymbolData( + selectedSymbol, + timeRange + ); + if (cancelled) return; + setSymbolData(primary.symbolData); + setHistoricalData(primary.historicalData); + setLoadedHistoryRange(timeRange); + } catch (err) { + if (cancelled) return; + console.error("Error fetching symbol data:", err); + setError( + err instanceof Error ? err.message : MARKET_UI_COPY.load.symbolData + ); + } finally { + if (!cancelled) setLoading(false); } - const forecastResponse = await fetch( - `/api/market/forecast/${selectedSymbol}` - ); - if (forecastResponse.ok) { - const forecastResult = await forecastResponse.json(); - setForecastData(forecastResult.data); + try { + const secondary = await fetchSecondarySymbolData(selectedSymbol); + if (cancelled) return; + setTechnicalIndicators(secondary.technicalIndicators); + setForecastData(secondary.forecastData); + setSeasonalData(secondary.seasonalData); + setFinancialData(secondary.financialData); + } catch (err) { + console.warn("Error fetching secondary symbol data:", err); } + return; + } - const seasonalResponse = await fetch( - `/api/market/seasonal/${selectedSymbol}` - ); - if (seasonalResponse.ok) { - const seasonalResult = await seasonalResponse.json(); - setSeasonalData(seasonalResult.data); - } + setHistoryLoading(true); + setHistoricalData([]); - const financialsResponse = await fetch( - `/api/market/financials/${selectedSymbol}` - ); - if (financialsResponse.ok) { - const financialsResult = await financialsResponse.json(); - setFinancialData(financialsResult.data); - } + try { + const historical = await fetchHistoricalData(selectedSymbol, timeRange); + if (cancelled) return; + setHistoricalData(historical); + setLoadedHistoryRange(timeRange); } catch (err) { - console.error("Error fetching symbol data:", err); - setError( - err instanceof Error ? err.message : MARKET_UI_COPY.load.symbolData - ); + console.warn("Error fetching historical data for range:", err); } finally { - setLoading(false); + if (!cancelled) setHistoryLoading(false); } }; - fetchSymbolData(); + void load(); + + return () => { + cancelled = true; + }; }, [selectedSymbol, timeRange]); useEffect(() => { @@ -410,6 +418,8 @@ export function HomePageClient() { symbolData={symbolData} historicalData={historicalData} timeRange={timeRange} + dataTimeRange={loadedHistoryRange} + historyLoading={historyLoading} onTimeRangeChange={handleTimeRangeChange} /> )} diff --git a/app/globals.css b/app/globals.css index 011a40f..bd4a613 100644 --- a/app/globals.css +++ b/app/globals.css @@ -43,3 +43,348 @@ h6 { display: none; } } + +/* Revolut-style chart sparkle — full-width ambient splash (area charts) */ +@keyframes chart-sparkle-sweep { + 0% { + transform: translateX(-130%) skewX(-14deg); + opacity: 0; + } + 12% { + opacity: 1; + } + 88% { + opacity: 1; + } + 100% { + transform: translateX(130%) skewX(-14deg); + opacity: 0; + } +} + +@keyframes chart-sparkle-flash { + 0%, + 100% { + opacity: 0.2; + } + 50% { + opacity: 0.45; + } +} + +.chart-sparkle-sweep { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + mix-blend-mode: screen; + opacity: 0; + z-index: 4; +} + +.chart-sparkle-sweep--loading { + opacity: 1; + /* Duration/loops set inline from chart-effects; fallback matches 1200ms × 1 */ + animation: chart-sparkle-sweep 1200ms ease-in-out 1; +} + +.chart-sparkle-sweep--up { + background: linear-gradient( + 100deg, + transparent 38%, + rgba(52, 211, 153, 0.08) 44%, + rgba(167, 243, 208, 0.42) 50%, + rgba(52, 211, 153, 0.08) 56%, + transparent 62% + ); +} + +.chart-sparkle-sweep--down { + background: linear-gradient( + 100deg, + transparent 38%, + rgba(244, 63, 94, 0.08) 44%, + rgba(253, 164, 175, 0.42) 50%, + rgba(244, 63, 94, 0.08) 56%, + transparent 62% + ); +} + +.chart-sparkle-flash { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + z-index: 3; +} + +.chart-sparkle-flash--loading { + opacity: 1; + animation: chart-sparkle-flash 1200ms ease-in-out 1; +} + +.chart-sparkle-flash--up { + background: radial-gradient( + ellipse 35% 25% at 92% 22%, + rgba(16, 185, 129, 0.12) 0%, + rgba(16, 185, 129, 0.04) 45%, + transparent 70% + ); +} + +.chart-sparkle-flash--down { + background: radial-gradient( + ellipse 35% 25% at 92% 22%, + rgba(244, 63, 94, 0.12) 0%, + rgba(244, 63, 94, 0.04) 45%, + transparent 70% + ); +} + +/* TradingView-style magnifier tooltip — frosted card along top edge */ +.chart-magnifier-tooltip { + padding: 8px 10px; + border-radius: 6px 6px 0 0; + border-bottom: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + background: rgba(255, 255, 255, 0.88); + color: #1c1917; +} + +.dark .chart-magnifier-tooltip, +.chart-magnifier-tooltip--dark { + background: rgba(28, 25, 23, 0.85); + color: #fafaf9; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); +} + +.chart-magnifier-tooltip__date { + opacity: 0.72; +} + +/* Revolut-style chart glow: scrub beam + live flash */ + +@keyframes chart-scrub-point-pulse { + 0%, + 100% { + transform: translate(-50%, -50%) scale(0.92); + opacity: 0.85; + } + 50% { + transform: translate(-50%, -50%) scale(1.08); + opacity: 1; + } +} + +@keyframes chart-scrub-beam-breathe { + 0%, + 100% { + opacity: 0.82; + } + 50% { + opacity: 1; + } +} + +@keyframes chart-live-flash-ring { + 0% { + transform: translate(-50%, -50%) scale(0.55); + opacity: 0.9; + } + 70% { + transform: translate(-50%, -50%) scale(2.6); + opacity: 0; + } + 100% { + transform: translate(-50%, -50%) scale(2.6); + opacity: 0; + } +} + +@keyframes chart-live-flash-core { + 0%, + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 50% { + transform: translate(-50%, -50%) scale(1.3); + opacity: 0.8; + } +} + +@keyframes chart-live-flash-core-down { + 0%, + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 50% { + transform: translate(-50%, -50%) scale(1.3); + opacity: 0.8; + } +} + +.chart-scrub-beam { + position: absolute; + top: 0; + bottom: 0; + width: 112px; + transform: translateX(-50%); + pointer-events: none; + opacity: 0; + mix-blend-mode: screen; + transition: + opacity 0.1s ease-out, + left 0.04s linear; + will-change: left, opacity; + z-index: 6; +} + +.chart-scrub-beam--active { + opacity: 1; + animation: chart-scrub-beam-breathe 1.6s ease-in-out infinite; +} + +.chart-scrub-beam--up { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(16, 185, 129, 0.1) 28%, + rgba(167, 243, 208, 0.55) 50%, + rgba(16, 185, 129, 0.1) 72%, + transparent 100% + ); +} + +.chart-scrub-beam--down { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(244, 63, 94, 0.1) 28%, + rgba(253, 164, 175, 0.55) 50%, + rgba(244, 63, 94, 0.1) 72%, + transparent 100% + ); +} + +.chart-scrub-point { + position: absolute; + width: 64px; + height: 64px; + transform: translate(-50%, -50%); + border-radius: 9999px; + pointer-events: none; + opacity: 0; + filter: blur(8px); + transition: + opacity 0.1s ease-out, + left 0.04s linear, + top 0.04s linear; + will-change: left, top, opacity; + z-index: 7; +} + +.chart-scrub-point--active { + opacity: 1; + animation: chart-scrub-point-pulse 1.4s ease-in-out infinite; +} + +.chart-scrub-point--up { + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.98) 0%, + rgba(167, 243, 208, 0.85) 20%, + rgba(16, 185, 129, 0.45) 45%, + transparent 72% + ); +} + +.chart-scrub-point--down { + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.98) 0%, + rgba(253, 164, 175, 0.85) 20%, + rgba(244, 63, 94, 0.45) 45%, + transparent 72% + ); +} + +.chart-live-flash { + position: absolute; + width: 0; + height: 0; + pointer-events: none; + z-index: 4; +} + +.chart-live-flash-ring { + position: absolute; + left: 0; + top: 0; + width: 1.35rem; + height: 1.35rem; + border-radius: 9999px; + animation: chart-live-flash-ring 1.8s ease-out infinite; +} + +.chart-live-flash-ring--up { + background: rgba(52, 211, 153, 0.4); + box-shadow: 0 0 20px rgba(52, 211, 153, 0.75); +} + +.chart-live-flash-ring--down { + background: rgba(244, 63, 94, 0.4); + box-shadow: 0 0 20px rgba(244, 63, 94, 0.75); +} + +.chart-live-flash-core { + position: absolute; + left: 0; + top: 0; + width: 0.625rem; + height: 0.625rem; + border-radius: 9999px; +} + +.chart-live-flash-core--up { + background: #ffffff; + border: 2px solid #34d399; + box-shadow: + 0 0 10px rgba(255, 255, 255, 0.95), + 0 0 18px rgba(52, 211, 153, 0.85); + animation: chart-live-flash-core 1.8s ease-in-out infinite; +} + +.chart-live-flash-core--down { + background: #ffffff; + border: 2px solid #fb7185; + box-shadow: + 0 0 10px rgba(255, 255, 255, 0.95), + 0 0 18px rgba(244, 63, 94, 0.85); + animation: chart-live-flash-core-down 1.8s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .chart-sparkle-sweep--loading, + .chart-sparkle-flash--loading, + .chart-scrub-beam--active, + .chart-scrub-point--active, + .chart-live-flash-ring, + .chart-live-flash-core--up, + .chart-live-flash-core--down { + animation: none; + } + + .chart-scrub-beam--active, + .chart-scrub-point--active { + opacity: 1; + } + + .chart-sparkle-flash--loading { + opacity: 0.55; + } +} diff --git a/components/ChartComponent.tsx b/components/ChartComponent.tsx index 9d88d8b..561b335 100644 --- a/components/ChartComponent.tsx +++ b/components/ChartComponent.tsx @@ -8,20 +8,42 @@ * Requirements: 4.2, 11.2, 11.3, 11.4, 11.5 */ +import { LoadingSpinner } from "@/components/LoadingSpinner"; import { DNA_CAPTION } from "@/lib/design-dna"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { ChartWrapper, IChartApi } from "./ChartWrapper"; +import { + ChartSparkleOverlay, + type ChartSparkleOverlayHandle, +} from "./ChartSparkleOverlay"; +import { ChartMagnifierTooltip } from "./ChartMagnifierTooltip"; import { PriceData, TimeRange, ChartType, ChartIndicator } from "@/types"; import { useTheme } from "@/lib/theme-context"; -import { homeChipClasses, HOME_CALLOUT } from "@/lib/home-ui"; +import { homeChipClasses } from "@/lib/home-ui"; +import { animateChartTrail, type ChartTrailCancel } from "@/lib/chart-trail"; +import { + applyPlotClip, + clearPlotClip, + computePlotClipPathFromRatio, + computePlotClipPathFromX, + HIDDEN_PLOT_CLIP, +} from "@/lib/chart-plot-clip"; import { getMarketChartColors, + marketChartAtmosphereGradient, marketChartOverlayColor, marketChartSignedColor, MARKET_DOWN_TEXT, MARKET_ERROR_SURFACE, } from "@/lib/market-semantics"; +import { + CHART_MAGNIFIER_TOOLTIP_WIDTH, + clampMagnifierTooltipLeft, + resolveMagnifierPoint, +} from "@/lib/chart-magnifier-tooltip"; import { MARKET_UI_COPY } from "@/lib/market-ui-copy"; +import { validatePriceDataSeries } from "@/lib/chart-price-data"; +import { filterPriceDataByTimeRange } from "@/lib/chart-time-range"; import { calculateRSI, calculateMACD, @@ -37,20 +59,33 @@ import { AreaSeries, LineSeries, HistogramSeries, + LineType, Time, } from "lightweight-charts"; export interface ChartComponentProps { data: PriceData[]; + symbol?: string; + symbolName?: string; type?: ChartType; + /** Active range — required when `serverRangeScoped` (parent-controlled). */ + timeRange?: TimeRange; initialTimeRange?: TimeRange; + /** Range the current `data` was fetched for (server-scoped charts). */ + dataTimeRange?: TimeRange; + /** Parent is refetching history for a new range. */ + historyLoading?: boolean; indicators?: ChartIndicator[]; onTimeRangeChange?: (range: TimeRange) => void; onDataPointHover?: (point: PriceData | null) => void; responsive?: boolean; height?: number; + /** Parent already fetches history for the active range (symbol overview). */ + serverRangeScoped?: boolean; } +const EMPTY_INDICATORS: ChartIndicator[] = []; + const TIME_RANGES: TimeRange[] = [ "1D", "1W", @@ -68,81 +103,186 @@ const TIME_RANGES: TimeRange[] = [ */ export function ChartComponent({ data, - type = "line", + symbol, + symbolName, + type = "area", + timeRange: controlledTimeRange, initialTimeRange = "1M", - indicators = [], + dataTimeRange, + historyLoading = false, + indicators = EMPTY_INDICATORS, onTimeRangeChange, onDataPointHover, height = 400, + serverRangeScoped = false, }: ChartComponentProps) { - const [selectedTimeRange, setSelectedTimeRange] = + const priceData = useMemo(() => validatePriceDataSeries(data), [data]); + const [localTimeRange, setLocalTimeRange] = useState(initialTimeRange); - const [chartType, setChartType] = useState(type); + const activeTimeRange = serverRangeScoped + ? (controlledTimeRange ?? initialTimeRange) + : localTimeRange; + const dataMatchesRange = + !serverRangeScoped || + (!historyLoading && + dataTimeRange === activeTimeRange && + priceData.length > 0); + const [chartType, setChartType] = useState( + type === "candlestick" ? "candlestick" : "area" + ); const [error, setError] = useState(null); - const [hoveredPoint, setHoveredPoint] = useState(null); + const [magnifier, setMagnifier] = useState<{ + left: number; + point: PriceData; + } | null>(null); + const [plotClipPath, setPlotClipPath] = useState(null); const [chartKey, setChartKey] = useState(0); + const chartApiRef = useRef(null); + const mainSeriesRef = useRef | null>(null); + const plotContainerRef = useRef(null); + const sparkleRef = useRef(null); + const isChartLoadingRef = useRef(false); + const isPointerDownRef = useRef(false); + const isPanningRef = useRef(false); + const lastHoverTimeRef = useRef