SoftTraderFrontend/components/pages/FrontPage.tsx
2026-03-20 11:10:13 -04:00

295 lines
9.5 KiB
TypeScript

'use client'
import { useEffect, useState, useRef } from 'react'
import { StockCard } from '@/components/widgets/StockCard'
import { NewsHeadlines } from '@/components/widgets/NewsHeadlines'
import { getUserSymbols } from '@/lib/userInterestsApi'
import { WeatherWidget } from '@/components/widgets/WeatherWidget'
import { getTimeFormat, type TimeFormat } from '@/lib/weatherApi'
type MarketStatus = {
label: string
color: 'green' | 'yellow' | 'red'
}
function getMarketStatus(now: Date): MarketStatus {
const day = now.getDay()
if (day === 0 || day === 6) {
return { label: 'NYSE Closed', color: 'red' }
}
const estStr = now.toLocaleString('en-US', {
timeZone: 'America/New_York',
hour: 'numeric',
minute: 'numeric',
hour12: false,
})
const [estHourStr, estMinStr] = estStr.split(':')
const estMinutes = parseInt(estHourStr) * 60 + parseInt(estMinStr)
const t0700 = 7 * 60
const t0930 = 9 * 60 + 30
const t1600 = 16 * 60
const t2000 = 20 * 60
if (estMinutes >= t0700 && estMinutes < t0930) {
return { label: 'NYSE Premarket', color: 'yellow' }
}
if (estMinutes >= t0930 && estMinutes < t1600) {
return { label: 'NYSE Open', color: 'green' }
}
if (estMinutes >= t1600 && estMinutes < t2000) {
return { label: 'NYSE After-Hours', color: 'yellow' }
}
return { label: 'NYSE Closed', color: 'red' }
}
const indicatorColors = {
green: { box: 'bg-positive', text: 'text-positive' },
yellow: { box: 'bg-yellow-400', text: 'text-yellow-500' },
red: { box: 'bg-negative', text: 'text-negative' },
}
// Skeleton placeholder for StockCard
function StockCardSkeleton() {
return (
<div className="border border-border bg-card p-6 space-y-4">
<div className="skeleton h-7 w-20" />
<div className="space-y-2">
<div className="skeleton h-9 w-32" />
<div className="skeleton h-6 w-40" />
</div>
<div className="space-y-2">
<div className="flex justify-between">
<div className="skeleton h-4 w-12" />
<div className="skeleton h-4 w-16" />
</div>
<div className="flex justify-between">
<div className="skeleton h-4 w-16" />
<div className="skeleton h-4 w-16" />
</div>
<div className="flex justify-between">
<div className="skeleton h-4 w-14" />
<div className="skeleton h-4 w-20" />
</div>
</div>
</div>
)
}
// Skeleton placeholder for NewsHeadlines (inside card)
function NewsHeadlinesSkeleton() {
return (
<div className="border border-border bg-card p-6 space-y-4">
<div className="skeleton h-6 w-32" />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="border-b border-border pb-3 last:border-b-0 space-y-2">
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-3/4" />
<div className="skeleton h-3 w-24 mt-1" />
</div>
))}
</div>
</div>
)
}
export function FrontPage() {
const [currentTime, setCurrentTime] = useState<string>('')
const [currentDate, setCurrentDate] = useState<string>('')
const [marketStatus, setMarketStatus] = useState<MarketStatus>({ label: 'NYSE Closed', color: 'red' })
const [timeFormat, setTimeFormatState] = useState<TimeFormat>('12h')
// Symbol list state
const [symbols, setSymbols] = useState<string[]>([])
const [initialLoading, setInitialLoading] = useState(true)
const [symbolsError, setSymbolsError] = useState<string | null>(null)
// Rotation state - track displayed symbol separately for seamless transitions
const [activeIndex, setActiveIndex] = useState(0)
const [displayedSymbol, setDisplayedSymbol] = useState<string | null>(null)
const [animationKey, setAnimationKey] = useState(0)
const [isTransitioning, setIsTransitioning] = useState(false)
// Track if first symbol has been shown
const hasShownFirst = useRef(false)
// Derived active symbol from index
const activeSymbol = symbols.length > 0 ? symbols[activeIndex] : null
// Fetch user symbols on mount
useEffect(() => {
setInitialLoading(true)
setSymbolsError(null)
getUserSymbols()
.then((items) => {
setSymbols(items)
setActiveIndex(0)
if (items.length > 0) {
setDisplayedSymbol(items[0])
hasShownFirst.current = true
}
})
.catch((err) => setSymbolsError(err.message))
.finally(() => setInitialLoading(false))
}, [])
// Rotation interval - only when more than one symbol
useEffect(() => {
if (symbols.length <= 1) return
const interval = setInterval(() => {
setIsTransitioning(true)
// After outgoing animation, switch to next
setTimeout(() => {
setActiveIndex((prev) => {
const next = (prev + 1) % symbols.length
setDisplayedSymbol(symbols[next])
setAnimationKey((k) => k + 1)
return next
})
setIsTransitioning(false)
}, 300)
}, 11000)
return () => clearInterval(interval)
}, [symbols])
// Reset index if it becomes invalid after symbols change
useEffect(() => {
if (activeIndex >= symbols.length && symbols.length > 0) {
setActiveIndex(0)
setDisplayedSymbol(symbols[0])
}
}, [symbols, activeIndex])
// Load time format preference on mount
useEffect(() => {
setTimeFormatState(getTimeFormat())
}, [])
useEffect(() => {
const update = () => {
const now = new Date()
const is24h = timeFormat === '24h'
setCurrentTime(now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !is24h
}))
setCurrentDate(now.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
}))
setMarketStatus(getMarketStatus(now))
}
update()
const interval = setInterval(update, 1000)
return () => clearInterval(interval)
}, [timeFormat])
const colors = indicatorColors[marketStatus.color]
return (
<div className="h-full flex flex-col">
{/* Top Half - Time, Date, Market Status, Weather */}
<div className="flex-1 bg-background border-b border-border p-6 flex items-start justify-between">
{/* Left: Time, Date, Market Status */}
<div className="space-y-2">
<div className="text-5xl font-bold text-foreground">
{currentTime}
</div>
<div className="text-sm text-muted-foreground">
{currentDate}
</div>
<div className="flex items-center gap-2 pt-2">
<div className={`w-3 h-3 ${colors.box}`} />
<span className={`text-sm font-medium ${colors.text}`}>
{marketStatus.label}
</span>
</div>
</div>
{/* Right: Weather */}
<WeatherWidget compact />
</div>
{/* Bottom Half - Stock Summary & News */}
<div className="flex-1 flex bg-background overflow-hidden">
{/* Left: Stock Card */}
<div className="flex-1 border-r border-border p-6 overflow-y-auto flex flex-col">
{/* Initial loading skeleton */}
{initialLoading && <StockCardSkeleton />}
{/* Error state */}
{!initialLoading && symbolsError && (
<div className="border border-border bg-card p-6 text-muted-foreground text-sm">
Unable to load symbols
</div>
)}
{/* Empty state */}
{!initialLoading && !symbolsError && symbols.length === 0 && (
<div className="border border-border bg-card p-6 text-muted-foreground text-sm">
No symbols added yet.
</div>
)}
{/* Active symbol with slide transition */}
{!initialLoading && !symbolsError && displayedSymbol && (
<div
key={animationKey}
className={isTransitioning ? 'animate-slide-out-left' : 'animate-slide-in-right'}
>
<StockCard symbol={displayedSymbol} />
</div>
)}
</div>
{/* Right: News Headlines */}
<div className="flex-1 p-6 overflow-y-auto flex flex-col">
{/* Initial loading skeleton */}
{initialLoading && <NewsHeadlinesSkeleton />}
{/* Error state */}
{!initialLoading && symbolsError && (
<div className="border border-border bg-card p-6">
<h3 className="text-lg font-semibold mb-4">News Headlines</h3>
<div className="text-muted-foreground text-sm">Unable to load</div>
</div>
)}
{/* Empty state */}
{!initialLoading && !symbolsError && symbols.length === 0 && (
<div className="border border-border bg-card p-6">
<h3 className="text-lg font-semibold mb-4">News Headlines</h3>
<div className="text-muted-foreground text-sm">
No symbols to show headlines for.
</div>
</div>
)}
{/* Active headlines with slide transition */}
{!initialLoading && !symbolsError && displayedSymbol && (
<div
key={animationKey}
className={isTransitioning ? 'animate-slide-out-left' : 'animate-slide-in-right'}
>
<div className="border border-border bg-card p-6">
<h3 className="text-lg font-semibold mb-4">News Headlines</h3>
<NewsHeadlines keyword={displayedSymbol} />
</div>
</div>
)}
</div>
</div>
</div>
)
}