diff options
author | Navan Chauhan <navanchauhan@gmail.com> | 2025-04-27 22:44:20 -0600 |
---|---|---|
committer | Navan Chauhan <navanchauhan@gmail.com> | 2025-04-27 22:44:20 -0600 |
commit | 37787895d56888ab44362252f21fb05c05e97250 (patch) | |
tree | 5ff7eaababa6fffd5dc950920f521c5f242010af /client/src/components | |
parent | f32142947b853076889801913d47b8c2c0f4f456 (diff) |
reorganize
Diffstat (limited to 'client/src/components')
-rw-r--r-- | client/src/components/BidsPage.jsx | 182 | ||||
-rw-r--r-- | client/src/components/MarketDataPage.jsx | 320 | ||||
-rw-r--r-- | client/src/components/SubmitBidPage.jsx | 188 |
3 files changed, 690 insertions, 0 deletions
diff --git a/client/src/components/BidsPage.jsx b/client/src/components/BidsPage.jsx new file mode 100644 index 0000000..e0c4128 --- /dev/null +++ b/client/src/components/BidsPage.jsx @@ -0,0 +1,182 @@ +import React, { useEffect, useState } from 'react'; +import { Table, Typography, Spin, Message, Card, Empty, Badge } from '@arco-design/web-react'; +import { IconArrowRise, IconArrowFall } from '@arco-design/web-react/icon'; +import '@arco-design/web-react/dist/css/arco.css'; +import API_BASE_URL from './config'; + +const columns = (bids) => [ + { + title: 'Timestamp', + dataIndex: 'timestamp', + sorter: (a, b) => new Date(a.timestamp) - new Date(b.timestamp), + render: (val) => new Date(val).toLocaleString(), + width: 180, + }, + { + title: 'Quantity (MW)', + dataIndex: 'quantity', + sorter: (a, b) => a.quantity - b.quantity, + render: (val) => val.toFixed(2), + width: 180, + }, + { + title: 'Price ($/MWh)', + dataIndex: 'price', + sorter: (a, b) => a.price - b.price, + render: (val) => `$${val.toFixed(2)}`, + width: 180, + }, + { + title: 'Status', + dataIndex: 'status', + sorter: (a, b) => a.status.localeCompare(b.status), + filters: [ + { text: 'Submitted', value: 'Submitted' }, + { text: 'Success', value: 'Success' }, + { text: 'Fail', value: 'Fail' }, + ], + onFilter: (value, record) => record.status === value, + render: (val) => ( + <Badge status={val === 'Success' ? 'success' : val === 'Fail' ? 'error' : 'processing'} text={val} /> + ), + width: 180, + }, + { + title: 'PnL', + dataIndex: 'pnl', + sorter: (a, b) => (a.pnl || 0) - (b.pnl || 0), + render: (val) => { + if (val === null) return 'N/A'; + const isProfit = val >= 0; + const color = isProfit ? 'green' : 'red'; + return ( + <Typography.Text style={{ color, display: 'flex', alignItems: 'center', gap: 4 }}> + {isProfit ? <IconArrowRise /> : <IconArrowFall />} + {val.toFixed(2)} + </Typography.Text> + ); + }, + width: 180, + }, + { + title: 'Market', + dataIndex: 'market', + sorter: (a, b) => a.market.localeCompare(b.market), + filters: [ + { text: 'ISONE', value: 'ISONE' }, + { text: 'NYISO', value: 'NYISO' }, + { text: 'MISO', value: 'MISO' }, + ], + onFilter: (value, record) => record.market === value, + render: (val) => val, + width: 180, + }, +]; + +function BidsPage() { + const [bids, setBids] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(`${API_BASE_URL}/bids/`) + .then((res) => { + if (!res.ok) throw new Error('Failed to fetch bids'); + return res.json(); + }) + .then((json) => setBids(json)) + .catch((err) => { + console.error(err); + Message.error('Failed to load bids.'); + }) + .finally(() => setLoading(false)); + }, []); + + return ( + <div style={{ padding: 20, backgroundColor: 'var(--color-fill-2)', minHeight: 'calc(100vh - 150px)', animation: 'fadeSlideIn 0.6s ease' }}> + <Typography.Title heading={3} style={{ textAlign: 'center', marginBottom: 20 }}> + Your Submitted Bids + </Typography.Title> + + <Card + style={{ + marginTop: 16, + borderRadius: 16, + boxShadow: '0 8px 24px rgba(0,0,0,0.08)', + background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(245,245,245,0.95) 100%)', + backdropFilter: 'blur(8px)', + overflow: 'hidden', + padding: 20 + }} + > + {loading ? ( + <div style={{ textAlign: 'center', padding: 80 }}> + <Spin /> + </div> + ) : bids.length === 0 ? ( + <div style={{ textAlign: 'center', padding: 80 }}> + <Empty description="No bids found yet." /> + </div> + ) : ( + <div className="responsive-table-wrapper"> + <Table + columns={columns(bids)} + data={bids} + rowKey="id" + pagination={{ + pageSize: 50, + sizeCanChange: true, + showTotal: true, + }} + scroll={{ x: true }} + border + style={{ transition: 'opacity 0.5s ease-in-out' }} + rowClassName={() => 'table-row-hover'} + /> + </div> + )} + </Card> + + <style>{` + @keyframes fadeSlideIn { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } + .table-row-hover:hover { + background-color: var(--color-fill-3); + transition: background-color 0.3s ease; + } + + responsive-table-wrapper { + width: 100%; + overflow-x: auto; + } + + /* Prevent wrapping in header and cells */ + .responsive-table-wrapper table th, + .responsive-table-wrapper table td { + white-space: nowrap; + } + + /* Optional: smaller fonts on mobile */ + @media (max-width: 768px) { + .responsive-table-wrapper table { + font-size: 12px; + } + .responsive-table-wrapper th, + .responsive-table-wrapper td { + padding: 8px; + } + } + + `}</style> + </div> + ); +} + +export default BidsPage; diff --git a/client/src/components/MarketDataPage.jsx b/client/src/components/MarketDataPage.jsx new file mode 100644 index 0000000..5e028f8 --- /dev/null +++ b/client/src/components/MarketDataPage.jsx @@ -0,0 +1,320 @@ +import React, { useEffect, useState, useContext, useRef, useMemo } from 'react'; +import { Typography, Spin, Message, Card, Grid } from '@arco-design/web-react'; +import '@arco-design/web-react/dist/css/arco.css'; +import { initVChartArcoTheme } from '@visactor/vchart-arco-theme'; +import { VChart } from '@visactor/react-vchart'; +import API_BASE_URL from './config'; +import { MarketContext, MARKET_FULL_NAMES } from './App'; + +// TODO: +// +// - [ ] Cancel Promise if market changes +// - [ ] Fix Data Zoom being reset + +const { Row, Col } = Grid; + +function TimeAgo({ timestamp, prefix }) { + const [now, setNow] = useState(new Date()); + + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + const formatTimeAgo = () => { + if (!timestamp) return ''; + const seconds = Math.floor((now - timestamp) / 1000); + if (seconds < 60) return `${seconds} sec ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + return `${hours} hr ago`; + }; + + return ( + <Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}> + {prefix}: {formatTimeAgo()} + </Typography.Text> + ); +} + +function MarketDataPage() { + const { selectedMarket } = useContext(MarketContext); + const fiveMinChartRef = useRef(null); + const dayAheadChartRef = useRef(null); + const [fiveMinZoom, setFiveMinZoom] = useState({ start: 0.9, end: 1 }); + const [dayAheadZoom, setDayAheadZoom] = useState({ start: 0.05, end: 1 }); + const [fiveMinData, setFiveMinData] = useState([]); + const [dayAheadData, setDayAheadData] = useState([]); + const [loading, setLoading] = useState(true); + const [fiveMinLastUpdated, setFiveMinLastUpdated] = useState(null); + const [dayAheadLastUpdated, setDayAheadLastUpdated] = useState(null); + const [, setNow] = useState(new Date()); + + const handleZoomChange = (e, title) => { + if (!e?.start || !e?.end) return; + if (title === 'fiveMin') { + setFiveMinZoom({ start: e.start, end: e.end }); + } else { + setDayAheadZoom({ start: e.start, end: e.end }); + } + }; + + useEffect(() => { + const fetchFiveMinData = async () => { + try { + const res = await fetch(`${API_BASE_URL}/market/real-time?market=${selectedMarket}`); + if (!res.ok) throw new Error('Failed to fetch real-time data'); + const json = await res.json(); + setFiveMinData(json); + setFiveMinLastUpdated(new Date()); + } catch (err) { + console.error(err); + Message.error('Failed to load real-time market data.'); + } + }; + + const fetchDayAheadData = async () => { + try { + const res = await fetch(`${API_BASE_URL}/market/day-ahead?market=${selectedMarket}`); + if (!res.ok) throw new Error('Failed to fetch day-ahead data'); + const json = await res.json(); + setDayAheadData(json); + setDayAheadLastUpdated(new Date()); + } catch (err) { + console.error(err); + Message.error('Failed to load day-ahead market data.'); + } + }; + + const initialize = async () => { + setLoading(true); + await Promise.all([fetchFiveMinData(), fetchDayAheadData()]); + setLoading(false); + + initVChartArcoTheme({ + defaultMode: 'light', + isWatchingMode: true + }); + }; + + initialize(); + + const fiveMinInterval = setInterval(fetchFiveMinData, 5 * 60 * 1000); + const dayAheadInterval = setInterval(fetchDayAheadData, 60 * 60 * 1000); + const timer = setInterval(() => setNow(new Date()), 1000); + + return () => { + clearInterval(fiveMinInterval); + clearInterval(dayAheadInterval); + clearInterval(timer); + }; + }, [selectedMarket]); + + const fiveMinSpec = useMemo(() => ({ + type: 'line', + data: { + values: fiveMinData.map(d => ({ + timestamp: new Date(d.timestamp).toLocaleString(), + LMP: d.lmp, + Energy: d.energy, + Congestion: d.congestion, + Loss: d.loss + })), + transforms: [{ + type: 'fold', + options: { key: 'name', value: 'value', fields: ['LMP', 'Energy', 'Congestion', 'Loss'] } + }] + }, + xField: 'timestamp', + yField: 'value', + seriesField: 'name', + smooth: true, + legend: { position: 'top' }, + tooltip: { + formatter: (datum) => ({ + name: datum.name, + value: datum.value.toFixed(2) + }) + }, + dataZoom: [{ + orient: 'bottom', + height: 20, + start: fiveMinZoom.start, + end: fiveMinZoom.end + }] + }), [fiveMinData, fiveMinZoom]); // <== DEPENDENCIES + + const dayAheadSpec = useMemo(() => ({ + type: 'line', + data: { + values: dayAheadData.map(d => ({ + timestamp: new Date(d.timestamp).toLocaleString(), + LMP: d.lmp, + Energy: d.energy, + Congestion: d.congestion, + Loss: d.loss + })), + transforms: [{ + type: 'fold', + options: { key: 'name', value: 'value', fields: ['LMP', 'Energy', 'Congestion', 'Loss'] } + }] + }, + xField: 'timestamp', + yField: 'value', + seriesField: 'name', + smooth: true, + legend: { position: 'top' }, + tooltip: { + formatter: (datum) => ({ + name: datum.name, + value: datum.value.toFixed(2) + }) + }, + dataZoom: [{ + orient: 'bottom', + height: 20, + start: dayAheadZoom.start, + end: dayAheadZoom.end + }] + }), [dayAheadData, dayAheadZoom]); + + const computeKPIs = (data) => { + if (!data.length) return { avgLmp: 0, totalEnergy: 0, maxCongestion: 0, avgLoss: 0 }; + const avgLmp = data.reduce((sum, d) => sum + d.lmp, 0) / data.length; + const totalEnergy = data.reduce((sum, d) => sum + d.energy, 0); + const maxCongestion = Math.max(...data.map(d => d.congestion)); + const avgLoss = data.reduce((sum, d) => sum + d.loss, 0) / data.length; + return { avgLmp, totalEnergy, maxCongestion, avgLoss }; + }; + + const fiveMinKPIs = computeKPIs(fiveMinData); + const dayAheadKPIs = computeKPIs(dayAheadData); + + return ( + <div className="page-container"> + <Typography.Title heading={3} style={{ textAlign: 'center', marginBottom: 20 }} className="market-page-title" > + Market Data for {MARKET_FULL_NAMES[selectedMarket]} + </Typography.Title> + + {loading ? ( + <Spin style={{ display: 'block', margin: '80px auto' }} /> + ) : ( + <> + <Row gutter={24} style={{ marginBottom: 30 }}> + {[ + { label: 'Avg Real-Time LMP ($/MWh)', value: fiveMinKPIs.avgLmp }, + { label: 'Total Real-Time Energy (MWh)', value: fiveMinKPIs.totalEnergy }, + { label: 'Max Real-Time Congestion ($)', value: fiveMinKPIs.maxCongestion }, + { label: 'Avg Real-Time Loss ($)', value: fiveMinKPIs.avgLoss } + ].map((item, idx) => ( + <Col xs={12} md={6} key={idx} style={{ marginBottom: 16 }}> + <Card + hoverable + style={{ + textAlign: 'center', + borderRadius: 12, + boxShadow: '0 4px 12px rgba(0,0,0,0.05)' + }} + title={item.label} + > + <Typography.Title heading={5}>${item.value.toFixed(2)}</Typography.Title> + </Card> + </Col> + ))} + </Row> + + <Row gutter={24} style={{ marginBottom: 30 }}> + {[ + { label: 'Avg Day-Ahead LMP ($/MWh)', value: dayAheadKPIs.avgLmp }, + { label: 'Total Day-Ahead Energy (MWh)', value: dayAheadKPIs.totalEnergy }, + { label: 'Max Day-Ahead Congestion ($)', value: dayAheadKPIs.maxCongestion }, + { label: 'Avg Day-Ahead Loss ($)', value: dayAheadKPIs.avgLoss } + ].map((item, idx) => ( + <Col xs={12} md={6} key={idx} style={{ marginBottom: 16 }}> + <Card + hoverable + style={{ + textAlign: 'center', + borderRadius: 12, + boxShadow: '0 4px 12px rgba(0,0,0,0.05)' + }} + title={item.label} + > + <Typography.Title heading={5}>${item.value.toFixed(2)}</Typography.Title> + </Card> + </Col> + ))} + </Row> + + <Row gutter={24}> + <Col xs={24} md={12}> + <Card title="Real-Time Market Data" extra={<TimeAgo timestamp={fiveMinLastUpdated} prefix="Updated" />} style={{ borderRadius: 12, marginBottom: 24 }}> + <div style={{ padding: 10 }}> + <VChart + spec={fiveMinSpec} + ref={fiveMinChartRef} + onDataZoom={(e) => handleZoomChange(e.detail, 'fiveMin')} + /> + </div> + </Card> + </Col> + <Col xs={24} md={12}> + <Card title="Day-Ahead Market Data" extra={<TimeAgo timestamp={dayAheadLastUpdated} prefix="Updated" />} style={{ borderRadius: 12, marginBottom: 24 }}> + <div style={{ padding: 10 }}> + <VChart + spec={dayAheadSpec} + ref={dayAheadChartRef} + onDataZoom={(e) => handleZoomChange(e.detail, 'dayAhead')} + /> + </div> + </Card> + </Col> + </Row> + </> + )} + + <style>{` + @keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + + .arco-card-header-title { + white-space: normal !important; /* allow wrapping */ + word-break: break-word; + } + + /* Optional: smaller text on tiny devices */ + @media (max-width: 768px) { + .arco-card-header-title { + font-size: 14px; + } + } + + .market-page-title { + white-space: normal; + word-break: keep-all; + overflow-wrap: break-word; + text-align: center; + } + + .page-container { + padding: 15px; + background: var(--color-fill-2); + animation: fadeIn 0.5s ease; + overflow-x: hidden; /* Also good practice */ + } + + /* Responsive: remove padding on small screens */ + @media (max-width: 768px) { + .page-container { + padding: 0; + } + } + `}</style> + </div> + ); +} + +export default MarketDataPage; diff --git a/client/src/components/SubmitBidPage.jsx b/client/src/components/SubmitBidPage.jsx new file mode 100644 index 0000000..7737c20 --- /dev/null +++ b/client/src/components/SubmitBidPage.jsx @@ -0,0 +1,188 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { Form, InputNumber, DatePicker, Button, Message, Typography, Card } from '@arco-design/web-react'; +import '@arco-design/web-react/dist/css/arco.css'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import API_BASE_URL from './config'; +import { MarketContext, MARKET_FULL_NAMES } from './App'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const MARKET_TIMEZONES = { + ISONE: 'America/New_York', + NYISO: 'America/New_York', + MISO: 'America/Chicago' +}; + +function SubmitBidPage() { + const { selectedMarket } = useContext(MarketContext); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [localNow, setLocalNow] = useState(''); + const [marketNow, setMarketNow] = useState(''); + + useEffect(() => { + const updateTime = () => { + const now = dayjs(); + const localFormatted = now.format('dddd, MMMM D, h:mm A'); + const marketTz = MARKET_TIMEZONES[selectedMarket] || 'America/New_York'; + const marketTime = now.tz(marketTz); + const marketFormatted = marketTime.format('dddd, MMMM D, h:mm A'); + setLocalNow(localFormatted); + setMarketNow(marketFormatted); + }; + + updateTime(); + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + }, [selectedMarket]); + + const handleSubmit = async (values) => { + setLoading(true); + try { + const picked = dayjs(values.timestamp); + const adjusted = picked.minute(0).second(0).millisecond(0); + + const marketTz = MARKET_TIMEZONES[selectedMarket] || 'America/New_York'; + const timestampInMarketTz = adjusted.tz(marketTz).format(); + + const payload = { + timestamp: timestampInMarketTz, + quantity: values.quantity, + price: values.price, + market: selectedMarket, + user_id: 1 + }; + + const res = await fetch(`${API_BASE_URL}/bids/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Failed to submit bid'); + } + + Message.success('Bid submitted successfully!'); + form.resetFields(); + } catch (err) { + console.error(err); + Message.error(err.message || 'Submission failed'); + } finally { + setLoading(false); + } + }; + + return ( + <div style={{ padding: 20, backgroundColor: 'var(--color-fill-2)', minHeight: 'calc(100vh - 150px)', animation: 'fadeSlideIn 0.6s ease' }}> + <Typography.Title heading={3} style={{ textAlign: 'center', marginBottom: 20 }}> + Submit New Bid + </Typography.Title> + + <Card + style={{ + marginTop: 16, + borderRadius: 16, + boxShadow: '0 8px 24px rgba(0,0,0,0.08)', + background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(245,245,245,0.95) 100%)', + backdropFilter: 'blur(8px)', + overflow: 'hidden', + padding: 32 + }} + > + <div style={{ marginBottom: 32, padding: 16, border: '1px solid var(--color-border)', borderRadius: 8, background: 'var(--color-fill-1)' }}> + <Typography.Paragraph style={{ marginBottom: 8 }}> + <strong>Current Local Time:</strong> {localNow} + </Typography.Paragraph> + <Typography.Paragraph> + <strong>Current {MARKET_FULL_NAMES[selectedMarket]} Time:</strong> {marketNow} + </Typography.Paragraph> + </div> + + <Form + form={form} + layout="vertical" + onSubmit={handleSubmit} + initialValues={{ quantity: 0, price: 0 }} + style={{ maxWidth: 600, margin: '0 auto' }} + > + <Form.Item + label="Bid Date and Hour" + field="timestamp" + rules={[{ required: true, message: 'Please select bid date and hour' }]} + > + <DatePicker + showTime={{ + format: 'h A', + defaultValue: dayjs().hour(0).minute(0), + disabledMinutes: () => Array.from({ length: 60 }, (_, i) => i !== 0), + step: { minute: 60 } + }} + style={{ width: '100%' }} + format="YYYY-MM-DD HH:00" + placeholder="Select date and hour" + timezone={MARKET_TIMEZONES[selectedMarket]} + /> + </Form.Item> + + <Form.Item + label="Quantity (MW)" + field="quantity" + rules={[ + { required: true, message: 'Please enter quantity' }, + { type: 'number', min: 0.01, message: 'Quantity must be > 0' } + ]} + > + <InputNumber style={{ width: '100%' }} placeholder="Enter quantity" /> + </Form.Item> + + <Form.Item + label="Price ($/MWh)" + field="price" + rules={[ + { required: true, message: 'Please enter price' }, + { type: 'number', min: 0, message: 'Price must be >= 0' } + ]} + > + <InputNumber style={{ width: '100%' }} placeholder="Enter price" /> + </Form.Item> + + <Form.Item> + <Button + type="primary" + htmlType="submit" + loading={loading} + style={{ + width: '100%', + transition: 'transform 0.2s ease', + }} + onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.02)')} + onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')} + > + Submit Bid + </Button> + </Form.Item> + </Form> + </Card> + + <style>{` + @keyframes fadeSlideIn { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } + `}</style> + </div> + ); +} + +export default SubmitBidPage; |