diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a750f90 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +MONGODB_URL='mongodb://localhost:27017/' +SMART_ENERGY_EPEX_SPOT_AT_ENDPOINT_URL='https://apis.smartenergy.at/market/v1/price' +KV_REST_API_URL='' +KV_REST_API_TOKEN='' \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..10a6843 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,50 @@ +name: Build + +on: + push: + branches: ['main'] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Cache NPM dependencies + uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: ${{ runner.os }}-node- + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd3dbb5..46bb361 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # production /build +.env # misc .DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..881002b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY . . +RUN npm i + +ENV NEXT_PUBLIC_API_URL= + +RUN npm run build + +CMD ["npm", "run", "start"] diff --git a/README.md b/README.md index c403366..607a999 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,3 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +This mini-project collects hourly energy prices using the smartENERGY EPEX Spot AT API and saves them to a MongoDB database. It also includes a web dashboard to display the collected data. Additionally, the project calculates the optimal times to charge devices, such as batteries or electric cars, based on the hourly prices available for the next 24 hours. -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +## Table of Contents \ No newline at end of file diff --git a/app/api/collect/route.ts b/app/api/collect/route.ts new file mode 100644 index 0000000..50f2de9 --- /dev/null +++ b/app/api/collect/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import data from '@/database/models/data'; +import connectDB from '@/database/connect'; +import crypto from 'crypto'; +import { Ratelimit } from "@upstash/ratelimit"; +import { kv } from '@vercel/kv'; +import { convertUTCtoGMT2, formatFetchedData, toLocalTimeISOString } from '@/utils/format'; +import { calculateBestTimes } from '@/utils/calculate'; + +export async function GET(req: NextRequest) { + try { + + const rateLimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(10, '1 m'), + }) + + const { success } = await rateLimit.limit(req.headers.get('x-real-ip') as string || req.headers.get('x-forwarded-for') as string || 'guest'); + + if (!success) { + return NextResponse.json({ message: 'Rate limit exceeded' }, { status: 429 }); + } + + const request = await fetch(process.env.SMART_ENERGY_EPEX_SPOT_AT_ENDPOINT_URL as string, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + const response = await request.json(); + + if (!request.ok) { + return NextResponse.json({ message: 'An error occurred while fetching data' }, { status: 500 }); + } + + await connectDB(); + + const formattedData = formatFetchedData(response.data); + + const splitData = formattedData.reduce((acc: any, curr) => { + const date = convertUTCtoGMT2(curr.date); + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(curr); + return acc; + }, {}); + + for (const date in splitData) { + const hash = crypto.createHash('sha256'); + hash.update(JSON.stringify(splitData[date])); + const hashValue = hash.digest('hex'); + + const existingData = await data.findOne + ({ + hash: hashValue, + }); + + if (!existingData) { + const calculatedData = calculateBestTimes(splitData[date]); + + const newData = new data({ + hash: hashValue, + tariff: response.tariff, + unit: response.unit, + interval: 60, // because we're formatting the 15 minute records to 1 hour + data: splitData[date], + energy_date: new Date(toLocalTimeISOString(date)), + data_clarification: calculatedData, + }); + + await newData.save(); + } + } + + return NextResponse.json({ message: 'Data fetched and saved successfully' }, { status: 200 }); + + } catch (err) { + console.log(err); + return NextResponse.json({ message: 'An error occurred', error: err }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/fetch/route.ts b/app/api/fetch/route.ts new file mode 100644 index 0000000..3765a1d --- /dev/null +++ b/app/api/fetch/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectDB from '@/database/connect'; +import { Ratelimit } from "@upstash/ratelimit"; +import { kv } from '@vercel/kv'; +import data from '@/database/models/data'; +import { timeframes } from '@/config/timeframes'; +import { calculateEndDate, calculateStartDate } from '@/utils/calculate'; + +interface Zone { + entries: { time: string, value: number }[]; +} + +interface Clarifications { + low: Zone; + mid: Zone; + high: Zone; +} + +interface CombinedData { + energyData: any[]; + clarifications: Clarifications; +} + +export async function GET(req: NextRequest) { + try { + const timeframe = req.nextUrl.searchParams.get('timeframe'); + + if (!timeframe) { + return NextResponse.json({ message: 'Please provide a timeframe' }, { status: 400 }); + } + + const selectedTimeframe = timeframes.find((tf) => tf.value === timeframe); + if (!selectedTimeframe) { + return NextResponse.json({ message: 'Invalid timeframe' }, { status: 400 }); + } + + const rateLimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(100, '1 m'), + }) + + const { success } = await rateLimit.limit(req.headers.get('x-real-ip') as string || req.headers.get('x-forwarded-for') as string || 'guest'); + + if (!success) { + return NextResponse.json({ message: 'Rate limit exceeded' }, { status: 429 }); + } + + await connectDB(); + + const startDate = calculateStartDate(selectedTimeframe.value); + const endDate = calculateEndDate(startDate, selectedTimeframe.duration, selectedTimeframe.unit); + const query = startDate ? { energy_date: { $gte: startDate, $lte: endDate } } : {}; + + const fetchedData = await data.find(query); + + const combinedData: CombinedData = { + energyData: [], + clarifications: { + low: { entries: [] }, + mid: { entries: [] }, + high: { entries: [] }, + } + }; + + fetchedData.forEach(doc => { + combinedData.energyData.push(...doc.data); + + if (doc.data_clarification && doc.data_clarification.length > 0) { + const clarifications = doc.data_clarification[0]; + const zones = ['low', 'mid', 'high'] as const; + zones.forEach(zone => { + combinedData.clarifications[zone].entries = clarifications[zone].map((item: any) => ({ + time: item.time, + value: item.value + })); + }); + } + }); + + return NextResponse.json({ message: 'Data fetched successfully', data: combinedData }, { status: 200 }); + + } catch (err) { + console.log(err); + return NextResponse.json({ message: 'An error occurred', error: err }, { status: 500 }); + } +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index 875c01e..6021f20 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,32 +2,6 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} - body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } -} + background-color: #f7fafc; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 3314e47..a0bb3ba 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,8 +5,7 @@ import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "EPEX SPOT AT - Statistics", }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index dc191aa..8b0f7bf 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,113 +1,67 @@ -import Image from "next/image"; +"use client"; -export default function Home() { - return ( -
-
-

- Get started by editing  - app/page.tsx -

-
- - By{" "} - Vercel Logo - -
-
+import { useEffect, useState } from "react"; +import { timeframes } from "@/config/timeframes"; +import { useGetData } from "@/hooks/data/getData"; +import TimeframeSelection from "@/components/timeframe"; +import { EnergyPriceChart, EnergyPriceChartSkeleton } from "@/components/charts/energyPriceChart"; +import ZoneVisual from "@/components/zoneVisualization"; -
- Next.js Logo -
+export default function Main() { + const [timeframe, setTimeframe] = useState(timeframes[1].value); + const { getDataResult, getDataError, getDataLoading } = useGetData({ + queryParams: { + timeframe, + }, + }); -
- -

- Docs{" "} - - -> - -

-

- Find in-depth information about Next.js features and API. -

-
+ const [energyData, setEnergyData] = useState([]); + const [clarifications, setClarifications] = useState({ low: [], mid: [], high: [] }); - -

- Learn{" "} - - -> - -

-

- Learn about Next.js in an interactive course with quizzes! -

-
+ useEffect(() => { + if (getDataResult) { + setEnergyData(getDataResult.data.energyData); + setClarifications(getDataResult.data.clarifications); + } + }, [getDataResult]); - -

- Templates{" "} - - -> - -

-

- Explore starter templates for Next.js. -

-
- - -

- Deploy{" "} - - -> - -

-

- Instantly deploy your Next.js site to a shareable URL with Vercel. -

-
+ return ( + <> +
+
+
+
+

+ Overview +

+
+
+
+
+
+

Timeframe

+ +
+
+
+
+
+

Energy Prices Over Time

+ {getDataLoading && energyData.length === 0 ? ( + + ) : ( + + )} +
+
+

Zone Clarifications

+ +
+
+
+
+
-
- ); -} + + ) +} \ No newline at end of file diff --git a/components/charts/energyPriceChart.tsx b/components/charts/energyPriceChart.tsx new file mode 100644 index 0000000..96272e5 --- /dev/null +++ b/components/charts/energyPriceChart.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; +import 'chart.js/auto'; +import 'chartjs-adapter-moment'; + +interface DataPoint { + date: string; + value: number; +} + +interface Props { + data: DataPoint[]; +} + +export const EnergyPriceChart: React.FC = ({ data }) => { + if (data.length === 0) { + return
No data available
; + } + + const gradientFill = (chart: any) => { + const ctx = chart.chart.ctx; + const gradient = ctx.createLinearGradient(0, 0, 0, 400); + gradient.addColorStop(0, 'rgba(75, 192, 192, 0.6)'); + gradient.addColorStop(1, 'rgba(75, 192, 192, 0.1)'); + return gradient; + }; + + const chartData = { + labels: data.map(d => new Date(d.date)), + datasets: [ + { + label: 'Energy Price (ct/kWh)', + data: data.map(d => d.value), + fill: true, + backgroundColor: (context: any) => gradientFill(context), + borderColor: 'rgba(75, 192, 192, 0.2)', + pointBackgroundColor: 'rgba(75, 192, 192, 1)', + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: 'rgba(75, 192, 192, 1)', + tension: 0.4 + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: { + parser: 'YYYY-MM-DDTHH:mm:ssZ', + unit: 'hour', + displayFormats: { + hour: 'HH:mm' + }, + tooltipFormat: 'MMM DD, HH:mm' + }, + title: { + display: true, + text: 'Time' + }, + grid: { + display: false + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Price (ct/kWh)' + }, + grid: { + drawBorder: false, + color: "rgba(200, 200, 200, 0.1)" + } + } + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + boxWidth: 12, + padding: 20, + font: { + size: 14 + }, + color: 'gray' + } + }, + tooltip: { + usePointStyle: true, + } + }, + elements: { + line: { + borderWidth: 3 + }, + point: { + radius: 5, + borderWidth: 2, + hoverRadius: 7, + hoverBorderWidth: 3 + } + } + } as any; + + return ( +
+ +
+ ); +}; + +export const EnergyPriceChartSkeleton: React.FC = () => { + return ( +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/charts/zoneChart.tsx b/components/charts/zoneChart.tsx new file mode 100644 index 0000000..199a982 --- /dev/null +++ b/components/charts/zoneChart.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; +import 'chart.js/auto'; +import 'chartjs-adapter-moment'; +import 'chartjs-plugin-annotation'; + +interface DataPoint { + date: string; + value: number; +} + +interface Clarification { + date: string; +} + +interface DataClarification { + high: Clarification[]; + mid: Clarification[]; + low: Clarification[]; +} + +interface Props { + energyData: DataPoint[]; + dataClarification: DataClarification; +} + +export const ZoneChart: React.FC = ({ dataClarification }) => { + if (dataClarification.high.length === 0 && dataClarification.mid.length === 0 && dataClarification.low.length === 0) { + return
No data available
; + } + + const highAnnotations = dataClarification.high.map((clarification, index) => { + return { + type: 'line', + mode: 'vertical', + scaleID: 'x', + value: clarification.date, + borderColor: 'red', + borderWidth: 2, + label: { + content: 'High', + enabled: true, + position: 'top' + } + }; + }); + + const midAnnotations = dataClarification.mid.map((clarification, index) => { + return { + type: 'line', + mode: 'vertical', + scaleID: 'x', + value: clarification.date, + borderColor: 'orange', + borderWidth: 2, + label: { + content: 'Mid', + enabled: true, + position: 'top' + } + }; + }); + + const lowAnnotations = dataClarification.low.map((clarification, index) => { + return { + type: 'line', + mode: 'vertical', + scaleID: 'x', + value: clarification.date, + borderColor: 'green', + borderWidth: 2, + label: { + content: 'Low', + enabled: true, + position: 'top' + } + }; + }); + + const annotations = [...highAnnotations, ...midAnnotations, ...lowAnnotations]; + + const chartData = { + labels: [], + datasets: [] + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: 'time', + time: { + parser: 'YYYY-MM-DDTHH:mm:ssZ', + unit: 'hour', + displayFormats: { + hour: 'HH:mm' + }, + tooltipFormat: 'MMM DD, HH:mm' + }, + title: { + display: true, + text: 'Time' + }, + grid: { + display: false + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Price (ct/kWh)' + }, + grid: { + drawBorder: false, + display: false + } + } + }, + plugins: { + annotation: { + annotations: annotations + } + } + } as any; + + return ( +
+ +
+ ); +} + + + +export const ZoneChartSkeleton: React.FC = () => { + return
; +} \ No newline at end of file diff --git a/components/timeframe.tsx b/components/timeframe.tsx new file mode 100644 index 0000000..62bf2ca --- /dev/null +++ b/components/timeframe.tsx @@ -0,0 +1,29 @@ +import { timeframes } from "@/config/timeframes" + +function Button({label, value, selected, setSelected}: ({label: string, value: string, selected: any, setSelected: (value: string) => void})) { + return ( + + ) +} + +export default function TimeframeSelection({selected, setSelected}: ({selected: string, setSelected: (value: string) => void})) { + return ( + + {timeframes.map(timeframe => ( +