├── .nvmrc
├── src
├── Graph.css
├── Results.css
├── ChartButtons.css
├── FileOpener.css
├── index.css
├── chart-types.js
├── interval-types.js
├── setupTests.js
├── initial-app-state.js
├── time-cuts.js
├── Button.css
├── chart-options.js
├── FileApiWarning.jsx
├── index.js
├── date-checkers.js
├── App.css
├── Button.jsx
├── date-formatters.js
├── GraphContainer.jsx
├── FileOpener.jsx
├── Slider.test.js
├── chart-maps.js
├── Button.test.js
├── Slider.jsx
├── Graph.jsx
├── Results.test.js
├── green-button-json.js
├── Slider.css
├── Results.jsx
├── App.test.js
├── ChartButtons.jsx
├── App.jsx
├── time-cutter.js
├── __snapshots__
│ └── App.test.js.snap
├── registerServiceWorker.js
└── chart-data.js
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── .flowconfig
├── .travis.yml
├── helpers
└── visit.js
├── .gitignore
├── package.json
├── README.md
└── LICENSE
/.nvmrc:
--------------------------------------------------------------------------------
1 | 8
2 |
--------------------------------------------------------------------------------
/src/Graph.css:
--------------------------------------------------------------------------------
1 | .graph-wrapper {
2 | height: 300px;
3 | }
4 |
5 |
--------------------------------------------------------------------------------
/src/Results.css:
--------------------------------------------------------------------------------
1 | .results {
2 | padding: 20px 0 20px 0;
3 | }
4 |
--------------------------------------------------------------------------------
/src/ChartButtons.css:
--------------------------------------------------------------------------------
1 | .button-set {
2 | padding: 5px 0 0 0
3 | }
4 |
--------------------------------------------------------------------------------
/src/FileOpener.css:
--------------------------------------------------------------------------------
1 | .file-opener {
2 | padding: 10px 0 10px 0;
3 | }
4 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mtmckenna/green-button-grapher/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 |
3 | [include]
4 |
5 | [libs]
6 |
7 | [lints]
8 |
9 | [options]
10 |
--------------------------------------------------------------------------------
/src/chart-types.js:
--------------------------------------------------------------------------------
1 | export default {
2 | COST: 'chart-type-cost',
3 | POWER_USAGE: 'chart-type-power-usage'
4 | }
5 |
--------------------------------------------------------------------------------
/src/interval-types.js:
--------------------------------------------------------------------------------
1 | export default {
2 | ACTUAL: 'interval-type-actual',
3 | THEORETICAL: 'interval-type-theoretical'
4 | }
5 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | configure({ adapter: new Adapter() });
5 |
--------------------------------------------------------------------------------
/src/initial-app-state.js:
--------------------------------------------------------------------------------
1 | import CHART_TYPES from './chart-types';
2 | import TIME_CUTS from './time-cuts';
3 |
4 | export default {
5 | address: '',
6 | multiplier: 1.0,
7 | chartType: CHART_TYPES.POWER_USAGE,
8 | timeCut: TIME_CUTS.AVG_DAY,
9 | loading: true
10 | };
11 |
--------------------------------------------------------------------------------
/src/time-cuts.js:
--------------------------------------------------------------------------------
1 | export default {
2 | AVG_DAY: 'avg-day',
3 | AVG_WEEKEND_DAY: 'avg-weekend-day',
4 | AVG_WEEK_DAY: 'avg-week-day',
5 | AVG_PEAK_TIME: 'avg-peak-time',
6 | ALL_TIME: 'all-time',
7 | LAST_30_DAYS: 'last-30-days',
8 | LAST_7_DAYS: 'last-7-days',
9 | MOST_RECENT_24_HOURS: 'most-recent-24-hours'
10 | }
11 |
--------------------------------------------------------------------------------
/src/Button.css:
--------------------------------------------------------------------------------
1 | .button {
2 | background-color: #4CAF50;
3 | border-radius: 2px;
4 | padding: 5px;
5 | margin: 2px;
6 | border: none;
7 | color: white;
8 | font-size: 14px;
9 | text-align: center;
10 | text-decoration: none;
11 | display: inline-block;
12 | cursor: pointer;
13 | }
14 |
15 | .button--active {
16 | background-color: #6699ff;
17 | }
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | cache:
3 | directories:
4 | - node_modules
5 | script:
6 | - npm run build
7 | - npm test
8 | addons:
9 | apt:
10 | packages:
11 | - xvfb
12 | before_install:
13 | - export TZ=America/Los_Angeles
14 | install:
15 | - export DISPLAY=':99.0'
16 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
17 | - npm install
18 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/helpers/visit.js:
--------------------------------------------------------------------------------
1 | import nightmare from 'nightmare';
2 | import url from 'url';
3 |
4 | const BASE_URL = url.format({
5 | protocol : process.env.PROTOCOL || 'http',
6 | hostname : process.env.HOST || 'localhost',
7 | port : process.env.PORT || 3001
8 | });
9 |
10 | export default function (path='', query={}) {
11 | const location = url.resolve(BASE_URL, path);
12 |
13 | return nightmare().goto(location);
14 | }
15 |
--------------------------------------------------------------------------------
/src/chart-options.js:
--------------------------------------------------------------------------------
1 | export default {
2 | responsive: true,
3 | maintainAspectRatio: false,
4 | legend: {
5 | position: 'bottom'
6 | },
7 | animation: {
8 | duration: 0, // disable animations
9 | },
10 | scales: {
11 | yAxes: [{
12 | ticks: {
13 | beginAtZero: true
14 | }
15 | }]
16 | },
17 | elements: {
18 | line: {
19 | tension: 0, // disabling bezier curves improves performance
20 | }
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | rsync.sh
2 | Session.vim
3 | Guardfile
4 | tags
5 | Gemfile
6 | Gemfile.lock
7 | save/
8 | # See https://help.github.com/ignore-files/ for more about ignoring files.
9 |
10 | # dependencies
11 | /node_modules
12 |
13 | # testing
14 | /coverage
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | .env.local
22 | .env.development.local
23 | .env.test.local
24 | .env.production.local
25 |
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 |
30 | package-lock.json
31 |
32 | .env
33 |
--------------------------------------------------------------------------------
/src/FileApiWarning.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | export default class FileApiWarning extends Component {
4 | render() {
5 | return (
6 |
7 |
Sorry! Your web browser doesn't support HTML 5 file uploads.
8 | If you'd like to graph your own Green Button data, please try using either
Google Chrome or
9 |
Firefox .
10 |
11 | );
12 | }
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App.jsx';
5 | import bugsnag from 'bugsnag-js'
6 | import bugsnagReact from 'bugsnag-react'
7 |
8 | const bugsnagClient = bugsnag({
9 | apiKey: process.env.REACT_APP_BUGSNAG_KEY || 'no-key',
10 | notifyReleaseStages: ['production']
11 | });
12 |
13 | const ErrorBoundary = bugsnagClient.use(bugsnagReact(React))
14 |
15 | ReactDOM.render(
16 |
17 |
18 | ,
19 | document.getElementById('root')
20 | );
21 |
22 |
--------------------------------------------------------------------------------
/src/date-checkers.js:
--------------------------------------------------------------------------------
1 | function peakDate(date) {
2 | const day = date.getDay();
3 | const hour = date.getHours();
4 | const weekday = day > 0 && day < 6;
5 | const peakHours = hour >= 13 && hour <= 19;
6 | return weekday && peakHours;
7 | }
8 |
9 | function weekend(date) {
10 | return date.getDay() === 6 || date.getDay() === 0;
11 | }
12 |
13 | function sameDay(date1, date2) {
14 | return date1.getDate() === date2.getDate()
15 | && date1.getMonth() === date2.getMonth()
16 | && date1.getFullYear() === date2.getFullYear();
17 | }
18 |
19 | export { peakDate, weekend, sameDay };
20 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | height: 100%;
3 | width: 100%;
4 | }
5 |
6 | body {
7 | box-sizing: border-box;
8 | background-color: #c7d6ce;
9 | font-family: "Trebuchet MS", "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Tahoma, sans-serif;
10 | padding: 0 20px 0 20px;
11 | }
12 |
13 | .loading {
14 | padding: 20px;
15 | font-size: 20px;
16 | text-align: center;
17 | }
18 |
19 | .address {
20 | text-align: center;
21 | padding: 20px;
22 | }
23 |
24 | .address > h1 {
25 | font-size: 20px;
26 | }
27 |
28 | .source-code {
29 | text-align: center;
30 | padding: 0 0 10px 0;
31 | }
32 |
33 |
--------------------------------------------------------------------------------
/src/Button.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import './Button.css';
3 |
4 | export default class Button extends Component {
5 | get active() {
6 | return this.props.type === this.props.currentType;
7 | }
8 |
9 | get className() {
10 | let activeClass = this.active ? 'button--active' : '';
11 | return `button ${activeClass}`;
12 | }
13 |
14 | render() {
15 | return (
16 |
20 | {this.props.children}
21 |
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/date-formatters.js:
--------------------------------------------------------------------------------
1 | function datePad(number) {
2 | let string = number.toString();
3 | while (string.length < 2) string = 0 + string;
4 | return string;
5 | }
6 |
7 | function formattedDay(date) {
8 | let hour = date.getHours();
9 | let amPm = hour >= 12 ? 'PM' : 'AM';
10 | hour = hour % 12;
11 | hour = hour === 0 ? 12 : hour;
12 | return `${datePad(hour)}:00 ${amPm}`;
13 | }
14 |
15 | function formattedFullDate(date) {
16 | const day = date.getDate();
17 | const month = date.getMonth() + 1;
18 | const year = date.getFullYear();
19 | return `${year}/${datePad(month)}/${datePad(day)}`;
20 | }
21 |
22 | export { formattedDay, formattedFullDate }
23 |
24 |
--------------------------------------------------------------------------------
/src/GraphContainer.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Graph from './Graph';
3 | import Results from './Results';
4 | import ChartData from './chart-data';
5 |
6 | export default class GraphContainer extends Component {
7 | render() {
8 | const chartData = chartDataFromProps(this.props);
9 |
10 | return (
11 |
12 |
13 |
17 |
18 | );
19 | }
20 | }
21 |
22 | function chartDataFromProps(props) {
23 | return new ChartData(props.intervals, props.chartType, props.timeCut, props.multiplier);
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/src/FileOpener.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import './FileOpener.css';
3 |
4 | export default class FileOpener extends Component {
5 | handleFileSelected = (event) => {
6 | let files = event.target.files;
7 | let reader = new FileReader();
8 | reader.onloadend = this.handleFileLoaded;
9 | reader.readAsText(files[0]);
10 | this.props.handleFileSelected(event);
11 | }
12 |
13 | handleFileLoaded = (event) => {
14 | let xmlString = event.target.result;
15 | this.props.handleFileLoaded(xmlString);
16 | }
17 |
18 | render() {
19 | return (
20 |
21 |
22 |
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Slider.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Slider from './Slider';
4 | import { shallow } from 'enzyme';
5 | import CHART_TYPES from './chart-types';
6 |
7 | describe('When the slider has been slid', function() {
8 | test('it shows the theoretical reduction', function() {
9 | const wrapper = shallow( );
10 |
11 | expect(wrapper.text()).not.toContain('Theoretical power use reduction');
12 | wrapper.setProps({multiplier: 0.9});
13 | expect(wrapper.text()).toContain('Theoretical power use reduction: 10%');
14 |
15 | wrapper.setProps({multiplier: 1.1});
16 | expect(wrapper.text()).toContain('Theoretical power use reduction: -10%');
17 |
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "green-button-grapher",
3 | "homepage": ".",
4 | "version": "0.1.0",
5 | "private": true,
6 | "dependencies": {
7 | "bugsnag-js": "^4.2.0",
8 | "bugsnag-react": "^1.0.0",
9 | "chart.js": "^2.7.1",
10 | "enzyme": "^3.3.0",
11 | "enzyme-adapter-react-16": "^1.1.1",
12 | "gh-pages": "^1.0.0",
13 | "react": "^16.2.0",
14 | "react-dom": "^16.2.0",
15 | "react-scripts": "1.1.0",
16 | "react-test-renderer": "^16.2.0"
17 | },
18 | "scripts": {
19 | "predeploy": "npm run build",
20 | "deploy": "NODE_ENV=production gh-pages -d build",
21 | "start": "react-scripts start",
22 | "build": "react-scripts build",
23 | "test": "react-scripts test --env=jsdom",
24 | "eject": "react-scripts eject"
25 | },
26 | "devDependencies": {
27 | "express": "^4.16.2",
28 | "flow-bin": "^0.54.0"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/chart-maps.js:
--------------------------------------------------------------------------------
1 | import CHART_TYPES from './chart-types';
2 | import INTERVAL_TYPES from './interval-types';
3 |
4 | const CHART_TYPE_TO_PROPERTY_MAP = {};
5 |
6 | CHART_TYPE_TO_PROPERTY_MAP[CHART_TYPES.COST] = {
7 | dataType: 'cost',
8 | chartTitle: 'Cost of Power Over Time'
9 | };
10 |
11 | CHART_TYPE_TO_PROPERTY_MAP[CHART_TYPES.POWER_USAGE] = {
12 | dataType: 'value',
13 | chartTitle: 'Power Usage Over Time'
14 | };
15 |
16 | const INTERVAL_TYPE_TO_PROPERTY_MAP = {};
17 |
18 | INTERVAL_TYPE_TO_PROPERTY_MAP[INTERVAL_TYPES.ACTUAL] = {
19 | backgroundColor: 'rgb(102,153,255)',
20 | borderColor: 'rgb(102,153,255)',
21 | titlePrefix: 'Actual'
22 | };
23 |
24 | INTERVAL_TYPE_TO_PROPERTY_MAP[INTERVAL_TYPES.THEORETICAL] = {
25 | backgroundColor: 'rgb(0, 0, 132)',
26 | borderColor: 'rgb(0, 0, 132)',
27 | titlePrefix: 'Theoretical'
28 | };
29 |
30 | export { CHART_TYPE_TO_PROPERTY_MAP, INTERVAL_TYPE_TO_PROPERTY_MAP };
31 |
--------------------------------------------------------------------------------
/src/Button.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Button from './Button';
4 | import { shallow } from 'enzyme';
5 |
6 | describe('When the button type is the currently selected type', function() {
7 | test('it is active', function() {
8 | const wrapper = shallow( );
9 | let button = wrapper.instance();
10 | expect(button.active).toBeFalsy();
11 | wrapper.setProps({currentType: 'cat'});
12 | expect(button.active).toBeTruthy();
13 | });
14 | });
15 |
16 | describe('When active', function() {
17 | test('it has the active class', function() {
18 | const wrapper = shallow( );
19 | let button = wrapper.instance();
20 | expect(button.className).toEqual('button ');
21 | wrapper.setProps({currentType: 'cat'});
22 | expect(button.className).toEqual('button button--active');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
14 | Green Button Grapher
15 |
16 |
17 |
18 | You need to enable JavaScript to run this app.
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Green Button Grapher [](https://travis-ci.org/mtmckenna/green-button-grapher)
2 |
3 | ## What is This Thing
4 |
5 | Green Button Grapher parses and charts [Green Button Data](http://www.greenbuttondata.org/).
6 |
7 | ## How to Use It
8 |
9 | First, download your Green Button Data from your power utility company's website. For example, [here are instructions for downloading Green Button Data from PG&E](https://energy.gov/sites/prod/files/Using%20Green%20Button%20Download.pdf).
10 |
11 | Next, visit the [hosted version of Green Button Grapher on GitHub](https://mtmckenna.github.io/green-button-grapher/) and upload your Green Button file. Your data will then be displayed as a chart. Note that the file is only parsed by your web browser and not sent to a server.
12 |
13 | ## How to Build It
14 |
15 | ### Building for Development
16 |
17 | `yarn start`
18 |
19 | ### Running Tests
20 |
21 | `yarn test`
22 |
23 | ### Building for Distribution
24 |
25 | `yarn build`
26 |
27 |
--------------------------------------------------------------------------------
/src/Slider.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import './Slider.css';
3 |
4 | export default class Slider extends Component {
5 | handleSliderMoved = (event) => {
6 | const multiplier = Number(event.currentTarget.value);
7 | this.props.handleSliderMoved(multiplier);
8 | }
9 |
10 | get powerUseReduction() {
11 | if (this.props.multiplier === 1.0) return null;
12 | return percentageFromMultiplier(this.props.multiplier);
13 | }
14 |
15 | render() {
16 | return (
17 |
18 |
21 |
22 | {this.powerUseReduction &&
23 |
24 | Theoretical power use reduction: {this.powerUseReduction}
25 |
26 | }
27 |
28 | );
29 | }
30 | }
31 |
32 | function percentageFromMultiplier(multiplier) {
33 | let percentage = ((1.0 - multiplier) * 100).toFixed(0);
34 | return `${percentage}%`;
35 | }
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Matt McKenna
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Graph.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Chart from 'chart.js';
3 | import chartOptions from './chart-options';
4 | import './Graph.css';
5 |
6 | export default class Graph extends Component {
7 | componentDidMount() {
8 | this.updateChart();
9 | }
10 |
11 | componentDidUpdate() {
12 | this.updateChart();
13 | }
14 |
15 | get ctx() {
16 | if (!this.canvas) return null;
17 | return this.canvas.getContext('2d');
18 | }
19 |
20 | updateChart() {
21 | if (this.chart) this.chart.destroy();
22 | if (!this.ctx) return;
23 |
24 | this.chart = new Chart(this.ctx, {
25 | type: 'line',
26 | data: dataFromProps(this.props),
27 | options: chartOptions
28 | });
29 | }
30 |
31 | render() {
32 | return (
33 |
34 | this.canvas = canvas}>
35 |
36 |
37 | );
38 | }
39 | };
40 |
41 | function dataFromProps(props) {
42 | const starts = props.chartData.starts;
43 | const datasets = props.chartData.datasets;
44 | return { labels: starts, datasets: datasets };
45 | }
46 |
--------------------------------------------------------------------------------
/src/Results.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Results from './Results';
4 | import { shallow } from 'enzyme';
5 | import CHART_TYPES from './chart-types';
6 |
7 | describe('When there are power savings to show', function() {
8 | test('it shows the results', function() {
9 | const wrapper = shallow( );
14 |
15 | expect(wrapper.text()).toContain('Total: 10 Wh');
16 | expect(wrapper.text()).toContain('Peak: 5 Wh');
17 |
18 | wrapper.setProps({
19 | totalTheoretical: 8,
20 | totalPeakTheoretical: 1,
21 | });
22 |
23 | expect(wrapper.text()).toContain('Total: 10 Wh (2 Wh saved)');
24 | expect(wrapper.text()).toContain('Peak: 5 Wh (4 Wh saved)');
25 | });
26 | });
27 |
28 | describe('When there are dollar savings to show', function() {
29 | test('it shows the results', function() {
30 | const wrapper = shallow( );
35 |
36 | expect(wrapper.text()).toContain('Total: $10.00');
37 | expect(wrapper.text()).toContain('Peak: $5.00');
38 |
39 | wrapper.setProps({
40 | totalTheoretical: 8,
41 | totalPeakTheoretical: 1,
42 | });
43 |
44 | expect(wrapper.text()).toContain('Total: $10.00 ($2.00 saved)');
45 | expect(wrapper.text()).toContain('Peak: $5.00 ($4.00 saved)');
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/green-button-json.js:
--------------------------------------------------------------------------------
1 | const COST_DIVISOR = 100000;
2 | const NAME_SPACE = '*';
3 |
4 | export default class GreenButtonJson {
5 | constructor(xml = document.implementation.createDocument(null, 'feed')) {
6 | this.xml = xml;
7 | }
8 |
9 | get address() {
10 | if (this._address) return this._address;
11 | this._address = this.xml.querySelector('entry > title').innerHTML;
12 | return this._address;
13 | }
14 |
15 | get intervals() {
16 | if (this._intervals) return this._intervals;
17 | let xmlIntervals = Array.from(this.xml.querySelectorAll('IntervalReading'));
18 |
19 | this._intervals = xmlIntervals.map((interval) => {
20 | let costElement = interval.getElementsByTagNameNS(NAME_SPACE, 'cost')[0];
21 |
22 | return {
23 | start: dateFromStart(interval.getElementsByTagNameNS(NAME_SPACE, 'start')[0].innerHTML),
24 | value: Number(interval.getElementsByTagNameNS(NAME_SPACE, 'value')[0].innerHTML) * Math.pow(10, this.powerOfTenMultiplier),
25 | cost: costElement ? Number(costElement.innerHTML) / COST_DIVISOR : 0.0
26 | }
27 | });
28 |
29 | return this._intervals;
30 | }
31 |
32 | get powerOfTenMultiplier() {
33 | if (this._powerOfTenMultiplier) return this._powerOfTenMultiplier;
34 |
35 | let powerOfTenElement = this.xml.querySelector('ReadingType > powerOfTenMultiplier');
36 | this._powerOfTenMultiplier = powerOfTenElement ? Number(powerOfTenElement.innerHTML) : null;
37 |
38 | console.log(this._powerOfTenMultiplier);
39 |
40 | return this._powerOfTenMultiplier;
41 | }
42 |
43 | }
44 |
45 | function dateFromStart(startString) {
46 | const startInMs = Number(startString) * 1000;
47 | return new Date(startInMs);
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/src/Slider.css:
--------------------------------------------------------------------------------
1 | .reduction-rate {
2 | padding: 5px 0 5px 0;
3 | text-align: center;
4 | }
5 |
6 | input[type=range].slider {
7 | -webkit-appearance: none;
8 | width: 100%;
9 | margin: 0 0;
10 | }
11 |
12 | input[type=range].slider:focus {
13 | outline: none;
14 | }
15 |
16 | input[type=range].slider::-webkit-slider-runnable-track {
17 | width: 100%;
18 | height: 27px;
19 | cursor: pointer;
20 | background: #484d4d;
21 | }
22 |
23 | input[type=range].slider::-webkit-slider-thumb {
24 | height: 27px;
25 | width: 18px;
26 | border-radius: 2px;
27 | background: #4caf50;
28 | cursor: pointer;
29 | -webkit-appearance: none;
30 | margin-top: -0.0px;
31 | }
32 |
33 | input[type=range].slider:focus::-webkit-slider-runnable-track {
34 | background: #545a5a;
35 | }
36 |
37 | input[type=range].slider::-moz-range-track {
38 | width: 100%;
39 | height: 27px;
40 | cursor: pointer;
41 | background: #484d4d;
42 | border-radius: 0px;
43 | }
44 |
45 | input[type=range].slider::-moz-range-thumb {
46 | height: 27px;
47 | width: 18px;
48 | border-radius: 2px;
49 | background: #4caf50;
50 | cursor: pointer;
51 | }
52 |
53 | input[type=range].slider::-ms-track {
54 | width: 100%;
55 | height: 27px;
56 | cursor: pointer;
57 | background: transparent;
58 | border-color: transparent;
59 | color: transparent;
60 | }
61 |
62 | input[type=range].slider::-ms-fill-lower {
63 | background: #3c4040;
64 | border: 0px solid #010101;
65 | border-radius: 0px;
66 | }
67 |
68 | input[type=range].slider::-ms-fill-upper {
69 | background: #484d4d;
70 | }
71 |
72 | input[type=range].slider::-ms-thumb {
73 | height: 27px;
74 | width: 18px;
75 | border-radius: 2px;
76 | background: #4caf50;
77 | cursor: pointer;
78 | height: 26px;
79 | }
80 |
81 | input[type=range].slider:focus::-ms-fill-lower {
82 | background: #484d4d;
83 | }
84 |
85 | input[type=range].slider:focus::-ms-fill-upper {
86 | background: #545a5a;
87 | }
88 |
--------------------------------------------------------------------------------
/src/Results.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import CHART_TYPES from './chart-types';
3 | import './Results.css';
4 |
5 | export default class Results extends Component {
6 | get formatter() {
7 | return CHART_TYPE_TO_FORMATTER_MAP[this.props.chartType];
8 | }
9 |
10 | get total() {
11 | let total = this.totalAmount;
12 | if (this.props.totalTheoretical) total += ` (${this.totalSaved} saved)`;
13 | return total;
14 | }
15 |
16 | get peak() {
17 | let peak = this.peakAmount;
18 | if (this.props.totalPeakTheoretical) peak += ` (${this.peakSaved} saved)`;
19 | return peak;
20 | }
21 |
22 | get totalAmount() {
23 | return this.formatter(this.props.total);
24 | }
25 |
26 | get peakAmount() {
27 | return this.formatter(this.props.totalPeak);
28 | }
29 |
30 | get totalSaved() {
31 | let saved = this.props.totalTheoretical ? this.props.total - this.props.totalTheoretical : 0;
32 | return this.formatter(saved);
33 | }
34 |
35 | get peakSaved() {
36 | let saved = this.props.totalPeakTheoretical ? this.props.totalPeak - this.props.totalPeakTheoretical : 0;
37 | return this.formatter(saved);
38 | }
39 |
40 | render() {
41 | return (
42 |
43 |
44 |
Total: {this.total}
45 |
Peak: {this.peak}
46 |
47 |
48 | );
49 | }
50 | }
51 |
52 | const CHART_TYPE_TO_FORMATTER_MAP = {};
53 | CHART_TYPE_TO_FORMATTER_MAP[CHART_TYPES.COST] = formattedDollarAmount;
54 | CHART_TYPE_TO_FORMATTER_MAP[CHART_TYPES.POWER_USAGE] = formattedPowerUsageAmount;
55 |
56 | function formattedPowerUsageAmount(number) {
57 | return numberAsLocaleString(formattedNumber(number, 0)) + ' Wh';
58 | }
59 |
60 | function formattedDollarAmount(number) {
61 | return '$' + numberAsLocaleString(formattedNumber(number, 2), { minimumFractionDigits: 2 });
62 | }
63 |
64 | function numberAsLocaleString(number, options) {
65 | return Number(number).toLocaleString(undefined, options);
66 | }
67 |
68 | function formattedNumber(number, sigFigs) {
69 | return Number(number).toFixed(sigFigs);
70 | }
71 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import nightmare from 'nightmare'
5 | require('nightmare-upload')(nightmare);
6 | import { shallow } from 'enzyme';
7 | import renderer from 'react-test-renderer';
8 |
9 | import url from 'url'
10 | import visit from '../helpers/visit'
11 |
12 | it('renders without crashing', () => {
13 | const div = document.createElement('div');
14 | ReactDOM.render( , div);
15 | });
16 |
17 | it('renders correctly', () => {
18 | const tree = renderer.create(
19 |
20 | ).toJSON();
21 | expect(tree).toMatchSnapshot();
22 | });
23 |
24 | describe('When the app boots', function () {
25 | let server;
26 | beforeEach(function() { server = runStaticServer(); });
27 | afterEach(function() { server.close(); });
28 |
29 | test('it displays test data and can parse a new file', async function () {
30 | const SECOND_FILE_PATH = '/public/data/Mountain_Single_family_Jan_1_2011_to_Jan_1_2012.xml';
31 | const FULL_SECOND_FILE_PATH = `${process.cwd()}${SECOND_FILE_PATH}`;
32 |
33 | let page = visit('/index.html');
34 |
35 | let initialText = await page
36 | .wait(() => document.body.textContent.includes('123 SAMPLE ST BERKELEY CA 94707-2701'))
37 | .click("button[data-type='chart-type-cost']")
38 | .evaluate(() => document.body.textContent);
39 |
40 | expect(initialText).toContain('Total: $1.30');
41 | expect(initialText).toContain('Peak: $0.45');
42 |
43 | page.upload("input[type='file']", FULL_SECOND_FILE_PATH);
44 |
45 | let textAfterUpload = await page
46 | .wait(() => document.body.textContent.includes('Mountain Single-family'))
47 | .click("button[data-type='chart-type-power-usage']")
48 | .evaluate(() => document.body.textContent)
49 | .end();
50 |
51 | expect(textAfterUpload).toContain('Total: 24,380 Wh');
52 | expect(textAfterUpload).toContain('Peak: 5,339 Wh');
53 | });
54 | });
55 |
56 | describe('When loading a file', function() {
57 | test('it shows the loading screen', function() {
58 | const wrapper = shallow( );
59 | wrapper.setState({ loading: false });
60 | expect(wrapper.text()).not.toContain('Loading');
61 | wrapper.setState({ loading: true });
62 | expect(wrapper.text()).toContain('Loading');
63 | });
64 | });
65 |
66 | function runStaticServer() {
67 | let express = require('express');
68 | let app = express();
69 | app.use(express.static('build'));
70 | return app.listen(3001);
71 | }
72 |
--------------------------------------------------------------------------------
/src/ChartButtons.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import Button from './Button';
3 | import CHART_TYPES from './chart-types';
4 | import TIME_CUTS from './time-cuts';
5 | import './ChartButtons.css';
6 |
7 | export default class ChartButtons extends Component {
8 | changeChartType = (event) => {
9 | this.props.changeChartType(event.target.dataset.type);
10 | }
11 |
12 | changeTimeCut = (event) => {
13 | this.props.changeTimeCut(event.target.dataset.type);
14 | }
15 |
16 | timeButtonProps(type) {
17 | return {
18 | type: type,
19 | onClick: this.changeTimeCut,
20 | currentType: this.props.currentTimeCut
21 | };
22 | }
23 |
24 | chartTypeProps(type) {
25 | return {
26 | type: type,
27 | onClick: this.changeChartType,
28 | currentType: this.props.currentChartType
29 | };
30 | }
31 |
32 | render() {
33 | return (
34 |
35 |
36 |
37 | Raw Data
38 |
39 | Most Recent 24 Hours
40 |
41 |
42 | Last 7 Days
43 |
44 |
45 | Last 30 Days
46 |
47 |
48 | All Time
49 |
50 |
51 |
52 |
53 |
54 |
55 | Averages
56 |
57 | Day
58 |
59 |
60 | Weekend Day
61 |
62 |
63 | Week Day
64 |
65 |
66 | Peak Time
67 |
68 |
69 |
70 |
71 |
72 |
73 | Chart Type
74 |
75 | Cost
76 |
77 |
78 | Power Usage
79 |
80 |
81 |
82 |
83 | );
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import './App.css';
4 | import ChartButtons from './ChartButtons';
5 | import GraphContainer from './GraphContainer';
6 | import Slider from './Slider';
7 | import FileOpener from './FileOpener';
8 | import FileApiWarning from './FileApiWarning';
9 | import sampleData from './sample-data';
10 | import GreenButtonJson from './green-button-json';
11 | import INITIAL_APP_STATE from './initial-app-state';
12 |
13 | class App extends Component {
14 | constructor(props) {
15 | super(props);
16 | this.state = INITIAL_APP_STATE;
17 | }
18 |
19 | componentDidMount() {
20 | this.handleFileLoaded(sampleData);
21 | }
22 |
23 | get hasFileApi() {
24 | return !!window.File && !!window.FileReader && !!window.FileList && !!window.Blob;
25 | }
26 |
27 | handleFileLoaded = (xmlString) => {
28 | let greenButtonJson = greenButtonJsonFromXmlString(xmlString);
29 | this.setState({
30 | loading: false,
31 | address: greenButtonJson.address,
32 | intervals: greenButtonJson.intervals
33 | });
34 | }
35 |
36 | handleFileSelected = () => {
37 | this.setState({ loading: true });
38 | }
39 |
40 | handleSliderMoved = (multiplier) => {
41 | this.setState({ multiplier: multiplier });
42 | }
43 |
44 | changeChartType = (chartType) => {
45 | this.setState({ chartType: chartType });
46 | }
47 |
48 | changeTimeCut = (timeCut) => {
49 | this.setState({ timeCut: timeCut });
50 | }
51 |
52 | render() {
53 | if (!this.hasFileApi) return ;
54 | if (this.state.loading) return Loading...
;
55 |
56 | return (
57 |
58 |
Green Button Data for {this.state.address}
59 |
65 |
69 |
75 |
79 |
82 |
83 | );
84 | }
85 | }
86 |
87 | function greenButtonJsonFromXmlString(xmlString) {
88 | let xml = new DOMParser().parseFromString(xmlString, 'text/xml');
89 | return new GreenButtonJson(xml);
90 | }
91 |
92 | export default App;
93 |
--------------------------------------------------------------------------------
/src/time-cutter.js:
--------------------------------------------------------------------------------
1 | import TIME_CUTS from './time-cuts';
2 | import { peakDate, weekend, sameDay } from './date-checkers';
3 |
4 | const TIME_CUTS_TO_CUTTER_MAP = {};
5 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.AVG_DAY] = avgDayCutter;
6 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.AVG_WEEKEND_DAY] = avgWeekendDayCutter;
7 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.AVG_WEEK_DAY] = avgWeekDayCutter;
8 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.AVG_PEAK_TIME] = avgPeakTimeCutter;
9 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.ALL_TIME] = allTimeCutter;
10 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.MOST_RECENT_24_HOURS] = mostRecent24HoursCutter;
11 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.LAST_7_DAYS] = last7DaysCutter;
12 | TIME_CUTS_TO_CUTTER_MAP[TIME_CUTS.LAST_30_DAYS] = last30DaysCutter;
13 |
14 | // Make the average day a Monday so it will have peak hours
15 | const avgDayDate = new Date('2017/9/18');
16 |
17 | export default class TimeCutter {
18 | constructor(intervals, timeCut) {
19 | this.originalIntervals = intervals;
20 | this.timeCut = timeCut;
21 | }
22 |
23 | get intervals() {
24 | return TIME_CUTS_TO_CUTTER_MAP[this.timeCut](this.originalIntervals);
25 | }
26 | };
27 |
28 | function mostRecent24HoursCutter(originalIntervals) {
29 | let mostRecentDate = originalIntervals[originalIntervals.length - 1].start;
30 | return originalIntervals.filter(function(interval) {
31 | return sameDay(mostRecentDate, interval.start);
32 | });
33 | }
34 |
35 | function last7DaysCutter(originalIntervals) {
36 | return previousDaysCutter(originalIntervals, 7);
37 | }
38 |
39 | function last30DaysCutter(originalIntervals) {
40 | return previousDaysCutter(originalIntervals, 30);
41 | }
42 |
43 | function previousDaysCutter(originalIntervals, numberOfDays) {
44 | let date = originalIntervals[originalIntervals.length - 1].start;
45 | date.setDate(date.getDate() - numberOfDays);
46 | return originalIntervals.filter(function(interval) {
47 | return interval.start >= date;
48 | });
49 | }
50 |
51 | function avgWeekendDayCutter(originalIntervals) {
52 | return avgDayCutter((originalIntervals.filter(function(interval) {
53 | return weekend(interval.start);
54 | })));
55 | }
56 |
57 | function avgWeekDayCutter(originalIntervals) {
58 | return avgDayCutter((originalIntervals.filter(function(interval) {
59 | return !weekend(interval.start);
60 | })));
61 | }
62 |
63 | function avgPeakTimeCutter(originalIntervals) {
64 | return avgDayCutter(originalIntervals.filter(function(interval) {
65 | return peakDate(interval.start);
66 | }));
67 | }
68 |
69 | function avgDayCutter(originalIntervals) {
70 | return intervalsByHour(originalIntervals)
71 | .filter((intervals) => !!intervals.length)
72 | .map(function(intervals) {
73 | let date = new Date(avgDayDate);
74 | date.setHours(intervals[0].start.getHours());
75 | return {
76 | start: date,
77 | value: average(intervals.map(interval => interval.value)),
78 | cost: average(intervals.map(interval => interval.cost))
79 | };
80 | });
81 | }
82 |
83 | function allTimeCutter(originalIntervals) {
84 | return originalIntervals;
85 | }
86 |
87 | function intervalsByHour(intervals) {
88 | return intervals.reduce(function(array, interval) {
89 | array[interval.start.getHours()].push(interval);
90 | return array;
91 | }, emptyDayArray());
92 | }
93 |
94 | function average(array) {
95 | return array.reduce(function(sum, value) {
96 | return sum + value;
97 | }, 0) / array.length;
98 | }
99 |
100 | function emptyDayArray() {
101 | return new Array(24).fill(null).map(element => []);
102 | }
103 |
104 |
--------------------------------------------------------------------------------
/src/__snapshots__/App.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders correctly 1`] = `
4 |
7 |
10 |
11 | Green Button Data for
12 | 123 SAMPLE ST BERKELEY CA 94707-2701
13 |
14 |
15 |
16 |
19 |
20 |
21 |
24 |
25 |
26 |
27 | Total:
28 |
29 |
30 | 9,995 kWh
31 |
32 |
33 |
34 | Peak:
35 |
36 |
37 | 3,469 kWh
38 |
39 |
40 |
41 |
42 |
43 |
52 |
53 |
54 |
57 |
58 |
59 | Raw Data
60 |
61 |
66 | Most Recent 24 Hours
67 |
68 |
73 | Last 7 Days
74 |
75 |
80 | Last 30 Days
81 |
82 |
87 | All Time
88 |
89 |
90 |
91 |
94 |
95 |
96 | Averages
97 |
98 |
103 | Day
104 |
105 |
110 | Weekend Day
111 |
112 |
117 | Week Day
118 |
119 |
124 | Peak Time
125 |
126 |
127 |
128 |
131 |
132 |
133 | Chart Type
134 |
135 |
140 | Cost
141 |
142 |
147 | Power Usage
148 |
149 |
150 |
151 |
152 |
155 |
159 |
160 |
169 |
170 | `;
171 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl);
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/chart-data.js:
--------------------------------------------------------------------------------
1 | import TimeCutter from './time-cutter';
2 | import INTERVAL_TYPES from './interval-types';
3 | import TIME_CUTS from './time-cuts';
4 | import { formattedDay, formattedFullDate } from './date-formatters';
5 | import { peakDate } from './date-checkers';
6 |
7 | import {
8 | CHART_TYPE_TO_PROPERTY_MAP,
9 | INTERVAL_TYPE_TO_PROPERTY_MAP
10 | } from './chart-maps';
11 |
12 | const TIME_CUTS_TO_FORMATTER_MAP = {};
13 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.AVG_DAY] = formattedDay;
14 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.AVG_WEEKEND_DAY] = formattedDay;
15 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.AVG_WEEK_DAY] = formattedDay;
16 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.AVG_PEAK_TIME] = formattedDay;
17 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.ALL_TIME] = formattedFullDate;
18 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.MOST_RECENT_24_HOURS] = formattedFullDate;
19 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.LAST_7_DAYS] = formattedFullDate;
20 | TIME_CUTS_TO_FORMATTER_MAP[TIME_CUTS.LAST_30_DAYS] = formattedFullDate;
21 |
22 | export default class ChartData {
23 | constructor(intervals, chartType, timeCut, multiplier) {
24 | this.intervals = timeCutIntervals(intervals, timeCut);
25 | this.theoreticalIntervals = theoreticalIntervals(this.intervals, multiplier);
26 | this.chartType = chartType;
27 | this.timeCut = timeCut;
28 | this.multiplier = multiplier;
29 | }
30 |
31 | get datasets() {
32 | let intervals = formattedComboIntervals(this.intervals, this.theoreticalIntervals, this.chartType);
33 | return datasetsFromFormattedComboIntervals(intervals, this.chartType);
34 | }
35 |
36 |
37 | get starts() {
38 | return this.intervals.map((interval) =>
39 | TIME_CUTS_TO_FORMATTER_MAP[this.timeCut](interval.start));
40 | }
41 |
42 | get results() {
43 | return {
44 | total: total(this.intervals, this.chartType),
45 | totalPeak: totalPeak(this.intervals, this.chartType),
46 | totalTheoretical: totalTheoretical(this.theoreticalIntervals, this.chartType),
47 | totalPeakTheoretical: totalPeakTheoretical(this.theoreticalIntervals, this.chartType)
48 | }
49 | }
50 | }
51 |
52 | function total(intervals, chartType) {
53 | return sumOfIntervals(intervals, chartType);
54 | }
55 |
56 | function totalPeak(intervals, chartType) {
57 | return sumOfIntervals(peakIntervals(intervals), chartType);
58 | }
59 |
60 | function totalTheoretical(theoreticalIntervals, chartType) {
61 | if (!theoreticalIntervals) return null;
62 | return sumOfIntervals(theoreticalIntervals, chartType);
63 | }
64 |
65 | function totalPeakTheoretical(theoreticalIntervals, chartType) {
66 | if (!theoreticalIntervals) return null;
67 | return sumOfIntervals(peakIntervals(theoreticalIntervals), chartType);
68 | }
69 |
70 | function timeCutIntervals(intervals, timeCut) {
71 | return new TimeCutter(intervals, timeCut).intervals;
72 | }
73 |
74 | function peakIntervals(intervals) {
75 | return intervals.filter(function(interval) {
76 | return peakDate(new Date(interval.start));
77 | })
78 | }
79 |
80 | function sumOfIntervals(intervals, chartType) {
81 | return intervals.reduce(function(sum, interval) {
82 | return sum + interval[CHART_TYPE_TO_PROPERTY_MAP[chartType].dataType];
83 | }, 0);
84 | }
85 |
86 | function formattedIntervals(intervals, chartType) {
87 | if (!intervals) return null;
88 | const key = CHART_TYPE_TO_PROPERTY_MAP[chartType].dataType;
89 | return intervals.map((interval) => interval[key]);
90 | }
91 |
92 | function theoreticalIntervals(intervals, multiplier) {
93 | if (multiplier === 1.0) return null;
94 |
95 | return intervals.map(function(interval) {
96 | return {
97 | start: interval.start,
98 | value: multiplier * interval.value,
99 | cost: multiplier * interval.cost
100 | };
101 | });
102 | }
103 |
104 | function formattedComboIntervals(intervals, theoreticalIntervals, chartType) {
105 | return [
106 | {
107 | type: INTERVAL_TYPES.THEORETICAL,
108 | data: formattedIntervals(theoreticalIntervals, chartType)
109 | },
110 | {
111 | type: INTERVAL_TYPES.ACTUAL,
112 | data: formattedIntervals(intervals, chartType)
113 | }
114 | ];
115 | }
116 |
117 | function datasetsFromFormattedComboIntervals(formattedIntervals, chartType) {
118 | return formattedIntervals.filter((interval) => !!interval.data)
119 | .map(function(interval) {
120 | const title = `${INTERVAL_TYPE_TO_PROPERTY_MAP[interval.type].titlePrefix} ${CHART_TYPE_TO_PROPERTY_MAP[chartType].chartTitle}`;
121 | return {
122 | fill: 'origin',
123 | pointRadius: 0,
124 | borderWidth: 1,
125 | label: title,
126 | data: interval.data,
127 | backgroundColor: INTERVAL_TYPE_TO_PROPERTY_MAP[interval.type].backgroundColor,
128 | borderColor: INTERVAL_TYPE_TO_PROPERTY_MAP[interval.type].borderColor
129 | };
130 | });
131 | }
132 |
--------------------------------------------------------------------------------