123 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|