Begin web client

This commit is contained in:
Tanner Collin 2022-01-10 07:55:39 +00:00
parent 9829c95c85
commit d169f72327
7 changed files with 12234 additions and 0 deletions

23
client/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

42
client/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "webclient",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"axios": "^0.21.1",
"moment": "^2.29.1",
"react": "^17.0.2",
"react-datetime": "^3.1.1",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"recharts": "^2.0.9",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

36
client/public/index.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Solar Display</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

85
client/src/App.css Normal file
View File

@ -0,0 +1,85 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.container {
max-width: 40em;
margin: 0 auto 3rem auto;
}
h2 {
font-weight: normal;
font-family: sans-serif;
font-size: 1.5em;
margin: 0.25em;
}
.recharts-wrapper p {
color: initial;
font-size: initial;
}
.menu {
overflow: hidden;
background-color: #333;
position: fixed;
bottom: 0;
width: 100%;
z-index: 99;
}
.submenu {
background-color: #666;
max-width: 40em;
margin: 0 auto;
padding-bottom: 0.5rem;
display: flex;
flex-direction: column;
}
.submenu p {
margin: 0 1rem 1rem 1rem;
color: white;
font-size: 1.5rem;
font-family: sans-serif;
}
.submenu-close {
display: flex;
justify-content: right;
}
.menu-container {
max-width: 40em;
margin: 0 auto;
display: flex;
justify-content: space-between;
}
.menu button {
background-color: #333;
height: 2.5rem;
min-width: 3rem;
font-size: 1.5rem;
color: white;
border-radius: 0;
border: 0;
}
.menu button:hover {
background-color: #999;
}
.menu button.active {
background-color: #666;
}
.submenu button {
background-color: #666;
}

382
client/src/App.js Normal file
View File

@ -0,0 +1,382 @@
import React, { useState, useEffect } from 'react';
import { ComposedChart, Bar, Label, LineChart, ReferenceLine, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import axios from 'axios';
import moment from 'moment';
import './App.css';
const durations = [
{id: 0, len: 'Day', win: '10m', full: '10 min'},
{id: 1, len: 'Day', win: '1h', full: '1 hour'},
{id: 2, len: 'Week', win: '1h', full: '1 hour'},
{id: 3, len: 'Week', win: '1d', full: '1 day'},
{id: 4, len: 'Month', win: '1d', full: '1 day'},
{id: 5, len: 'Month', win: '7d', full: '7 day'},
{id: 6, len: 'Year', win: '1d', full: '1 day'},
{id: 7, len: 'Year', win: '30d', full: '30 day'},
];
function Graphs({data, loading}) {
return (
<div className='container'>
<h2>Outside Temperature {loading ? 'Loading...' : ''}</h2>
{data.Outside ?
<ResponsiveContainer width='100%' height={300}>
<LineChart syncId={1} data={data.Outside} margin={{ top: 25, left: 0, right: 30, bottom: 0 }}>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={timeStr => moment(timeStr).format('HH:mm')}
/>
<YAxis
domain={[-40, 40]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' °C'}
labelFormatter={timeStr => moment(timeStr).format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
<ReferenceLine x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue'>
<Label value='Midnight' offset={7} position='top' />
</ReferenceLine>
<Line
type='monotone'
dataKey='temperature_C'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
:
<p>Loading...</p>
}
<h2>Bedroom Temperature {loading ? 'Loading...' : ''}</h2>
{data.Bedroom ?
<ResponsiveContainer width='100%' height={300}>
<LineChart syncId={1} data={data.Bedroom} margin={{ top: 5, left: 0, right: 30, bottom: 0 }}>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={timeStr => moment(timeStr).format('HH:mm')}
/>
<YAxis
domain={[15, 25]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' °C'}
labelFormatter={timeStr => moment(timeStr).format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine y={0} stroke='blue'>
<Label value='Freezing' offset={7} position='bottom' />
</ReferenceLine>
<ReferenceLine x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='temperature_C'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
:
<p>Loading...</p>
}
<h2>Thermostat {loading ? 'Loading...' : ''}</h2>
{data.Venstar ?
<ResponsiveContainer width='100%' height={300}>
<LineChart syncId={1} data={data.Venstar} margin={{ top: 5, left: 0, right: 30, bottom: 0 }}>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={timeStr => moment(timeStr).format('HH:mm')}
/>
<YAxis
domain={[15, 25]}
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' °C'}
labelFormatter={timeStr => moment(timeStr).format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Line
type='monotone'
dataKey='heattemp'
name='Setpoint'
stroke='red'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
type='monotone'
dataKey='spacetemp'
name='Temperature'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
:
<p>Loading...</p>
}
<h2>Gas Usage {loading ? 'Loading...' : ''}</h2>
{data.Gas ?
<ResponsiveContainer width='100%' height={300}>
<ComposedChart syncId={1} data={data.Gas} margin={{ top: 5, left: 0, right: 30, bottom: 0 }}>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={timeStr => moment(timeStr).format('HH:mm')}
/>
<YAxis
yAxisId="right"
domain={[data.Gas[0].consumption_data, data.Gas.slice(-1)[0].consumption_data]}
orientation="right"
hide={true}
/>
<YAxis
yAxisId="left"
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v.toFixed(1) + ' MJ'}
labelFormatter={timeStr => moment(timeStr).format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId="right" x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Bar
yAxisId="left"
type='monotone'
dataKey='delta'
name='Delta'
fill='green'
isAnimationActive={false}
/>
<Line
yAxisId="right"
type='monotone'
dataKey='max'
name='Total'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
:
<p>Loading...</p>
}
<h2>Water Usage {loading ? 'Loading...' : ''}</h2>
{data.Water ?
<ResponsiveContainer width='100%' height={300}>
<ComposedChart syncId={1} data={data.Water} margin={{ top: 5, left: 0, right: 30, bottom: 0 }}>
<XAxis
dataKey='time'
minTickGap={10}
tickFormatter={timeStr => moment(timeStr).format('HH:mm')}
/>
<YAxis
yAxisId="right"
domain={[data.Water[0].consumption_data, data.Water.slice(-1)[0].consumption_data]}
orientation="right"
hide={true}
/>
<YAxis
yAxisId="left"
/>
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip
formatter={v => v + ' L'}
labelFormatter={timeStr => moment(timeStr).format('ddd MMM DD h:mm A')}
separator=': '
/>
<ReferenceLine yAxisId="right" x={moment().startOf('day').toISOString().replace('.000', '')} stroke='blue' />
<Bar
yAxisId="left"
type='monotone'
dataKey='delta'
name='Delta'
fill='green'
isAnimationActive={false}
/>
<Line
yAxisId="right"
type='monotone'
dataKey='max'
name='Total'
stroke='black'
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
:
<p>Loading...</p>
}
</div>
);
}
function Menu({duration, setDuration, end, setEnd, setLoading}) {
const [submenu, setSubmenu] = useState(false);
const chooseDuration = (x) => {
setLoading(true);
setSubmenu(false);
setDuration(x);
};
return (
<div className='menu'>
{!!submenu &&<div className='submenu'>
<div className='submenu-close'>
<button onClick={() => setSubmenu(false)}>×</button>
</div>
{submenu === 'end' &&
<>
<p>Choose end date:</p>
<p>epic date picker</p>
</>
}
{submenu === 'duration' &&
durations.map(x =>
<button id={x.id} onClick={() => chooseDuration(x)}>Last {x.len} / {x.full} data</button>
)
}
</div>}
<div className='menu-container'>
<button onClick={() => setDuration('day')}>&lt;</button>
<button
onClick={() => setSubmenu('end')}
className={submenu === 'end' ? 'active' : ''}
>
{end.format('ddd MMM DD')}
</button>
<button
onClick={() => setSubmenu('duration')}
className={submenu === 'duration' ? 'active' : ''}
>
{duration.len} / {duration.win}
</button>
<button onClick={() => setDuration('day')}>&gt;</button>
</div>
</div>
);
}
function App() {
const [duration, setDuration] = useState(durations[0]);
const [loading, setLoading] = useState(false);
const [end, setEnd] = useState(moment());
const [data, setData] = useState(false);
const setupGetter = (measurement, name) => {
const get = async() => {
try {
const params = { end: end.unix(), duration: duration.len.toLowerCase(), window: duration.win };
const res = await axios.get(
'https://sensors-api.dns.t0.vc/history/'+measurement+'/'+name,
{ params: params },
);
setData((d) => ({ ...d, [name]: res.data }));
setLoading(false);
} catch (error) {
console.log(error);
}
};
get();
const interval = setInterval(get, 30000);
return () => clearInterval(interval);
};
useEffect(() => {
return setupGetter('temperature', 'Outside');
}, [duration]);
useEffect(() => {
return setupGetter('temperature', 'Bedroom');
}, [duration]);
useEffect(() => {
return setupGetter('thermostat', 'Venstar');
}, [duration]);
useEffect(() => {
return setupGetter('ertscm', 'Gas');
}, [duration]);
useEffect(() => {
return setupGetter('ertscm', 'Water');
}, [duration]);
return (
<div>
<Menu
duration={duration}
setDuration={setDuration}
end={end}
setEnd={setEnd}
setLoading={setLoading}
/>
<Graphs
data={data}
loading={loading}
/>
</div>
);
}
export default App;

10
client/src/index.js Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

11656
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff