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; | 
