123 lines
3.5 KiB
TypeScript

'use client';
import { fetchLatestCommodityPrices, fetchLatestPrices } from '@/actions/prices.js';
import type { getLatestPrices } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
import { useEffect, useState } from 'react';
interface TickerItem {
label: string;
price: string;
change: number | null;
unit: string;
}
function formatPrice(price: number, unit: string): string {
if (unit === '$/MWh') {
return `$${price.toFixed(2)}`;
}
return `$${price.toFixed(2)}`;
}
function TickerItemDisplay({ item }: { item: TickerItem }) {
const changeColor =
item.change === null ? 'text-muted-foreground' : item.change >= 0 ? 'text-emerald-400' : 'text-red-400';
const changeSymbol = item.change === null ? '' : item.change >= 0 ? '\u25B2' : '\u25BC';
return (
<span className="inline-flex items-center gap-1.5 px-4 text-sm whitespace-nowrap">
<span className="font-medium text-muted-foreground">{item.label}</span>
<span className="font-bold tabular-nums">{item.price}</span>
<span className="text-xs text-muted-foreground">{item.unit}</span>
{item.change !== null && (
<span className={cn('text-xs font-medium tabular-nums', changeColor)}>
{changeSymbol} {Math.abs(item.change).toFixed(1)}%
</span>
)}
<span className="ml-3 text-border">|</span>
</span>
);
}
export function TickerTape() {
const [items, setItems] = useState<TickerItem[]>([]);
useEffect(() => {
async function loadPrices() {
const [priceResult, commodityResult] = await Promise.all([fetchLatestPrices(), fetchLatestCommodityPrices()]);
const tickerItems: TickerItem[] = [];
if (priceResult.ok) {
const prices = deserialize<getLatestPrices.Result[]>(priceResult.data);
for (const p of prices) {
tickerItems.push({
label: p.region_code,
price: formatPrice(p.price_mwh, '$/MWh'),
change: null,
unit: '$/MWh',
});
}
}
if (commodityResult.ok) {
const commodities = deserialize<
Array<{
commodity: string;
price: number;
unit: string;
timestamp: Date;
source: string;
}>
>(commodityResult.data);
const commodityLabels: Record<string, string> = {
natural_gas: 'Nat Gas',
wti_crude: 'WTI Crude',
coal: 'Coal',
};
for (const c of commodities) {
tickerItems.push({
label: commodityLabels[c.commodity] ?? c.commodity,
price: formatPrice(c.price, c.unit),
change: null,
unit: c.unit,
});
}
}
setItems(tickerItems);
}
void loadPrices();
const interval = setInterval(() => void loadPrices(), 60_000);
return () => clearInterval(interval);
}, []);
if (items.length === 0) {
return null;
}
return (
<div className="overflow-hidden border-b border-border/40 bg-muted/30">
<div className="ticker-scroll-container flex py-1.5">
{/* Duplicate items for seamless looping */}
<div className="ticker-scroll flex shrink-0">
{items.map((item, i) => (
<TickerItemDisplay key={`a-${i}`} item={item} />
))}
</div>
<div className="ticker-scroll flex shrink-0" aria-hidden>
{items.map((item, i) => (
<TickerItemDisplay key={`b-${i}`} item={item} />
))}
</div>
</div>
</div>
);
}