diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/App.js | 432 | ||||
-rwxr-xr-x | src/App.test.js | 9 | ||||
-rwxr-xr-x | src/index.js | 11 | ||||
-rwxr-xr-x | src/serviceWorker.js | 135 |
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(); + }); + } +} |