aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDeterminant <ted.sybil@gmail.com>2019-01-28 01:59:42 -0500
committerDeterminant <ted.sybil@gmail.com>2019-01-28 01:59:42 -0500
commit1d2773cbf4c15fd5b1384f7bd993edb63a460c3a (patch)
tree279aaa50a9f6189cd50bb6ad0207b93a44049929 /src
parent9eec5b0d0ca748a73abe23511688bbba9b906c22 (diff)
use React; impl the prototype
Diffstat (limited to 'src')
-rwxr-xr-xsrc/App.js432
-rwxr-xr-xsrc/App.test.js9
-rwxr-xr-xsrc/index.js11
-rwxr-xr-xsrc/serviceWorker.js135
4 files changed, 587 insertions, 0 deletions
diff --git a/src/App.js b/src/App.js
new file mode 100755
index 0000000..f0023f1
--- /dev/null
+++ b/src/App.js
@@ -0,0 +1,432 @@
+import 'typeface-roboto';
+import 'react-dates/initialize';
+import 'react-dates/lib/css/_datepicker.css';
+import { DateRangePicker, DayPickerRangeController } from 'react-dates';
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { withStyles } from '@material-ui/core/styles';
+import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
+import orange from '@material-ui/core/colors/orange';
+import cyan from '@material-ui/core/colors/cyan';
+import deepOrange from '@material-ui/core/colors/deepOrange';
+import CssBaseline from '@material-ui/core/CssBaseline';
+import AppBar from '@material-ui/core/AppBar';
+import Toolbar from '@material-ui/core/Toolbar';
+import TextField from '@material-ui/core/TextField';
+import Typography from '@material-ui/core/Typography';
+import Divider from '@material-ui/core/Divider';
+import Table from '@material-ui/core/Table';
+import TableBody from '@material-ui/core/TableBody';
+import TableRow from '@material-ui/core/TableRow';
+import TableCell from '@material-ui/core/TableCell';
+import TableHead from '@material-ui/core/TableHead';
+import TablePagination from '@material-ui/core/TablePagination';
+import Paper from '@material-ui/core/Paper';
+import Button from '@material-ui/core/Button';
+import FormControl from '@material-ui/core/FormControl';
+import FormGroup from '@material-ui/core/FormGroup';
+import Grid from '@material-ui/core/Grid';
+import DeleteOutlinedIcon from '@material-ui/icons/DeleteOutlined';
+import { PieChart, Pie, Cell, Tooltip } from 'recharts';
+
+const default_chart_data = [{name: 'Work', value: 10, color: cyan[300]},
+ {name: 'Wasted', value: 10, color: cyan[300]}];
+
+const gapi_base = 'https://www.googleapis.com/calendar/v3';
+
+const theme = createMuiTheme({
+ palette: {
+ primary: {
+ light: orange[300],
+ main: orange[500],
+ dark: orange[700],
+ contrastText: "#fff"
+ }
+ }
+});
+
+/* eslint-disable no-undef */
+
+function to_params(dict) {
+ return Object.entries(dict).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
+}
+
+function getAuthToken() {
+ return new Promise(resolver =>
+ chrome.identity.getAuthToken(
+ {interactive: true}, token => resolver(token)));
+}
+
+function getCalendars(token) {
+ return fetch(gapi_base + '/users/me/calendarList?' + to_params({access_token: token}),
+ { method: 'GET', async: true })
+ .then(response => response.json())
+ .then(data => data.items);
+}
+
+function genEventsGetter(calId, timeMin, timeMax) {
+ return token => fetch(gapi_base + '/calendars/' + calId + '/events?' + to_params({
+ access_token: token,
+ timeMin,
+ timeMax
+ }), { method: 'GET', async: true })
+ .then(response => response.json())
+ .then(data => data.items);
+}
+
+function getColors(token) {
+ return fetch(gapi_base + '/colors?' + to_params({access_token: token}), { method: 'GET', async: true })
+ .then(response => response.json());
+}
+
+function filterPatterns(patterns, calName) {
+ return patterns.filter(p => {
+ let re = new RegExp(p.cal);
+ return re.test(calName);
+ });
+}
+
+const styles = theme => ({
+ root: {
+ display: 'flex',
+ height: '100vh',
+ },
+ icon: {
+ margin: theme.spacing.unit * 2,
+ },
+ appBar: {
+ zIndex: theme.zIndex.drawer + 1,
+ transition: theme.transitions.create(['width', 'margin'], {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen,
+ }),
+ },
+ title: {
+ flexGrow: 1,
+ },
+ sectionTitle: {
+ flex: '0 0 auto'
+ },
+ appBarSpacer: theme.mixins.toolbar,
+ content: {
+ flexGrow: 1,
+ padding: theme.spacing.unit * 3,
+ overflow: 'auto',
+ },
+ buttonSpacer: {
+ marginBottom: theme.spacing.unit * 4,
+ },
+ patternTable: {
+ overflowX: 'auto'
+ },
+ pieChart: {
+ margin: '0 auto'
+ },
+ fab: {
+ margin: theme.spacing.unit,
+ }
+});
+
+function customizedLabel(props) {
+ const {cx, cy, x, y, stroke, fill, name, value} = props;
+ let anchor = "middle";
+ const EPS = 2;
+ let dx = 0;
+ let dy = 0;
+ if (x < cx - EPS) {
+ dx = -5;
+ anchor = "end"
+ } else if (x > cx + EPS) {
+ dx = 5;
+ anchor = "start";
+ }
+
+ if (y < cy - EPS) {
+ dy = -5;
+ } else if (y > cy + EPS) {
+ dy = 10;
+ }
+
+ return (<text x={x} y={y} dx={dx} dy={dy} fill={fill} textAnchor={anchor}>{`${name}`}</text>);
+}
+
+function ChromiclePieChart(props) {
+ return (
+ <Grid container spacing={0}>
+ <Grid item xs={12} lg={6}>
+ <PieChart width={400} height={300} className={props.classes.pieChart}>
+ <Pie data={props.patternGraphData}
+ cx={200}
+ cy={150}
+ outerRadius={60}
+ fill={deepOrange[300]}
+ label={customizedLabel}/>
+ <Tooltip formatter={(value) => `${value.toFixed(2)} hr`}/>
+ </PieChart>
+ </Grid>
+ <Grid item xs={12} lg={6}>
+ <PieChart width={400} height={300} className={props.classes.pieChart}>
+ <Pie data={props.calendarGraphData}
+ cx={200}
+ cy={150}
+ innerRadius={40}
+ outerRadius={70}
+ fill={cyan[300]}
+ label={customizedLabel}>
+ {props.calendarGraphData.map(d => <Cell fill={d.color}/>)}
+ </Pie>
+ <Tooltip formatter={(value) => `${value.toFixed(2)} hr`}/>
+ </PieChart>
+ </Grid>
+ </Grid>);
+}
+
+ChromiclePieChart.propTypes = {
+ patternGraphData: PropTypes.object.isRequired,
+ patterncalendarData: PropTypes.object.isRequired,
+};
+
+class Dashboard extends React.Component {
+ state = {
+ open: true,
+ patterns: [],
+ page: 0,
+ rowsPerPage: 5,
+ timeRange: null,
+ token: getAuthToken(),
+ patternGraphData: default_chart_data,
+ calendarGraphData: default_chart_data,
+ activePattern: null
+ };
+
+ cached = {
+ calendars: {}
+ };
+
+ static patternHead = [
+ {label: "Name", field: "name"},
+ {label: "Calendar", field: "cal"},
+ {label: "Event", field: 'event'}];
+
+ handleChangePage = (event, page) => {
+ this.setState({ page });
+ };
+
+ handleChangeRowsPerPage = event => {
+ this.setState({ rowsPerPage: event.target.value });
+ };
+
+ updatePattern = (field, idx, value) => {
+ let patterns = this.state.patterns;
+ patterns[idx][field] = value;
+ this.setState({ patterns });
+ };
+
+ removePattern = idx => {
+ let patterns = this.state.patterns;
+ patterns.splice(idx, 1);
+ for (let i = 0; i < patterns.length; i++)
+ patterns[i].idx = i;
+ this.setState({ patterns });
+ };
+
+ analyze = () => {
+ if (!(this.state.startDate && this.state.endDate)) {
+ alert("Please choose a valid time range.");
+ return;
+ }
+ let start = this.state.startDate.toISOString();
+ let end = this.state.endDate.toISOString();
+ let event_pms = [];
+ for (let id in this.cached.calendars) {
+ event_pms.push(
+ this.state.token
+ .then(genEventsGetter(id, start, end))
+ .then(items => this.cached.calendars[id].events = items));
+ }
+
+ Promise.all(event_pms).then(() => {
+ let results = {}; // pattern idx => time
+ let cal_results = {}; // cal id => time
+ for (let i = 0; i < this.state.patterns.length; i++)
+ results[i] = 0;
+ for (let id in this.cached.calendars) {
+ let patterns = filterPatterns(this.state.patterns, this.cached.calendars[id].name)
+ .map(p => { return { regex: new RegExp(p.event), idx: p.idx } });
+ if (!this.cached.calendars[id].events) continue;
+ this.cached.calendars[id].events.forEach(event => {
+ if (event.status != "confirmed") return;
+ patterns.forEach(p => {
+ if (!p.regex.test(event.summary)) return;
+ if (cal_results[id] === undefined) {
+ cal_results[id] = 0;
+ }
+ let duration = (new Date(event.end.dateTime) - new Date(event.start.dateTime)) / 60000;
+ results[p.idx] += duration;
+ cal_results[id] += duration;
+ });
+ });
+ }
+ let patternGraphData = [];
+ let calendarGraphData = [];
+ for (let i = 0; i < this.state.patterns.length; i++) {
+ patternGraphData.push({ name: this.state.patterns[i].name, value: results[i] / 60.0 });
+ }
+ for (let id in cal_results) {
+ calendarGraphData.push({
+ name: this.cached.calendars[id].name,
+ value: (cal_results[id] / 60.0),
+ color: this.cached.calendars[id].color.background});
+ }
+ this.setState({ patternGraphData, calendarGraphData });
+ });
+ };
+
+ loadPatterns = () => {
+ let token = this.state.token;
+ let colors = token.then(getColors).then(color => {
+ return color.calendar;
+ });
+ let cals = token.then(getCalendars);
+ Promise.all([colors, cals]).then(([colors, items]) => {
+ items.forEach(item => {
+ this.cached.calendars[item.id] = {
+ name: item.summary,
+ events: {},
+ color: colors[item.colorId]
+ };
+ });
+ this.setState({ patterns: items.map((item, idx) => {
+ return { name: item.summary,
+ cal: item.summary,
+ event: '.*',
+ idx }
+ })});
+ });
+ };
+
+ render() {
+ const { classes } = this.props;
+ const { patterns, rows, rowsPerPage, page } = this.state;
+ const nDummy = rowsPerPage - Math.min(rowsPerPage, patterns.length - page * rowsPerPage);
+
+ return (
+ <MuiThemeProvider theme={theme}>
+ <div className={classes.root}>
+ <AppBar
+ position="absolute"
+ className={classes.appBar}>
+ <Toolbar disableGutters={!this.state.open} className={classes.toolbar}>
+ <Typography component="h1" variant="h6" color="inherit" noWrap className={classes.title}>
+ Chromicle
+ </Typography>
+ </Toolbar>
+ </AppBar>
+ <main className={classes.content}>
+ <div className={classes.appBarSpacer} />
+ <Grid container spacing={16}>
+ <CssBaseline />
+ <Grid item md={6} xs={12}>
+ <FormControl fullWidth={true}>
+ <FormGroup>
+ <Typography variant="h6" component="h1" gutterBottom>
+ Event Patterns
+ </Typography>
+ <Table>
+ <TableHead>
+ <TableRow>{Dashboard.patternHead.map(s => (<TableCell>{s.label}</TableCell>))}</TableRow>
+ </TableHead>
+ <TableBody>
+ {patterns.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map(p => (
+ <TableRow
+ onMouseOver={() => this.setState({ activePattern: p.idx })}
+ onMouseOut={() => this.setState({ activePattern: null })}>
+ {Dashboard.patternHead.map(s => (
+ <TableCell>
+ <TextField
+ value={p[s.field]}
+ onChange={event => this.updatePattern(s.field, p.idx, event.target.value)}/>
+ </TableCell>))}
+ <span style={(this.state.activePattern == p.idx &&
+ { position: 'absolute', right: 0, height: 48 }) ||
+ { display: 'none' }}>
+ <DeleteOutlinedIcon
+ className={classes.icon}
+ style={{ height: '100%', cursor: 'pointer' }}
+ onClick={() => this.removePattern(p.idx)} />
+ </span>
+ </TableRow>))}
+ {nDummy > 0 && (
+ <TableRow style={{ height: 48 * nDummy }}>
+ <TableCell colSpan={Dashboard.patternHead.length} />
+ </TableRow>)}
+ </TableBody>
+ </Table>
+ <TablePagination
+ rowsPerPageOptions={[5, 10, 25]}
+ component="div"
+ count={patterns.length}
+ rowsPerPage={rowsPerPage}
+ page={page}
+ backIconButtonProps={{'aria-label': 'Previous Page'}}
+ nextIconButtonProps={{'aria-label': 'Next Page'}}
+ onChangePage={this.handleChangePage}
+ onChangeRowsPerPage={this.handleChangeRowsPerPage}/>
+ </FormGroup>
+ <FormGroup>
+ <Typography variant="h6" component="h1" gutterBottom>
+ Time Range
+ </Typography>
+ <div style={{textAlign: 'center'}}>
+ <DateRangePicker
+ startDate={this.state.startDate} // momentPropTypes.momentObj or null,
+ startDateId="your_unique_start_date_id" // PropTypes.string.isRequired,
+ endDate={this.state.endDate} // momentPropTypes.momentObj or null,
+ endDateId="your_unique_end_date_id" // PropTypes.string.isRequired,
+ onDatesChange={({ startDate, endDate }) => {
+ //if (startDate && endDate)
+ // this.setState({ timeRange: [startDate.toISOString(), endDate.toISOString()]});
+ this.setState({ startDate, endDate });
+ }} // PropTypes.func.isRequired,
+ focusedInput={this.state.focusedInput} // PropTypes.oneOf([START_DATE, END_DATE]) or null,
+ onFocusChange={focusedInput => this.setState({ focusedInput })} // PropTypes.func.isRequired,
+ isOutsideRange={() => false}/>
+ </div>
+ </FormGroup>
+ <div className={classes.buttonSpacer} />
+ <Grid container spacing={16}>
+ <Grid item md={6} xs={12}>
+ <FormGroup>
+ <Button variant="contained" color="primary" onClick={this.loadPatterns}>Load</Button>
+ </FormGroup>
+ </Grid>
+ <Grid item md={6} xs={12}>
+ <FormGroup>
+ <Button variant="contained" color="primary" onClick={this.analyze}>Analyze</Button>
+ </FormGroup>
+ </Grid>
+ </Grid>
+ </FormControl>
+ </Grid>
+ <Grid item md={6} xs={12}>
+ <Typography variant="h6" component="h1" gutterBottom>
+ Graph
+ </Typography>
+ <ChromiclePieChart
+ patternGraphData={this.state.patternGraphData}
+ calendarGraphData={this.state.calendarGraphData}
+ classes={classes}/>
+ </Grid>
+ </Grid>
+ </main>
+ </div>
+ </MuiThemeProvider>);
+ }
+}
+
+Dashboard.propTypes = {
+ classes: PropTypes.object.isRequired,
+};
+
+export default withStyles(styles)(Dashboard);
diff --git a/src/App.test.js b/src/App.test.js
new file mode 100755
index 0000000..a754b20
--- /dev/null
+++ b/src/App.test.js
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import App from './App';
+
+it('renders without crashing', () => {
+ const div = document.createElement('div');
+ ReactDOM.render(<App />, div);
+ ReactDOM.unmountComponentAtNode(div);
+});
diff --git a/src/index.js b/src/index.js
new file mode 100755
index 0000000..955691f
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import App from './App';
+import * as serviceWorker from './serviceWorker';
+
+ReactDOM.render(<App />, document.getElementById('root'));
+
+// If you want your app to work offline and load faster, you can change
+// unregister() to register() below. Note this comes with some pitfalls.
+// Learn more about service workers: http://bit.ly/CRA-PWA
+serviceWorker.unregister();
diff --git a/src/serviceWorker.js b/src/serviceWorker.js
new file mode 100755
index 0000000..2283ff9
--- /dev/null
+++ b/src/serviceWorker.js
@@ -0,0 +1,135 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read http://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+ window.location.hostname === 'localhost' ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === '[::1]' ||
+ // 127.0.0.1/8 is considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+);
+
+export function register(config) {
+ if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener('load', () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ 'This web app is being served cache-first by a service ' +
+ 'worker. To learn more, visit http://bit.ly/CRA-PWA'
+ );
+ });
+ } else {
+ // Is not localhost. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl, config) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) {
+ return;
+ }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the updated precached content has been fetched,
+ // but the previous service worker will still serve the older
+ // content until all client tabs are closed.
+ console.log(
+ 'New content is available and will be used when all ' +
+ 'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
+ );
+
+ // Execute callback
+ if (config && config.onUpdate) {
+ config.onUpdate(registration);
+ }
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log('Content is cached for offline use.');
+
+ // Execute callback
+ if (config && config.onSuccess) {
+ config.onSuccess(registration);
+ }
+ }
+ }
+ };
+ };
+ })
+ .catch(error => {
+ console.error('Error during service worker registration:', error);
+ });
+}
+
+function checkValidServiceWorker(swUrl, config) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl)
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ const contentType = response.headers.get('content-type');
+ if (
+ response.status === 404 ||
+ (contentType != null && contentType.indexOf('javascript') === -1)
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl, config);
+ }
+ })
+ .catch(() => {
+ console.log(
+ 'No internet connection found. App is running in offline mode.'
+ );
+ });
+}
+
+export function unregister() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister();
+ });
+ }
+}