295 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|