From c594888953151ddfb4ca04b7752bfd51edc1d6da Mon Sep 17 00:00:00 2001 From: Determinant Date: Wed, 13 Feb 2019 01:11:31 -0500 Subject: WIP: migrate to TypeScriptX --- src/Analyze.js | 266 ------------------------------- src/Analyze.tsx | 270 +++++++++++++++++++++++++++++++ src/Chart.js | 165 ------------------- src/Chart.tsx | 165 +++++++++++++++++++ src/Dashboard.js | 98 ------------ src/Dashboard.tsx | 98 ++++++++++++ src/Dialog.js | 45 ------ src/Dialog.tsx | 45 ++++++ src/Logo.js | 34 ---- src/Logo.tsx | 34 ++++ src/PatternTable.js | 208 ------------------------ src/PatternTable.tsx | 208 ++++++++++++++++++++++++ src/RegexField.js | 98 ------------ src/RegexField.tsx | 98 ++++++++++++ src/Settings.js | 437 --------------------------------------------------- src/Settings.tsx | 437 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/Snackbar.js | 78 --------- src/Snackbar.tsx | 78 +++++++++ src/background.js | 217 ------------------------- src/background.ts | 217 +++++++++++++++++++++++++ src/decl.ts | 1 + src/duration.js | 21 --- src/duration.ts | 26 +++ src/gapi.js | 335 --------------------------------------- src/gapi.ts | 369 +++++++++++++++++++++++++++++++++++++++++++ src/index.js | 11 -- src/index.tsx | 5 + src/msg.js | 97 ------------ src/msg.ts | 87 ++++++++++ src/pattern.js | 48 ------ src/pattern.ts | 84 ++++++++++ src/popup.js | 118 -------------- src/popup.tsx | 112 +++++++++++++ src/serviceWorker.js | 135 ---------------- src/theme.js | 18 --- src/theme.tsx | 18 +++ 36 files changed, 2352 insertions(+), 2429 deletions(-) delete mode 100644 src/Analyze.js create mode 100644 src/Analyze.tsx delete mode 100644 src/Chart.js create mode 100644 src/Chart.tsx delete mode 100644 src/Dashboard.js create mode 100644 src/Dashboard.tsx delete mode 100644 src/Dialog.js create mode 100644 src/Dialog.tsx delete mode 100644 src/Logo.js create mode 100644 src/Logo.tsx delete mode 100644 src/PatternTable.js create mode 100644 src/PatternTable.tsx delete mode 100644 src/RegexField.js create mode 100644 src/RegexField.tsx delete mode 100644 src/Settings.js create mode 100644 src/Settings.tsx delete mode 100644 src/Snackbar.js create mode 100644 src/Snackbar.tsx delete mode 100644 src/background.js create mode 100644 src/background.ts create mode 100644 src/decl.ts delete mode 100644 src/duration.js create mode 100644 src/duration.ts delete mode 100644 src/gapi.js create mode 100644 src/gapi.ts delete mode 100644 src/index.js create mode 100644 src/index.tsx delete mode 100644 src/msg.js create mode 100644 src/msg.ts delete mode 100644 src/pattern.js create mode 100644 src/pattern.ts delete mode 100644 src/popup.js create mode 100644 src/popup.tsx delete mode 100644 src/serviceWorker.js delete mode 100644 src/theme.js create mode 100644 src/theme.tsx (limited to 'src') diff --git a/src/Analyze.js b/src/Analyze.js deleted file mode 100644 index 98e3ce2..0000000 --- a/src/Analyze.js +++ /dev/null @@ -1,266 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import 'react-dates/initialize'; -import 'react-dates/lib/css/_datepicker.css'; -import { DateRangePicker } from 'react-dates'; -import { withStyles } from '@material-ui/core/styles'; -import cyan from '@material-ui/core/colors/cyan'; -import deepOrange from '@material-ui/core/colors/deepOrange'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import Typography from '@material-ui/core/Typography'; -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 AddCircleIcon from '@material-ui/icons/AddCircle'; -import IconButton from '@material-ui/core/IconButton'; -import * as gapi from './gapi'; -import { msgType, MsgClient } from './msg'; -import { Pattern, PatternEntry } from './pattern'; -import { AnalyzePieChart, getChartData } from './Chart'; -import PatternTable from './PatternTable'; -import Snackbar from './Snackbar'; -import AlertDialog from './Dialog'; - -const default_chart_data = [ - {name: 'Work', value: 10, color: cyan[300]}, - {name: 'Wasted', value: 10, color: deepOrange[300]}]; - -const styles = theme => ({ - buttonSpacer: { - marginBottom: theme.spacing.unit * 4, - }, -}); - -class Analyze extends React.Component { - state = { - patterns: [], - calendars: {}, - startDate: null, - endDate: null, - patternGraphData: default_chart_data, - calendarGraphData: default_chart_data, - snackBarOpen: false, - snackBarMsg: 'unknown', - snackBarVariant: 'error', - dialogOpen: false, - dialogMsg: {title: '', message: ''}, - }; - - constructor(props) { - super(props); - this.msgClient = new MsgClient('main'); - - this.msgClient.sendMsg({ - type: msgType.getPatterns, - data: { id: 'analyze' } - }).then(msg => { - this.setState({ patterns: msg.data.map(p => PatternEntry.inflate(p)) }); - }); - - this.msgClient.sendMsg({ - type: msgType.getCalendars, - data: { enabledOnly: true } - }).then(msg => { - this.setState({ calendars: msg.data }); - }); - - gapi.getLoggedIn().then(b => !b && - this.handleSnackbarOpen('Not logged in. Operating in offline mode.', 'warning')); - - this.dialogPromiseResolver = null; - } - - loadPatterns = patterns => { - this.msgClient.sendMsg({ - type: msgType.updatePatterns, - data: { id: 'analyze', patterns: patterns.map(p => p.deflate()) } - }).then(() => this.setState({ patterns })); - }; - - updatePattern = (field, idx, value) => { - let patterns = this.state.patterns; - patterns[idx][field] = value; - this.loadPatterns(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.loadPatterns(patterns); - }; - - newPattern = () => { - let patterns = [PatternEntry.defaultPatternEntry(0), ...this.state.patterns]; - for (let i = 1; i < patterns.length; i++) - patterns[i].idx = i; - this.loadPatterns(patterns); - }; - - getCalEvents = (id, start, end) => { - return this.msgClient.sendMsg({ type: msgType.getCalEvents, data: { id, - start: start.getTime(), - end: end.getTime() } }) - .then(({ data }) => data.map(e => { - return { - id: e.id, - start: new Date(e.start), - end: new Date(e.end) } - })); - } - - analyze = () => { - if (!(this.state.startDate && this.state.endDate)) { - this.handleSnackbarOpen('Please choose a valid time range.', 'error'); - return; - } - let start = this.state.startDate.startOf('day').toDate(); - let end = this.state.endDate.startOf('day').toDate(); - getChartData(start, end, - this.state.patterns, - this.state.calendars, - this.getCalEvents).then(results => { - this.setState(results); - }); - } - - reset = () => { - this.handleDialogOpen("Reset", "Are you sure to reset the patterns?").then(ans => { - if (!ans) return; - this.loadPatterns([]); - this.setState({ startDate: null, endDate: null }); - }); - } - - loadDefaultPatterns() { - let patterns = []; - let idx = 0; - for (let id in this.state.calendars) { - let cal = this.state.calendars[id]; - if (!cal.enabled) continue; - patterns.push(new PatternEntry(cal.name, idx++, - new Pattern(id, false, cal.name, cal.name), - Pattern.anyPattern(), - cal.color)); - } - console.log(patterns); - this.loadPatterns(patterns); - } - - default = () => { - this.handleDialogOpen("Load Default", "Load the calendars as patterns?").then(ans => { - if (!ans) return; - this.loadDefaultPatterns(); - }); - } - - handleSnackbarClose = (event, reason) => { - if (reason === 'clickaway') return; - this.setState({ snackBarOpen: false }); - } - - handleSnackbarOpen = (msg, variant) => { - this.setState({ snackBarOpen: true, snackBarMsg: msg, snackBarVariant: variant }); - } - - handleDialogOpen = (title, message) => { - let pm = new Promise(resolver => { - this.dialogPromiseResolver = resolver - }); - this.setState({ dialogOpen: true, dialogMsg: {title, message} }); - return pm; - } - - handleDialogClose = result => { - this.dialogPromiseResolver(result); - this.setState({ dialogOpen: false }); - } - - render() { - const { classes } = this.props; - - return ( - - - - - - - - Analyzed Events - this.newPattern()}> - - - - - - Time Range - -
- { - this.setState({ startDate, endDate }); - }} - focusedInput={this.state.focusedInput} - onFocusChange={focusedInput => this.setState({ focusedInput })} - isOutsideRange={() => false} /> -
-
-
- - - - - - - - - - - - - - - - - - - - - - Results - - - - - ); - } -} - -Analyze.propTypes = { - classes: PropTypes.object.isRequired, -}; - -export default withStyles(styles)(Analyze); diff --git a/src/Analyze.tsx b/src/Analyze.tsx new file mode 100644 index 0000000..5450998 --- /dev/null +++ b/src/Analyze.tsx @@ -0,0 +1,270 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import 'react-dates/initialize'; +import 'react-dates/lib/css/_datepicker.css'; +import { DateRangePicker } from 'react-dates'; +import { Theme, withStyles } from '@material-ui/core/styles'; +import cyan from '@material-ui/core/colors/cyan'; +import deepOrange from '@material-ui/core/colors/deepOrange'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import Typography from '@material-ui/core/Typography'; +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 AddCircleIcon from '@material-ui/icons/AddCircle'; +import IconButton from '@material-ui/core/IconButton'; +import * as gapi from './gapi'; +import { MsgType, MsgClient } from './msg'; +import { Pattern, PatternEntry } from './pattern'; +import { AnalyzePieChart, getChartData } from './Chart'; +import PatternTable from './PatternTable'; +import Snackbar from './Snackbar'; +import AlertDialog from './Dialog'; +import moment from 'moment'; + +const default_chart_data = [ + {name: 'Work', value: 10, color: cyan[300]}, + {name: 'Wasted', value: 10, color: deepOrange[300]}]; + +const styles = (theme: Theme) => ({ + buttonSpacer: { + marginBottom: theme.spacing.unit * 4, + }, +}); + +class Analyze extends React.Component { + msgClient: MsgClient; + + state = { + patterns: [] as PatternEntry[], + calendars: {}, + startDate: null as moment.Moment, + endDate: null as moment.Moment, + patternGraphData: default_chart_data, + calendarGraphData: default_chart_data, + snackBarOpen: false, + snackBarMsg: 'unknown', + snackBarVariant: 'error', + dialogOpen: false, + dialogMsg: {title: '', message: ''} + }; + + constructor(props: any) { + super(props); + + this.msgClient = new MsgClient('main'); + + this.msgClient.sendMsg({ + type: MsgType.getPatterns, + data: { id: 'analyze' } + }).then(msg => { + this.setState({ patterns: msg.data.map(p => PatternEntry.inflate(p)) }); + }); + + this.msgClient.sendMsg({ + type: MsgType.getCalendars, + data: { enabledOnly: true } + }).then(msg => { + this.setState({ calendars: msg.data }); + }); + + gapi.getLoggedIn().then(b => !b && + this.handleSnackbarOpen('Not logged in. Operating in offline mode.', 'warning')); + + this.dialogPromiseResolver = null; + } + + loadPatterns = (patterns: PatternEntry[]) => { + this.msgClient.sendMsg({ + type: MsgType.updatePatterns, + data: { id: 'analyze', patterns: patterns.map(p => p.deflate()) } + }).then(() => this.setState({ patterns })); + }; + + updatePattern = (field: string, idx: number, value: PatternEntry[]) => { + let patterns = this.state.patterns; + patterns[idx][field] = value; + this.loadPatterns(patterns); + }; + + removePattern = (idx: number) => { + let patterns = this.state.patterns; + patterns.splice(idx, 1); + for (let i = 0; i < patterns.length; i++) + patterns[i].idx = i; + this.loadPatterns(patterns); + }; + + newPattern = () => { + let patterns = [PatternEntry.defaultPatternEntry(0), ...this.state.patterns]; + for (let i = 1; i < patterns.length; i++) + patterns[i].idx = i; + this.loadPatterns(patterns); + }; + + getCalEvents = (id: string, start: Date, end: Date) => { + return this.msgClient.sendMsg({ type: MsgType.getCalEvents, data: { id, + start: start.getTime(), + end: end.getTime() } }) + .then(({ data }) => data.map(e => { + return { + id: e.id, + start: new Date(e.start), + end: new Date(e.end) } + })); + } + + analyze = () => { + if (!(this.state.startDate && this.state.endDate)) { + this.handleSnackbarOpen('Please choose a valid time range.', 'error'); + return; + } + let start = this.state.startDate.startOf('day').toDate(); + let end = this.state.endDate.startOf('day').toDate(); + getChartData(start, end, + this.state.patterns, + this.state.calendars, + this.getCalEvents).then(results => { + this.setState(results); + }); + } + + reset = () => { + this.handleDialogOpen("Reset", "Are you sure to reset the patterns?").then(ans => { + if (!ans) return; + this.loadPatterns([]); + this.setState({ startDate: null, endDate: null }); + }); + } + + loadDefaultPatterns() { + let patterns = []; + let idx = 0; + for (let id in this.state.calendars) { + let cal = this.state.calendars[id]; + if (!cal.enabled) continue; + patterns.push(new PatternEntry(cal.name, idx++, + new Pattern(id, false, cal.name, cal.name), + Pattern.anyPattern(), + cal.color)); + } + console.log(patterns); + this.loadPatterns(patterns); + } + + default = () => { + this.handleDialogOpen("Load Default", "Load the calendars as patterns?").then(ans => { + if (!ans) return; + this.loadDefaultPatterns(); + }); + } + + handleSnackbarClose = (event, reason) => { + if (reason === 'clickaway') return; + this.setState({ snackBarOpen: false }); + } + + handleSnackbarOpen = (msg, variant) => { + this.setState({ snackBarOpen: true, snackBarMsg: msg, snackBarVariant: variant }); + } + + handleDialogOpen = (title, message) => { + let pm = new Promise(resolver => { + this.dialogPromiseResolver = resolver + }); + this.setState({ dialogOpen: true, dialogMsg: {title, message} }); + return pm; + } + + handleDialogClose = result => { + this.dialogPromiseResolver(result); + this.setState({ dialogOpen: false }); + } + + render() { + const { classes } = this.props; + + return ( + + + + + + + + Analyzed Events + this.newPattern()}> + + + + + + Time Range + +
+ { + this.setState({ startDate, endDate }); + }} + focusedInput={this.state.focusedInput} + onFocusChange={focusedInput => this.setState({ focusedInput })} + isOutsideRange={() => false} /> +
+
+
+ + + + + + + + + + + + + + + + + + + + + + Results + + + + + ); + } +} + +Analyze.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(Analyze); diff --git a/src/Chart.js b/src/Chart.js deleted file mode 100644 index b1c36ed..0000000 --- a/src/Chart.js +++ /dev/null @@ -1,165 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; -import Grid from '@material-ui/core/Grid'; -import cyan from '@material-ui/core/colors/cyan'; -import { PieChart, Pie, Cell, Tooltip } from 'recharts'; -import { defaultChartColor } from './theme'; - -export function getChartData(start, end, patterns, calendars, calEventsGetter) { - if (start >= end) return Promise.resolve({ patternGraphData: [], calendarGraphData: [] }); - let event_pms = []; - for (let id in calendars) - { - if (!calendars[id].enabled) continue; - let filtered = patterns.filter(p => p.cal.regex.test(calendars[id].name)); - if (filtered.length > 0) - event_pms.push(calEventsGetter(id, start, end) - .then(r => { return { id, events: r, filtered }; })); - } - return Promise.all(event_pms).then(all_events => { - let events = {}; - let patternsByCal = {}; - let results = {}; // pattern idx => time - let cal_results = {}; // cal id => time - all_events.forEach(e => { - events[e.id] = e.events; - patternsByCal[e.id] = e.filtered; - }); - for (let i = 0; i < patterns.length; i++) - results[i] = 0; - for (let id in calendars) { - if (!events[id]) continue; - events[id].forEach(event => { - patternsByCal[id].forEach(p => { - if (!p.event.regex.test(event.summary)) return; - if (!cal_results.hasOwnProperty(id)) { - cal_results[id] = 0; - } - let duration = (event.end - event.start) / 60000; - results[p.idx] += duration; - cal_results[id] += duration; - }); - }); - } - let patternGraphData = []; - let calendarGraphData = []; - const filterMarginal = data => { - let sum = 0; - let majorParts = []; - let minorSum = 0; - data.forEach(d => sum += d.value); - data.forEach(d => { - let ratio = d.value / sum; - if (ratio < 1e-2) minorSum += d.value; - else majorParts.push(d); - }); - majorParts.push({ - name: 'Other', - value: minorSum, - color: defaultChartColor, - }); - return majorParts; - }; - for (let i = 0; i < patterns.length; i++) { - patternGraphData.push({ - name: patterns[i].name, - value: results[i] / 60.0, - color: patterns[i].color.background}); - } - for (let id in cal_results) { - calendarGraphData.push({ - name: calendars[id].name, - value: (cal_results[id] / 60.0), - color: calendars[id].color.background}); - } - return {start, end, - patternGraphData: filterMarginal(patternGraphData), - calendarGraphData: filterMarginal(calendarGraphData) }; - }); -} - -const styles = theme => ({ - pieChart: { - margin: '0 auto', - } -}); - -function customizedLabel(props) { - const {cx, cy, x, y, fill, name} = 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 ({`${name}`}); -} - -function PatternPieChart(props) { - return ( - -
- - - {props.data.map((d, i) => )} - - `${value.toFixed(2)} hr`}/> - -
-
- ); -} - -export const StyledPatternPieChart = withStyles(styles)(PatternPieChart); - -function DoublePieChart(props) { - return ( - - - -
- - - {props.calendarGraphData.map((d, i) => )} - - `${value.toFixed(2)} hr`}/> - -
-
-
); -} - -DoublePieChart.propTypes = { - patternGraphData: PropTypes.array.isRequired, - calendarGraphData: PropTypes.array.isRequired, -}; - -export const AnalyzePieChart = withStyles(styles)(DoublePieChart); diff --git a/src/Chart.tsx b/src/Chart.tsx new file mode 100644 index 0000000..b1c36ed --- /dev/null +++ b/src/Chart.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import Grid from '@material-ui/core/Grid'; +import cyan from '@material-ui/core/colors/cyan'; +import { PieChart, Pie, Cell, Tooltip } from 'recharts'; +import { defaultChartColor } from './theme'; + +export function getChartData(start, end, patterns, calendars, calEventsGetter) { + if (start >= end) return Promise.resolve({ patternGraphData: [], calendarGraphData: [] }); + let event_pms = []; + for (let id in calendars) + { + if (!calendars[id].enabled) continue; + let filtered = patterns.filter(p => p.cal.regex.test(calendars[id].name)); + if (filtered.length > 0) + event_pms.push(calEventsGetter(id, start, end) + .then(r => { return { id, events: r, filtered }; })); + } + return Promise.all(event_pms).then(all_events => { + let events = {}; + let patternsByCal = {}; + let results = {}; // pattern idx => time + let cal_results = {}; // cal id => time + all_events.forEach(e => { + events[e.id] = e.events; + patternsByCal[e.id] = e.filtered; + }); + for (let i = 0; i < patterns.length; i++) + results[i] = 0; + for (let id in calendars) { + if (!events[id]) continue; + events[id].forEach(event => { + patternsByCal[id].forEach(p => { + if (!p.event.regex.test(event.summary)) return; + if (!cal_results.hasOwnProperty(id)) { + cal_results[id] = 0; + } + let duration = (event.end - event.start) / 60000; + results[p.idx] += duration; + cal_results[id] += duration; + }); + }); + } + let patternGraphData = []; + let calendarGraphData = []; + const filterMarginal = data => { + let sum = 0; + let majorParts = []; + let minorSum = 0; + data.forEach(d => sum += d.value); + data.forEach(d => { + let ratio = d.value / sum; + if (ratio < 1e-2) minorSum += d.value; + else majorParts.push(d); + }); + majorParts.push({ + name: 'Other', + value: minorSum, + color: defaultChartColor, + }); + return majorParts; + }; + for (let i = 0; i < patterns.length; i++) { + patternGraphData.push({ + name: patterns[i].name, + value: results[i] / 60.0, + color: patterns[i].color.background}); + } + for (let id in cal_results) { + calendarGraphData.push({ + name: calendars[id].name, + value: (cal_results[id] / 60.0), + color: calendars[id].color.background}); + } + return {start, end, + patternGraphData: filterMarginal(patternGraphData), + calendarGraphData: filterMarginal(calendarGraphData) }; + }); +} + +const styles = theme => ({ + pieChart: { + margin: '0 auto', + } +}); + +function customizedLabel(props) { + const {cx, cy, x, y, fill, name} = 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 ({`${name}`}); +} + +function PatternPieChart(props) { + return ( + +
+ + + {props.data.map((d, i) => )} + + `${value.toFixed(2)} hr`}/> + +
+
+ ); +} + +export const StyledPatternPieChart = withStyles(styles)(PatternPieChart); + +function DoublePieChart(props) { + return ( + + + +
+ + + {props.calendarGraphData.map((d, i) => )} + + `${value.toFixed(2)} hr`}/> + +
+
+
); +} + +DoublePieChart.propTypes = { + patternGraphData: PropTypes.array.isRequired, + calendarGraphData: PropTypes.array.isRequired, +}; + +export const AnalyzePieChart = withStyles(styles)(DoublePieChart); diff --git a/src/Dashboard.js b/src/Dashboard.js deleted file mode 100644 index 04ced46..0000000 --- a/src/Dashboard.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import 'typeface-roboto'; -import { withStyles, MuiThemeProvider } from '@material-ui/core/styles'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import AppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import Tabs from '@material-ui/core/Tabs'; -import Tab from '@material-ui/core/Tab'; -import Grid from '@material-ui/core/Grid'; -import { HashRouter as Router, withRouter, Route, Link, Redirect, Switch } from "react-router-dom"; -import { hashHistory } from 'react-router'; -import Logo from './Logo'; -import { theme } from './theme'; -import Analyze from './Analyze'; -import Settings from './Settings'; - -const styles = theme => ({ - root: { - display: 'flex', - height: '100vh', - }, - 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, - display: 'inline-block' - }, - appBarSpacer: theme.mixins.toolbar, - content: { - flexGrow: 1, - padding: theme.spacing.unit * 3, - overflow: 'auto', - }, - indicator: { - backgroundColor: theme.palette.primary.contrastText - } -}); - -class DashboardTabs extends React.Component { - handleChangeTab = (event, currentTab) => { - this.props.history.push(currentTab); - } - render() { - const { classes } = this.props; - return ( -
- - - - Chromicle - - - - - - - - -
-
- - - }/> -
-
- ); - } -} - -DashboardTabs.propTypes = { - classes: PropTypes.object.isRequired, -}; - -class Dashboard extends React.Component { - render() { - const { classes } = this.props; - let Tabs = withRouter(withStyles(styles)(DashboardTabs)); - return ( - - - ); - } -} - -export default Dashboard; diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx new file mode 100644 index 0000000..04ced46 --- /dev/null +++ b/src/Dashboard.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import 'typeface-roboto'; +import { withStyles, MuiThemeProvider } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import Typography from '@material-ui/core/Typography'; +import Paper from '@material-ui/core/Paper'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; +import Grid from '@material-ui/core/Grid'; +import { HashRouter as Router, withRouter, Route, Link, Redirect, Switch } from "react-router-dom"; +import { hashHistory } from 'react-router'; +import Logo from './Logo'; +import { theme } from './theme'; +import Analyze from './Analyze'; +import Settings from './Settings'; + +const styles = theme => ({ + root: { + display: 'flex', + height: '100vh', + }, + 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, + display: 'inline-block' + }, + appBarSpacer: theme.mixins.toolbar, + content: { + flexGrow: 1, + padding: theme.spacing.unit * 3, + overflow: 'auto', + }, + indicator: { + backgroundColor: theme.palette.primary.contrastText + } +}); + +class DashboardTabs extends React.Component { + handleChangeTab = (event, currentTab) => { + this.props.history.push(currentTab); + } + render() { + const { classes } = this.props; + return ( +
+ + + + Chromicle + + + + + + + + +
+
+ + + }/> +
+
+ ); + } +} + +DashboardTabs.propTypes = { + classes: PropTypes.object.isRequired, +}; + +class Dashboard extends React.Component { + render() { + const { classes } = this.props; + let Tabs = withRouter(withStyles(styles)(DashboardTabs)); + return ( + + + ); + } +} + +export default Dashboard; diff --git a/src/Dialog.js b/src/Dialog.js deleted file mode 100644 index 7e24176..0000000 --- a/src/Dialog.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogContentText from '@material-ui/core/DialogContentText'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import Button from '@material-ui/core/Button'; -import Slide from '@material-ui/core/Slide'; - -// modified from https://material-ui.com/demos/dialogs/ - -function Transition(props) { - return ; -} - -function AlertDialog(props) { - return ( - props.handleClose(false)} - aria-labelledby="alert-dialog-slide-title" - aria-describedby="alert-dialog-slide-description"> - - {props.title} - - - - {props.message} - - - - - - - - ); -} - -export default AlertDialog; diff --git a/src/Dialog.tsx b/src/Dialog.tsx new file mode 100644 index 0000000..7e24176 --- /dev/null +++ b/src/Dialog.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Button from '@material-ui/core/Button'; +import Slide from '@material-ui/core/Slide'; + +// modified from https://material-ui.com/demos/dialogs/ + +function Transition(props) { + return ; +} + +function AlertDialog(props) { + return ( + props.handleClose(false)} + aria-labelledby="alert-dialog-slide-title" + aria-describedby="alert-dialog-slide-description"> + + {props.title} + + + + {props.message} + + + + + + + + ); +} + +export default AlertDialog; diff --git a/src/Logo.js b/src/Logo.js deleted file mode 100644 index a4036a9..0000000 --- a/src/Logo.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -export default (props) => - - - - - - - Cr - - - - - diff --git a/src/Logo.tsx b/src/Logo.tsx new file mode 100644 index 0000000..a4036a9 --- /dev/null +++ b/src/Logo.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +export default (props) => + + + + + + + Cr + + + + + diff --git a/src/PatternTable.js b/src/PatternTable.js deleted file mode 100644 index 93be293..0000000 --- a/src/PatternTable.js +++ /dev/null @@ -1,208 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withStyles, withTheme } from '@material-ui/core/styles'; -import TextField from '@material-ui/core/TextField'; -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 DeleteOutlinedIcon from '@material-ui/icons/DeleteOutlined'; -import Popover from '@material-ui/core/Popover'; -import MaterialColorPicker from 'react-material-color-picker'; -import { CalendarField, EventField } from './RegexField'; -import { theme, defaultChartColor } from './theme'; - -const styles = theme => ({ - deleteButton: { - width: 0, - position: 'absolute', - marginRight: '2em', - right: 0, - height: 48, - }, - deleteButtonHide: { - display: 'none' - }, - deleteButtonShow: {}, - deleteIcon: { - position: 'absolute', - height: '100%', - cursor: 'pointer' - }, - patternTableWrapper: { - overflowX: 'auto', - overflowY: 'hidden' - }, - patternTable: { - minWidth: 600 - } -}); - -let nameFieldstyles = { - colorSample: { - display: 'inline-block', - height: 30, - width: 30, - marginRight: 10, - cursor: 'pointer' - } -}; - -function NameField(props) { - let color = props.value.color; - return ( - -
-
- props.onChange('name', event.target.value)} /> -
); -} - -const patternHead = [ - {label: "Name", elem: withStyles(nameFieldstyles)(NameField)}, - {label: "Calendar", elem: withTheme(theme)(CalendarField)}, - {label: "Event", elem: withTheme(theme)(EventField)}]; - -class PatternTable extends React.Component { - state = { - page: 0, - rowsPerPage: 5, - activePattern: null, - anchorEl: null, - colorPickerOpen: false, - colorPickerDefault: defaultChartColor - }; - - handleChangePage = (event, page) => { - this.setState({ page }); - } - - handleChangeRowsPerPage = event => { - this.setState({ rowsPerPage: event.target.value }); - } - - handleColorPickerClose = () => { - this.setState({ colorPickerOpen: false }); - this.activeColorPattern !== null && - this.chosenColor && - this.props.onUpdatePattern('color', this.activeColorPattern, - {background: this.chosenColor.target.value}) - } - - render() { - const { classes, calendars, patterns } = this.props; - const { rowsPerPage, page } = this.state; - const nDummy = rowsPerPage - Math.min(rowsPerPage, patterns.length - page * rowsPerPage); - let rows = patterns.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map((p, i) => { - let setActive = () => this.setState({ activePattern: p.idx }); - let unsetActive = () => this.setState({ activePattern: null }); - return [ - - - this.props.onRemovePattern(p.idx)} /> - - - , - - { - patternHead.map((s, i) => { - const CustomText = s.elem; - return ( - - this.props.onUpdatePattern(field, p.idx, value)} - colorOnClick={event => { - this.activeColorPattern = p.idx; - this.setState({ - anchorEl: event.currentTarget, - colorPickerDefault: p.color.background, - colorPickerOpen: true - }); - }}/> - )}) - } - ] - }); - rows.flat(); - - return ( -
- - { - console.log("select"); - this.chosenColor = color; - }} - onSubmit={this.handleColorPickerClose} - onReset={() => {}} - style={{width: 400, backgroundColor: '#c7c7c7'}} - submitLabel='Apply' - resetLabel='Undo' - /> - -
- - - {patternHead.map((s, i) => ({s.label}))} - - - {rows} - { - nDummy > 0 && ( - - - ) - } - -
-
- -
); - } -} - - -PatternTable.propTypes = { - classes: PropTypes.object.isRequired, - patterns: PropTypes.array.isRequired, - calendars: PropTypes.object.isRequired, - onRemovePattern: PropTypes.func.isRequired, - onUpdatePattern: PropTypes.func.isRequired, -}; - -export default withStyles(styles)(PatternTable); diff --git a/src/PatternTable.tsx b/src/PatternTable.tsx new file mode 100644 index 0000000..93be293 --- /dev/null +++ b/src/PatternTable.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles, withTheme } from '@material-ui/core/styles'; +import TextField from '@material-ui/core/TextField'; +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 DeleteOutlinedIcon from '@material-ui/icons/DeleteOutlined'; +import Popover from '@material-ui/core/Popover'; +import MaterialColorPicker from 'react-material-color-picker'; +import { CalendarField, EventField } from './RegexField'; +import { theme, defaultChartColor } from './theme'; + +const styles = theme => ({ + deleteButton: { + width: 0, + position: 'absolute', + marginRight: '2em', + right: 0, + height: 48, + }, + deleteButtonHide: { + display: 'none' + }, + deleteButtonShow: {}, + deleteIcon: { + position: 'absolute', + height: '100%', + cursor: 'pointer' + }, + patternTableWrapper: { + overflowX: 'auto', + overflowY: 'hidden' + }, + patternTable: { + minWidth: 600 + } +}); + +let nameFieldstyles = { + colorSample: { + display: 'inline-block', + height: 30, + width: 30, + marginRight: 10, + cursor: 'pointer' + } +}; + +function NameField(props) { + let color = props.value.color; + return ( + +
+
+ props.onChange('name', event.target.value)} /> +
); +} + +const patternHead = [ + {label: "Name", elem: withStyles(nameFieldstyles)(NameField)}, + {label: "Calendar", elem: withTheme(theme)(CalendarField)}, + {label: "Event", elem: withTheme(theme)(EventField)}]; + +class PatternTable extends React.Component { + state = { + page: 0, + rowsPerPage: 5, + activePattern: null, + anchorEl: null, + colorPickerOpen: false, + colorPickerDefault: defaultChartColor + }; + + handleChangePage = (event, page) => { + this.setState({ page }); + } + + handleChangeRowsPerPage = event => { + this.setState({ rowsPerPage: event.target.value }); + } + + handleColorPickerClose = () => { + this.setState({ colorPickerOpen: false }); + this.activeColorPattern !== null && + this.chosenColor && + this.props.onUpdatePattern('color', this.activeColorPattern, + {background: this.chosenColor.target.value}) + } + + render() { + const { classes, calendars, patterns } = this.props; + const { rowsPerPage, page } = this.state; + const nDummy = rowsPerPage - Math.min(rowsPerPage, patterns.length - page * rowsPerPage); + let rows = patterns.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map((p, i) => { + let setActive = () => this.setState({ activePattern: p.idx }); + let unsetActive = () => this.setState({ activePattern: null }); + return [ + + + this.props.onRemovePattern(p.idx)} /> + + + , + + { + patternHead.map((s, i) => { + const CustomText = s.elem; + return ( + + this.props.onUpdatePattern(field, p.idx, value)} + colorOnClick={event => { + this.activeColorPattern = p.idx; + this.setState({ + anchorEl: event.currentTarget, + colorPickerDefault: p.color.background, + colorPickerOpen: true + }); + }}/> + )}) + } + ] + }); + rows.flat(); + + return ( +
+ + { + console.log("select"); + this.chosenColor = color; + }} + onSubmit={this.handleColorPickerClose} + onReset={() => {}} + style={{width: 400, backgroundColor: '#c7c7c7'}} + submitLabel='Apply' + resetLabel='Undo' + /> + +
+ + + {patternHead.map((s, i) => ({s.label}))} + + + {rows} + { + nDummy > 0 && ( + + + ) + } + +
+
+ +
); + } +} + + +PatternTable.propTypes = { + classes: PropTypes.object.isRequired, + patterns: PropTypes.array.isRequired, + calendars: PropTypes.object.isRequired, + onRemovePattern: PropTypes.func.isRequired, + onUpdatePattern: PropTypes.func.isRequired, +}; + +export default withStyles(styles)(PatternTable); diff --git a/src/RegexField.js b/src/RegexField.js deleted file mode 100644 index e3fa9f4..0000000 --- a/src/RegexField.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; -import Select from '@material-ui/core/Select'; -import MenuItem from '@material-ui/core/MenuItem'; -import TextField from '@material-ui/core/TextField'; -import FormControl from '@material-ui/core/FormControl'; -import { Pattern } from './pattern'; - -const styles = theme => ({ - fieldNoRegex: { - width: 200 - }, - fieldRegex: { - marginRight: '0.5em' - } -}); - -class RegexField extends React.Component { - render() { - const { classes } = this.props; - let items = []; - var pitems = this.props.options; - const p0 = new Pattern.emptyPattern(); - pitems[p0.id] = p0; - for (let id in pitems) - { - const label = !pitems[id].isEmpty ? pitems[id].label : - Custom; - items.push({label}); - } - const selectOnClick = event => { - let value; - if (pitems[event.target.value].label == null) { - value = new Pattern(0, true, - this.props.value.isRegex ? - this.props.value.value : - `^${this.props.value.value}$`, null); - } else { - value = pitems[event.target.value]; - } - this.props.onChange(value); - }; - - const regexTextOnChange = event => this.props.onChange( - new Pattern(0, true, event.target.value, null)); - - const className = this.props.value.isRegex ? classes.fieldRegex: classes.fieldNoRegex; - return ( - - - - {this.props.value.label == null && ( - - )} - - ); - } -} - -RegexField.propTypes = { - classes: PropTypes.object.isRequired, -}; - -const RegexFieldWithStyles = withStyles(styles)(RegexField); - -export function CalendarField(props) { - let options = {}; - for (let id in props.calendars) { - options[id] = new Pattern(id, false, - props.calendars[id].name, - props.calendars[id].name); - } - return ( - props.onChange('cal', value)} - theme={props.theme} />); -} - -export function EventField(props) { - let any = Pattern.anyPattern(); - let options = {}; - options[any.id] = any; - return ( - props.onChange('event', value)} - theme={props.theme} />); -} diff --git a/src/RegexField.tsx b/src/RegexField.tsx new file mode 100644 index 0000000..e3fa9f4 --- /dev/null +++ b/src/RegexField.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import TextField from '@material-ui/core/TextField'; +import FormControl from '@material-ui/core/FormControl'; +import { Pattern } from './pattern'; + +const styles = theme => ({ + fieldNoRegex: { + width: 200 + }, + fieldRegex: { + marginRight: '0.5em' + } +}); + +class RegexField extends React.Component { + render() { + const { classes } = this.props; + let items = []; + var pitems = this.props.options; + const p0 = new Pattern.emptyPattern(); + pitems[p0.id] = p0; + for (let id in pitems) + { + const label = !pitems[id].isEmpty ? pitems[id].label : + Custom; + items.push({label}); + } + const selectOnClick = event => { + let value; + if (pitems[event.target.value].label == null) { + value = new Pattern(0, true, + this.props.value.isRegex ? + this.props.value.value : + `^${this.props.value.value}$`, null); + } else { + value = pitems[event.target.value]; + } + this.props.onChange(value); + }; + + const regexTextOnChange = event => this.props.onChange( + new Pattern(0, true, event.target.value, null)); + + const className = this.props.value.isRegex ? classes.fieldRegex: classes.fieldNoRegex; + return ( + + + + {this.props.value.label == null && ( + + )} + + ); + } +} + +RegexField.propTypes = { + classes: PropTypes.object.isRequired, +}; + +const RegexFieldWithStyles = withStyles(styles)(RegexField); + +export function CalendarField(props) { + let options = {}; + for (let id in props.calendars) { + options[id] = new Pattern(id, false, + props.calendars[id].name, + props.calendars[id].name); + } + return ( + props.onChange('cal', value)} + theme={props.theme} />); +} + +export function EventField(props) { + let any = Pattern.anyPattern(); + let options = {}; + options[any.id] = any; + return ( + props.onChange('event', value)} + theme={props.theme} />); +} diff --git a/src/Settings.js b/src/Settings.js deleted file mode 100644 index 2835483..0000000 --- a/src/Settings.js +++ /dev/null @@ -1,437 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import Typography from '@material-ui/core/Typography'; -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 RefreshIcon from '@material-ui/icons/Refresh'; -import AddCircleIcon from '@material-ui/icons/AddCircle'; -import IconButton from '@material-ui/core/IconButton'; -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 List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import Checkbox from '@material-ui/core/Checkbox'; -import * as gapi from './gapi'; -import { msgType, MsgClient } from './msg'; -import { Pattern, PatternEntry } from './pattern'; -import PatternTable from './PatternTable'; -import Snackbar from './Snackbar'; -import AlertDialog from './Dialog'; -import TextField from '@material-ui/core/TextField'; -import MenuItem from '@material-ui/core/MenuItem'; -import Select from '@material-ui/core/Select'; -import { Duration } from './duration'; - -const styles = theme => ({ - tableHead: { - verticalAlign: 'top', - textAlign: 'right', - lineHeight: '3em', - }, - tableContent: { - textAlign: 'left', - maxWidth: 600, - }, - calendarList: { - maxHeight: 400, - overflowY: 'auto' - }, -}); - -const STableCell = withStyles(theme => ({ - body: { - fontSize: 16, - }, -}))(TableCell); - -const CompactListItem = withStyles(theme => ({ - dense: { - paddingTop: 0, - paddingBottom: 0 - }, -}))(ListItem); - -class TrackedPeriod extends React.Component { - valueOnChange = (old, onChange) => event => { - onChange(new Duration(event.target.value, old.unit)); - } - - unitOnChange = (old, onChange) => event => { - onChange(new Duration(old.value, event.target.value)); - } - - static styles = { - periodName: { - textAlign: 'right' - }, - periodValue: { - width: 30, - textAlign: 'center' - } - }; - - static toValue(value) { - if (isNaN(value)) return null; - let v = parseInt(value, 10); - if (v < 0 || v > 999) return null; - return v; - } - - render() { - let { classes, fromDuration, toDuration, nameOnChange, fromOnChange, toOnChange, name } = this.props; - let units = [ - Day(s), - Week(s), - Month(s) - ]; - return ( - - nameOnChange(event.target.value)}/>: - from - ago - to - ago - - ); - } -} - -class Settings extends React.Component { - state = { - isLoggedIn: false, - patterns: [], - calendars: {}, - config: {}, - snackBarOpen: false, - snackBarMsg: 'unknown', - dialogOpen: false, - dialogMsg: {title: '', message: ''}, - calendarsLoading: false, - }; - - constructor(props) { - super(props); - gapi.getLoggedIn().then(b => this.setState({ isLoggedIn: b })); - - this.msgClient = new MsgClient('main'); - - this.msgClient.sendMsg({ - type: msgType.getPatterns, - data: { id: 'main' } - }).then(msg => { - this.setState({ patterns: msg.data.map(p => PatternEntry.inflate(p)) }); - }); - - this.msgClient.sendMsg({ - type: msgType.getCalendars, - data: { enabledOnly: false } - }).then(msg => { - this.setState({ calendars: msg.data }); - }); - - this.msgClient.sendMsg({ - type: msgType.getConfig, - data: ['trackedPeriods'] - }).then(msg => { - let config = { - trackedPeriods: msg.data.trackedPeriods.map(p => { - return { - start: Duration.inflate(p.start), - end: Duration.inflate(p.end), - name: p.name - }; - }) - }; - console.log(msg.data.trackedPeriods); - this.setState({ config }); - }); - - this.dialogPromiseResolver = null; - } - - handleLogin = () => { - gapi.login().then(() => { - this.setState({ isLoggedIn: true }); - this.loadAll(true); - }).catch(() => this.handleSnackbarOpen("Failed to login!")); - } - - handleLogout = () => { - this.handleDialogOpen("Logout", "Are you sure to logout?").then(ans => { - if (!ans) return; - gapi.logout().then(() => { - this.setState({ isLoggedIn: false }); - //this.loadPatterns([], 'analyze'); - }).catch(() => this.handleSnackbarOpen("Failed to logout!")); - }); - } - - handleToggleCalendar = id => { - var calendars = {...this.state.calendars}; - calendars[id].enabled = !calendars[id].enabled; - this.msgClient.sendMsg({ - type: msgType.updateCalendars, - data: calendars - }).then(() => this.setState({ calendars })); - } - - async loadAll(loadPatterns = false) { - await new Promise(resolver => (this.setState({ calendarsLoading: true }, resolver))); - - let colors = gapi.getAuthToken().then(gapi.getColors).then(color => { - return color.calendar; - }); - let cals = gapi.getAuthToken().then(gapi.getCalendars); - await Promise.all([colors, cals]).then(([colors, items]) => { - var cals = {}; - items.forEach(item => { - cals[item.id] = { - name: item.summary, - color: colors[item.colorId], - enabled: true - //cal: new gapi.GCalendar(item.id, item.summary) - }}); - this.loadCalendars(cals); - if (loadPatterns) - this.loadDefaultPatterns(); - }); - this.setState({ calendarsLoading: false }); - }; - - loadDefaultPatterns() { - let patterns = []; - let idx = 0; - for (let id in this.state.calendars) { - let cal = this.state.calendars[id]; - if (!cal.enabled) continue; - patterns.push(new PatternEntry(cal.name, idx++, - new Pattern(id, false, cal.name, cal.name), - Pattern.anyPattern(), - cal.color)); - } - console.log(patterns); - this.loadPatterns(patterns, 'main'); - } - - loadCalendars = calendars => { - for (let id in this.state.calendars) { - if (calendars.hasOwnProperty(id)) - calendars[id].enabled = this.state.calendars[id].enabled; - } - this.msgClient.sendMsg({ - type: msgType.updateCalendars, - data: calendars - }).then(() => this.setState({ calendars })); - }; - - loadPatterns = (patterns, id) => { - this.msgClient.sendMsg({ - type: msgType.updatePatterns, - data: { id, patterns: patterns.map(p => p.deflate()) } - }).then(() => this.setState({ patterns })); - }; - - updatePattern = (field, idx, value) => { - let patterns = this.state.patterns; - patterns[idx][field] = value; - this.loadPatterns(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.loadPatterns(patterns); - }; - - newPattern = () => { - let patterns = [PatternEntry.defaultPatternEntry(0), ...this.state.patterns]; - for (let i = 1; i < patterns.length; i++) - patterns[i].idx = i; - this.loadPatterns(patterns); - }; - - handleSnackbarClose = (event, reason) => { - if (reason === 'clickaway') return; - this.setState({ snackBarOpen: false }); - } - - handleSnackbarOpen = msg => { - this.setState({ snackBarOpen: true, snackBarMsg: msg }); - } - - handleDialogOpen = (title, message) => { - let pm = new Promise(resolver => { - this.dialogPromiseResolver = resolver - }); - this.setState({ dialogOpen: true, dialogMsg: {title, message} }); - return pm; - } - - handleDialogClose = result => { - this.dialogPromiseResolver(result); - this.setState({ dialogOpen: false }); - } - - updateTrackedPeriods = trackedPeriods => { - this.msgClient.sendMsg({ - type: msgType.updateConfig, - data: { trackedPeriods: trackedPeriods.map(p => ({ - name: p.name, - start: p.start.deflate(), - end: p.end.deflate() - })) } - }).then(() => this.setState({...this.state.config, trackedPeriods })); - } - - handlePeriodNameChange = idx => name => { - let trackedPeriods = [...this.state.config.trackedPeriods]; - trackedPeriods[idx].name = name; - this.updateTrackedPeriods(trackedPeriods); - } - - handlePeriodFromChange = idx => duration => { - let trackedPeriods = [...this.state.config.trackedPeriods]; - trackedPeriods[idx].start = duration; - this.updateTrackedPeriods(trackedPeriods); - } - - handlePeriodToChange = idx => duration => { - let trackedPeriods = [...this.state.config.trackedPeriods]; - trackedPeriods[idx].end = duration; - this.updateTrackedPeriods(trackedPeriods); - } - - render() { - const { classes } = this.props; - return ( -
- - - - General - - - - - Account - - { - (this.state.isLoggedIn && - ) || - - } - - - - - this.loadAll(false)} - disabled={this.state.calendarsLoading || !this.state.isLoggedIn}> - - - Calendars - - - {(this.state.isLoggedIn && - - {Object.keys(this.state.calendars).map(id => - this.handleToggleCalendar(id)} - disableGutters - dense button > - - - )} - ) || 'Please Login.'} - - - - - this.newPattern()} - disabled={!this.state.isLoggedIn}> - Tracked Events -
- -
-
- - {(this.state.isLoggedIn && - - - ) || 'Please Login.'} - -
- - - Tracked Time Range - - - {this.state.config.trackedPeriods && - this.state.config.trackedPeriods.map((p, idx) => - - - )} - - -
-
-
- ); - } -} - -Settings.propTypes = { - classes: PropTypes.object.isRequired, -}; - -export default withStyles(styles)(Settings); diff --git a/src/Settings.tsx b/src/Settings.tsx new file mode 100644 index 0000000..83f1da6 --- /dev/null +++ b/src/Settings.tsx @@ -0,0 +1,437 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import Typography from '@material-ui/core/Typography'; +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 RefreshIcon from '@material-ui/icons/Refresh'; +import AddCircleIcon from '@material-ui/icons/AddCircle'; +import IconButton from '@material-ui/core/IconButton'; +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 List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Checkbox from '@material-ui/core/Checkbox'; +import * as gapi from './gapi'; +import { MsgType, MsgClient } from './msg'; +import { Pattern, PatternEntry } from './pattern'; +import PatternTable from './PatternTable'; +import Snackbar from './Snackbar'; +import AlertDialog from './Dialog'; +import TextField from '@material-ui/core/TextField'; +import MenuItem from '@material-ui/core/MenuItem'; +import Select from '@material-ui/core/Select'; +import { Duration } from './duration'; + +const styles = theme => ({ + tableHead: { + verticalAlign: 'top', + textAlign: 'right', + lineHeight: '3em', + }, + tableContent: { + textAlign: 'left', + maxWidth: 600, + }, + calendarList: { + maxHeight: 400, + overflowY: 'auto' + }, +}); + +const STableCell = withStyles(theme => ({ + body: { + fontSize: 16, + }, +}))(TableCell); + +const CompactListItem = withStyles(theme => ({ + dense: { + paddingTop: 0, + paddingBottom: 0 + }, +}))(ListItem); + +class TrackedPeriod extends React.Component { + valueOnChange = (old, onChange) => event => { + onChange(new Duration(event.target.value, old.unit)); + } + + unitOnChange = (old, onChange) => event => { + onChange(new Duration(old.value, event.target.value)); + } + + static styles = { + periodName: { + textAlign: 'right' + }, + periodValue: { + width: 30, + textAlign: 'center' + } + }; + + static toValue(value) { + if (isNaN(value)) return null; + let v = parseInt(value, 10); + if (v < 0 || v > 999) return null; + return v; + } + + render() { + let { classes, fromDuration, toDuration, nameOnChange, fromOnChange, toOnChange, name } = this.props; + let units = [ + Day(s), + Week(s), + Month(s) + ]; + return ( + + nameOnChange(event.target.value)}/>: + from + ago + to + ago + + ); + } +} + +class Settings extends React.Component { + state = { + isLoggedIn: false, + patterns: [], + calendars: {}, + config: {}, + snackBarOpen: false, + snackBarMsg: 'unknown', + dialogOpen: false, + dialogMsg: {title: '', message: ''}, + calendarsLoading: false, + }; + + constructor(props) { + super(props); + gapi.getLoggedIn().then(b => this.setState({ isLoggedIn: b })); + + this.msgClient = new MsgClient('main'); + + this.msgClient.sendMsg({ + type: MsgType.getPatterns, + data: { id: 'main' } + }).then(msg => { + this.setState({ patterns: msg.data.map(p => PatternEntry.inflate(p)) }); + }); + + this.msgClient.sendMsg({ + type: MsgType.getCalendars, + data: { enabledOnly: false } + }).then(msg => { + this.setState({ calendars: msg.data }); + }); + + this.msgClient.sendMsg({ + type: MsgType.getConfig, + data: ['trackedPeriods'] + }).then(msg => { + let config = { + trackedPeriods: msg.data.trackedPeriods.map(p => { + return { + start: Duration.inflate(p.start), + end: Duration.inflate(p.end), + name: p.name + }; + }) + }; + console.log(msg.data.trackedPeriods); + this.setState({ config }); + }); + + this.dialogPromiseResolver = null; + } + + handleLogin = () => { + gapi.login().then(() => { + this.setState({ isLoggedIn: true }); + this.loadAll(true); + }).catch(() => this.handleSnackbarOpen("Failed to login!")); + } + + handleLogout = () => { + this.handleDialogOpen("Logout", "Are you sure to logout?").then(ans => { + if (!ans) return; + gapi.logout().then(() => { + this.setState({ isLoggedIn: false }); + //this.loadPatterns([], 'analyze'); + }).catch(() => this.handleSnackbarOpen("Failed to logout!")); + }); + } + + handleToggleCalendar = id => { + var calendars = {...this.state.calendars}; + calendars[id].enabled = !calendars[id].enabled; + this.msgClient.sendMsg({ + type: MsgType.updateCalendars, + data: calendars + }).then(() => this.setState({ calendars })); + } + + async loadAll(loadPatterns = false) { + await new Promise(resolver => (this.setState({ calendarsLoading: true }, resolver))); + + let colors = gapi.getAuthToken().then(gapi.getColors).then(color => { + return color.calendar; + }); + let cals = gapi.getAuthToken().then(gapi.getCalendars); + await Promise.all([colors, cals]).then(([colors, items]) => { + var cals = {}; + items.forEach(item => { + cals[item.id] = { + name: item.summary, + color: colors[item.colorId], + enabled: true + //cal: new gapi.GCalendar(item.id, item.summary) + }}); + this.loadCalendars(cals); + if (loadPatterns) + this.loadDefaultPatterns(); + }); + this.setState({ calendarsLoading: false }); + }; + + loadDefaultPatterns() { + let patterns = []; + let idx = 0; + for (let id in this.state.calendars) { + let cal = this.state.calendars[id]; + if (!cal.enabled) continue; + patterns.push(new PatternEntry(cal.name, idx++, + new Pattern(id, false, cal.name, cal.name), + Pattern.anyPattern(), + cal.color)); + } + console.log(patterns); + this.loadPatterns(patterns, 'main'); + } + + loadCalendars = calendars => { + for (let id in this.state.calendars) { + if (calendars.hasOwnProperty(id)) + calendars[id].enabled = this.state.calendars[id].enabled; + } + this.msgClient.sendMsg({ + type: MsgType.updateCalendars, + data: calendars + }).then(() => this.setState({ calendars })); + }; + + loadPatterns = (patterns, id) => { + this.msgClient.sendMsg({ + type: MsgType.updatePatterns, + data: { id, patterns: patterns.map(p => p.deflate()) } + }).then(() => this.setState({ patterns })); + }; + + updatePattern = (field, idx, value) => { + let patterns = this.state.patterns; + patterns[idx][field] = value; + this.loadPatterns(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.loadPatterns(patterns); + }; + + newPattern = () => { + let patterns = [PatternEntry.defaultPatternEntry(0), ...this.state.patterns]; + for (let i = 1; i < patterns.length; i++) + patterns[i].idx = i; + this.loadPatterns(patterns); + }; + + handleSnackbarClose = (event, reason) => { + if (reason === 'clickaway') return; + this.setState({ snackBarOpen: false }); + } + + handleSnackbarOpen = msg => { + this.setState({ snackBarOpen: true, snackBarMsg: msg }); + } + + handleDialogOpen = (title, message) => { + let pm = new Promise(resolver => { + this.dialogPromiseResolver = resolver + }); + this.setState({ dialogOpen: true, dialogMsg: {title, message} }); + return pm; + } + + handleDialogClose = result => { + this.dialogPromiseResolver(result); + this.setState({ dialogOpen: false }); + } + + updateTrackedPeriods = trackedPeriods => { + this.msgClient.sendMsg({ + type: MsgType.updateConfig, + data: { trackedPeriods: trackedPeriods.map(p => ({ + name: p.name, + start: p.start.deflate(), + end: p.end.deflate() + })) } + }).then(() => this.setState({...this.state.config, trackedPeriods })); + } + + handlePeriodNameChange = idx => name => { + let trackedPeriods = [...this.state.config.trackedPeriods]; + trackedPeriods[idx].name = name; + this.updateTrackedPeriods(trackedPeriods); + } + + handlePeriodFromChange = idx => duration => { + let trackedPeriods = [...this.state.config.trackedPeriods]; + trackedPeriods[idx].start = duration; + this.updateTrackedPeriods(trackedPeriods); + } + + handlePeriodToChange = idx => duration => { + let trackedPeriods = [...this.state.config.trackedPeriods]; + trackedPeriods[idx].end = duration; + this.updateTrackedPeriods(trackedPeriods); + } + + render() { + const { classes } = this.props; + return ( +
+ + + + General + + + + + Account + + { + (this.state.isLoggedIn && + ) || + + } + + + + + this.loadAll(false)} + disabled={this.state.calendarsLoading || !this.state.isLoggedIn}> + + + Calendars + + + {(this.state.isLoggedIn && + + {Object.keys(this.state.calendars).map(id => + this.handleToggleCalendar(id)} + disableGutters + dense button > + + + )} + ) || 'Please Login.'} + + + + + this.newPattern()} + disabled={!this.state.isLoggedIn}> + Tracked Events +
+ +
+
+ + {(this.state.isLoggedIn && + + + ) || 'Please Login.'} + +
+ + + Tracked Time Range + + + {this.state.config.trackedPeriods && + this.state.config.trackedPeriods.map((p, idx) => + + + )} + + +
+
+
+ ); + } +} + +Settings.propTypes = { + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(Settings); diff --git a/src/Snackbar.js b/src/Snackbar.js deleted file mode 100644 index f17863c..0000000 --- a/src/Snackbar.js +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { withStyles } from '@material-ui/core/styles'; -import amber from '@material-ui/core/colors/amber'; -import Snackbar from '@material-ui/core/Snackbar'; -import SnackbarContent from '@material-ui/core/SnackbarContent'; -import ErrorIcon from '@material-ui/icons/Error'; -import WarningIcon from '@material-ui/icons/Warning'; -import CloseIcon from '@material-ui/icons/Close'; -import IconButton from '@material-ui/core/IconButton'; - -// modified from https://material-ui.com/demos/snackbars/ - -const variantIcon = { - error: ErrorIcon, - warning: WarningIcon, -}; - -const styles = theme => ({ - error: { - backgroundColor: theme.palette.error.dark, - }, - warning: { - backgroundColor: amber[700], - }, - icon: { - fontSize: 20, - }, - iconVariant: { - opacity: 0.9, - marginRight: theme.spacing.unit, - }, - message: { - display: 'flex', - alignItems: 'center', - }, -}); - -function CustomSnackbar(props) { - const { classes, className, message, variant, open, onClose, ...other } = props; - const Icon = variantIcon[variant]; - return ( - - - - {message} - - } - action={[ - - - , - ]} - {...other} - /> - - ); -} - -export default withStyles(styles)(CustomSnackbar); diff --git a/src/Snackbar.tsx b/src/Snackbar.tsx new file mode 100644 index 0000000..f17863c --- /dev/null +++ b/src/Snackbar.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { withStyles } from '@material-ui/core/styles'; +import amber from '@material-ui/core/colors/amber'; +import Snackbar from '@material-ui/core/Snackbar'; +import SnackbarContent from '@material-ui/core/SnackbarContent'; +import ErrorIcon from '@material-ui/icons/Error'; +import WarningIcon from '@material-ui/icons/Warning'; +import CloseIcon from '@material-ui/icons/Close'; +import IconButton from '@material-ui/core/IconButton'; + +// modified from https://material-ui.com/demos/snackbars/ + +const variantIcon = { + error: ErrorIcon, + warning: WarningIcon, +}; + +const styles = theme => ({ + error: { + backgroundColor: theme.palette.error.dark, + }, + warning: { + backgroundColor: amber[700], + }, + icon: { + fontSize: 20, + }, + iconVariant: { + opacity: 0.9, + marginRight: theme.spacing.unit, + }, + message: { + display: 'flex', + alignItems: 'center', + }, +}); + +function CustomSnackbar(props) { + const { classes, className, message, variant, open, onClose, ...other } = props; + const Icon = variantIcon[variant]; + return ( + + + + {message} + + } + action={[ + + + , + ]} + {...other} + /> + + ); +} + +export default withStyles(styles)(CustomSnackbar); diff --git a/src/background.js b/src/background.js deleted file mode 100644 index f22970e..0000000 --- a/src/background.js +++ /dev/null @@ -1,217 +0,0 @@ -import * as gapi from './gapi'; -import { msgType, Msg } from './msg'; -import { Duration } from './duration'; -import moment from 'moment'; -import { getChartData } from './Chart'; -import { PatternEntry } from './pattern'; - -let mainPatterns = []; -let analyzePatterns = []; -let calendars = {}; -let calData = {}; -let config = { - trackedPeriods: [ - {name: 'Today', start: Duration.days(1), end: Duration.days(0)}, - {name: 'Yesterday', start: Duration.days(2), end: Duration.days(1)}, - {name: 'This Week', start: Duration.weeks(1), end: Duration.weeks(0)}, - {name: 'This Month', start: Duration.months(1), end: Duration.months(0)}] -}; -let mainGraphData = []; -let dirtyMetadata = false; - -function loadMetadata() { - return new Promise(resolver => chrome.storage.local.get([ - 'calendars', 'config', 'mainPatterns', 'analyzePatterns', - ], function(items) { - if (chrome.runtime.lastError) - console.error("error while loading saved metadata"); - else if (!items.hasOwnProperty('config')) - console.log("no saved metadata"); - else - { - console.log('metadata loaded'); - config = { - trackedPeriods: items.config.trackedPeriods.map(p => ({ - name: p.name, - start: Duration.inflate(p.start), - end: Duration.inflate(p.end), - })) - }; - calendars = items.calendars; - mainPatterns = items.mainPatterns.map(p => PatternEntry.inflate(p)); - analyzePatterns = items.analyzePatterns.map(p => PatternEntry.inflate(p)); - } - resolver(); - })); -} - -function saveMetadata() { - return new Promise(resolver => chrome.storage.local.set({ - calendars, - config: { - trackedPeriods: config.trackedPeriods.map(p => ({ - name: p.name, - start: p.start.deflate(), - end: p.end.deflate() - })) - }, - mainPatterns: mainPatterns.map(p => p.deflate()), - analyzePatterns: analyzePatterns.map(p => p.deflate()) - }, function() { - console.log('metadata saved'); - resolver(); - })); -} - -function getCalEvents(id, start, end) { - if (!calData.hasOwnProperty(id)) - calData[id] = new gapi.GCalendar(id, calendars[id].summary); - return calData[id].getEvents(new Date(start), new Date(end)) - .catch(e => { - console.log(`cannot load calendar ${id}`, e); - calendars[id].enabled = false; - return []; - }); -} - -function updateMainGraphData() { - console.log('refreshing graph data'); - console.log(mainGraphData); - let pms = []; - for (let i = 0; i < config.trackedPeriods.length; i++) - { - let p = config.trackedPeriods[i]; - let startD = p.start.toMoment(); - let endD = p.end.toMoment(); - if (!(startD && endD)) return; - let start = moment().endOf('day'); - if (endD.valueOf() == 0) { - switch (p.start.unit) { - case 'days': start = moment().endOf('day'); break; - case 'weeks': start = moment().endOf('week'); break; - case 'months': start = moment().endOf('month'); break; - default: - } - } - let end = start.clone(); - start.subtract(startD); - end.subtract(endD); - pms.push(getChartData( - start.toDate(), - end.toDate(), - mainPatterns, - calendars, - (id, start,end) => getCalEvents(id, start, end).then(d => d.map(e => ({ - id: e.id, - start: e.start.getTime(), - end: e.end.getTime() - })))).then(results => { - mainGraphData[i] = { - name: p.name, start, end, - data: results.patternGraphData - }; - })); - } - return Promise.all(pms); -} - -async function pollSync() { - console.log('poll'); - await updateMainGraphData(); - if (dirtyMetadata) - await saveMetadata().then(() => dirtyMetadata = false); - return new Promise(resolver => ( - window.setTimeout(() => { resolver(); pollSync();}, 10000) - )); -} - -loadMetadata().then(() => pollSync()); - -chrome.runtime.onConnect.addListener(function(port) { - console.assert(port.name == 'main'); - port.onMessage.addListener(function(_msg) { - let msg = Msg.inflate(_msg); - console.log(msg); - switch (msg.type) { - case msgType.updatePatterns: { - let patterns = msg.data.patterns.map(p => PatternEntry.inflate(p)); - if (msg.data.id == 'analyze') - analyzePatterns = patterns; - else - mainPatterns = patterns; - dirtyMetadata = true; - port.postMessage(msg.genResp(null)); - break; - } - case msgType.getPatterns: { - let patterns; - if (msg.data.id == 'analyze') - patterns = analyzePatterns; - else - patterns = mainPatterns; - port.postMessage(msg.genResp(patterns.map(p => p.deflate()))); - break; - } - case msgType.updateCalendars: { - calendars = msg.data; - dirtyMetadata = true; - port.postMessage(msg.genResp(null)); - break; - } - case msgType.getCalendars: { - let cals = calendars; - if (msg.data.enabledOnly) - { - cals = Object.keys(calendars) - .filter(id => calendars[id].enabled) - .reduce((res, id) => (res[id] = calendars[id], res), {}); - } - port.postMessage(msg.genResp(cals)); - break; - } - case msgType.getCalEvents: { - getCalEvents(msg.data.id, msg.data.start, msg.data.end).then(data => { - console.log(data); - let resp = msg.genResp(data.map(e => { - return { - id: e.id, - start: e.start.getTime(), - end: e.end.getTime() - } - })); - console.log(resp); - port.postMessage(resp); - }); - break; - } - case msgType.updateConfig: { - config.trackedPeriods = msg.data.trackedPeriods.map(p => ({ - name: p.name, - start: Duration.inflate(p.start), - end: Duration.inflate(p.end) - })); - dirtyMetadata = true; - port.postMessage(msg.genResp(null)); - break; - } - case msgType.getConfig: { - let res = {}; - msg.data.forEach(prop => res[prop] = config[prop]); - port.postMessage(msg.genResp(res)); - break; - } - case msgType.getGraphData: { - (msg.data.sync ? updateMainGraphData() : Promise.resolve()).then(() => ( - port.postMessage(msg.genResp(mainGraphData.map(d => ({ - name: d.name, - start: d.start.toISOString(), - end: d.end.toISOString(), - data: d.data - })))) - )); - break; - } - default: console.error("unknown msg type"); - } - }); -}); diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..2a23b57 --- /dev/null +++ b/src/background.ts @@ -0,0 +1,217 @@ +import * as gapi from './gapi'; +import { msgType, Msg } from './msg'; +import { Duration } from './duration'; +import moment from 'moment'; +import { getChartData } from './Chart'; +import { PatternEntry } from './pattern'; + +let mainPatterns: number[] = []; +let analyzePatterns = []; +let calendars = {}; +let calData = {}; +let config = { + trackedPeriods: [ + {name: 'Today', start: Duration.days(1), end: Duration.days(0)}, + {name: 'Yesterday', start: Duration.days(2), end: Duration.days(1)}, + {name: 'This Week', start: Duration.weeks(1), end: Duration.weeks(0)}, + {name: 'This Month', start: Duration.months(1), end: Duration.months(0)}] +}; +let mainGraphData = []; +let dirtyMetadata = false; + +function loadMetadata() { + return new Promise(resolver => chrome.storage.local.get([ + 'calendars', 'config', 'mainPatterns', 'analyzePatterns', + ], function(items) { + if (chrome.runtime.lastError) + console.error("error while loading saved metadata"); + else if (!items.hasOwnProperty('config')) + console.log("no saved metadata"); + else + { + console.log('metadata loaded'); + config = { + trackedPeriods: items.config.trackedPeriods.map(p => ({ + name: p.name, + start: Duration.inflate(p.start), + end: Duration.inflate(p.end), + })) + }; + calendars = items.calendars; + mainPatterns = items.mainPatterns.map(p => PatternEntry.inflate(p)); + analyzePatterns = items.analyzePatterns.map(p => PatternEntry.inflate(p)); + } + resolver(); + })); +} + +function saveMetadata() { + return new Promise(resolver => chrome.storage.local.set({ + calendars, + config: { + trackedPeriods: config.trackedPeriods.map(p => ({ + name: p.name, + start: p.start.deflate(), + end: p.end.deflate() + })) + }, + mainPatterns: mainPatterns.map(p => p.deflate()), + analyzePatterns: analyzePatterns.map(p => p.deflate()) + }, function() { + console.log('metadata saved'); + resolver(); + })); +} + +function getCalEvents(id, start, end) { + if (!calData.hasOwnProperty(id)) + calData[id] = new gapi.GCalendar(id, calendars[id].summary); + return calData[id].getEvents(new Date(start), new Date(end)) + .catch(e => { + console.log(`cannot load calendar ${id}`, e); + calendars[id].enabled = false; + return []; + }); +} + +function updateMainGraphData() { + console.log('refreshing graph data'); + console.log(mainGraphData); + let pms = []; + for (let i = 0; i < config.trackedPeriods.length; i++) + { + let p = config.trackedPeriods[i]; + let startD = p.start.toMoment(); + let endD = p.end.toMoment(); + if (!(startD && endD)) return; + let start = moment().endOf('day'); + if (endD.valueOf() == 0) { + switch (p.start.unit) { + case 'days': start = moment().endOf('day'); break; + case 'weeks': start = moment().endOf('week'); break; + case 'months': start = moment().endOf('month'); break; + default: + } + } + let end = start.clone(); + start.subtract(startD); + end.subtract(endD); + pms.push(getChartData( + start.toDate(), + end.toDate(), + mainPatterns, + calendars, + (id, start,end) => getCalEvents(id, start, end).then(d => d.map(e => ({ + id: e.id, + start: e.start.getTime(), + end: e.end.getTime() + })))).then(results => { + mainGraphData[i] = { + name: p.name, start, end, + data: results.patternGraphData + }; + })); + } + return Promise.all(pms); +} + +async function pollSync() { + console.log('poll'); + await updateMainGraphData(); + if (dirtyMetadata) + await saveMetadata().then(() => dirtyMetadata = false); + return new Promise(resolver => ( + window.setTimeout(() => { resolver(); pollSync();}, 10000) + )); +} + +loadMetadata().then(() => pollSync()); + +chrome.runtime.onConnect.addListener(function(port) { + console.assert(port.name == 'main'); + port.onMessage.addListener(function(_msg) { + let msg = Msg.inflate(_msg); + console.log(msg); + switch (msg.type) { + case msgType.updatePatterns: { + let patterns = msg.data.patterns.map(p => PatternEntry.inflate(p)); + if (msg.data.id == 'analyze') + analyzePatterns = patterns; + else + mainPatterns = patterns; + dirtyMetadata = true; + port.postMessage(msg.genResp(null)); + break; + } + case msgType.getPatterns: { + let patterns; + if (msg.data.id == 'analyze') + patterns = analyzePatterns; + else + patterns = mainPatterns; + port.postMessage(msg.genResp(patterns.map(p => p.deflate()))); + break; + } + case msgType.updateCalendars: { + calendars = msg.data; + dirtyMetadata = true; + port.postMessage(msg.genResp(null)); + break; + } + case msgType.getCalendars: { + let cals = calendars; + if (msg.data.enabledOnly) + { + cals = Object.keys(calendars) + .filter(id => calendars[id].enabled) + .reduce((res, id) => (res[id] = calendars[id], res), {}); + } + port.postMessage(msg.genResp(cals)); + break; + } + case msgType.getCalEvents: { + getCalEvents(msg.data.id, msg.data.start, msg.data.end).then(data => { + console.log(data); + let resp = msg.genResp(data.map(e => { + return { + id: e.id, + start: e.start.getTime(), + end: e.end.getTime() + } + })); + console.log(resp); + port.postMessage(resp); + }); + break; + } + case msgType.updateConfig: { + config.trackedPeriods = msg.data.trackedPeriods.map(p => ({ + name: p.name, + start: Duration.inflate(p.start), + end: Duration.inflate(p.end) + })); + dirtyMetadata = true; + port.postMessage(msg.genResp(null)); + break; + } + case msgType.getConfig: { + let res = {}; + msg.data.forEach(prop => res[prop] = config[prop]); + port.postMessage(msg.genResp(res)); + break; + } + case msgType.getGraphData: { + (msg.data.sync ? updateMainGraphData() : Promise.resolve()).then(() => ( + port.postMessage(msg.genResp(mainGraphData.map(d => ({ + name: d.name, + start: d.start.toISOString(), + end: d.end.toISOString(), + data: d.data + })))) + )); + break; + } + default: console.error("unknown msg type"); + } + }); +}); diff --git a/src/decl.ts b/src/decl.ts new file mode 100644 index 0000000..5b3daa3 --- /dev/null +++ b/src/decl.ts @@ -0,0 +1 @@ +declare module 'react-dates'; diff --git a/src/duration.js b/src/duration.js deleted file mode 100644 index 53de0ad..0000000 --- a/src/duration.js +++ /dev/null @@ -1,21 +0,0 @@ -import moment from 'moment'; - -export class Duration { - constructor(value, unit) { - this.value = value - this.unit = unit - } - - toMoment() { - let m = moment.duration(this.value, this.unit); - if (m.isValid()) return m; - return null; - } - - static days(n) { return new Duration(n, 'days'); } - static weeks(n) { return new Duration(n, 'weeks'); } - static months(n) { return new Duration(n, 'months'); } - - deflate() { return { value: this.value, unit: this.unit }; } - static inflate = obj => new Duration(obj.value, obj.unit); -} diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..18849a0 --- /dev/null +++ b/src/duration.ts @@ -0,0 +1,26 @@ +import moment from 'moment'; + +export type TimeUnit = moment.unitOfTime.DurationConstructor; + +export class Duration { + value: number; + unit: TimeUnit; + constructor(value: number, unit: TimeUnit) { + this.value = value + this.unit = unit + } + + isValid() { return moment.duration(this.value, this.unit).isValid(); } + toMoment() { + let m = moment.duration(this.value, this.unit); + if (m.isValid()) return m; + return null; + } + + static days(n: number) { return new Duration(n, 'days'); } + static weeks(n: number) { return new Duration(n, 'weeks'); } + static months(n: number) { return new Duration(n, 'months'); } + + deflate() { return { value: this.value, unit: this.unit }; } + static inflate = (obj: { value: number, unit: TimeUnit }) => new Duration(obj.value, obj.unit); +} diff --git a/src/gapi.js b/src/gapi.js deleted file mode 100644 index 3938864..0000000 --- a/src/gapi.js +++ /dev/null @@ -1,335 +0,0 @@ -/* global chrome */ -import LRU from "lru-cache"; -const gapi_base = 'https://www.googleapis.com/calendar/v3'; - -const GApiError = Object.freeze({ - invalidSyncToken: Symbol("invalidSyncToken"), - notLoggedIn: Symbol("notLoggedIn"), - notLoggedOut: Symbol("notLoggedOut"), - otherError: Symbol("otherError"), -}); - -function to_params(dict) { - return Object.entries(dict).filter(([k, v]) => v).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); -} - -let loggedIn = null; - -function _getAuthToken(interactive = false) { - return new Promise(resolver => - chrome.identity.getAuthToken( - { interactive }, token => resolver([token, !chrome.runtime.lastError]))) - .then(([token, ok]) => { - if (ok) return token; - else throw GApiError.notLoggedIn; - }); -} - -function _removeCachedAuthToken(token) { - return new Promise(resolver => - chrome.identity.removeCachedAuthToken({ token }, () => resolver())); -} - -export function getLoggedIn() { - if (loggedIn === null) - { - return _getAuthToken(false) - .then(() => loggedIn = true) - .catch(() => loggedIn = false) - .then(() => loggedIn); - } - else return Promise.resolve(loggedIn); -} - -export function getAuthToken() { - return getLoggedIn().then(b => { - if (b) return _getAuthToken(false); - else throw GApiError.notLoggedIn; - }); -} - -export function login() { - return getLoggedIn().then(b => { - if (!b) return _getAuthToken(true).then(() => loggedIn = true); - else throw GApiError.notLoggedOut; - }); -} - -export function logout() { - return getAuthToken().then(token => { - return fetch(`https://accounts.google.com/o/oauth2/revoke?${to_params({ token })}`, - { method: 'GET', async: true }).then(response => { - //if (response.status === 200) - return _removeCachedAuthToken(token); - //else throw GApiError.otherError; - }); - }).then(() => loggedIn = false); -} - -export 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); -} - -export function getColors(token) { - return fetch(`${gapi_base}/colors?${to_params({access_token: token})}`, - { method: 'GET', async: true }) - .then(response => response.json()); -} - -function getEvent(calId, eventId, token) { - return fetch(`${gapi_base}/calendars/${calId}/events/${eventId}?${to_params({access_token: token})}`, - { method: 'GET', async: true }) - .then(response => response.json()); -} - -function getEvents(calId, token, syncToken=null, timeMin=null, timeMax=null, resultsPerRequest=100) { - let results = []; - const singleFetch = (pageToken, syncToken) => fetch(`${gapi_base}/calendars/${calId}/events?${to_params({ - access_token: token, - pageToken, - syncToken, - timeMin, - timeMax, - maxResults: resultsPerRequest - })}`, { method: 'GET', async: true }) - .then(response => { - if (response.status === 200) - return response.json(); - else if (response.status === 410) - throw GApiError.invalidSyncToken; - else throw GApiError.otherError; - }) - .then(data => { - results.push(...data.items); - if (data.nextPageToken) { - return singleFetch(data.nextPageToken, ''); - } else { - return ({ - nextSyncToken: data.nextSyncToken, - results - }); - } - }) - - return singleFetch('', syncToken); -} - -export class GCalendar { - constructor(calId, name, options={maxCachedItems: 100, nDaysPerSlot: 10, largeQuery: 10}) { - this.calId = calId; - this.name = name; - this.syncToken = ''; - this.cache = new LRU({ - max: options.maxCachedItems, - dispose: (k, v) => this.onRemoveSlot(k, v) - }); - this.eventMeta = {}; - this.options = options; - this.divider = 8.64e7 * this.options.nDaysPerSlot; - } - - get token() { return getAuthToken(); } - - dateToCacheKey(date) { - return Math.floor(date / this.divider); - } - - dateRangeToCacheKeys(range) { - return { - start: this.dateToCacheKey(range.start), - end: this.dateToCacheKey(new Date(range.end.getTime() - 1)) - }; - } - - getSlot(k) { - if (!this.cache.has(k)) - { - let res = {}; - this.cache.set(k, res); - return res; - } - else return this.cache.get(k); - } - - onRemoveSlot(k, v) { - for (let id in v) { - console.assert(this.eventMeta[id]); - let keys = this.eventMeta[id].keys; - keys.delete(k); - if (keys.size === 0) - delete this.eventMeta[id]; - } - } - - slotStartDate(k) { return new Date(k * this.divider); } - slotEndDate(k) { return new Date((k + 1) * this.divider); } - - addEvent(e, evict = false) { - //console.log('adding event', e); - if (this.eventMeta.hasOwnProperty(e.id)) - this.removeEvent(e); - let r = this.dateRangeToCacheKeys(e); - let ks = r.start; - let ke = r.end; - let t = this.cache.length; - let keys = new Set(); - for (let i = ks; i <= ke; i++) - { - keys.add(i); - if (!this.cache.has(i)) t++; - } - this.eventMeta[e.id] = { - keys, - summary: e.summary, - }; - if (!evict && t > this.options.maxCachedItems) return; - if (ks === ke) - this.getSlot(ks)[e.id] = { - start: e.start, - end: e.end, - id: e.id }; - else - { - this.getSlot(ks)[e.id] = { - start: e.start, - end: this.slotEndDate(ks), - id: e.id }; - this.getSlot(ke)[e.id] = { - start: this.slotStartDate(ke), - end: e.end, - id: e.id }; - for (let k = ks + 1; k < ke; k++) - this.getSlot(k)[e.id] = { - start: this.slotStartDate(k), - end: this.slotEndDate(k), - id: e.id}; - } - } - - removeEvent(e) { - let keys = this.eventMeta[e.id].keys; - console.assert(keys); - keys.forEach(k => delete this.getSlot(k)[e.id]); - delete this.eventMeta[e.id]; - } - - getSlotEvents(k, start, end) { - let s = this.getSlot(k); - //console.log(s); - let results = []; - for (let id in s) { - if (!(s[id].start >= end || s[id].end <= start)) - { - results.push({ - id, - start: s[id].start < start ? start: s[id].start, - end: s[id].end > end ? end: s[id].end, - summary: this.eventMeta[id].summary - }); - } - } - return results; - } - - getCachedEvents(_r) { - let r = this.dateRangeToCacheKeys(_r); - let ks = r.start; - let ke = r.end; - let results = this.getSlotEvents(ks, _r.start, _r.end); - for (let k = ks + 1; k < ke; k++) - { - let s = this.getSlot(k); - for (let id in s) - results.push(s[id]); - } - if (ke > ks) - results.push(...this.getSlotEvents(ke, _r.start, _r.end)); - return results; - } - - sync() { - return this.token.then(token => getEvents(this.calId, token, this.syncToken).then(r => { - let pms = r.results.map(e => e.start ? Promise.resolve(e) : getEvent(this.calId, e.id, token)); - return Promise.all(pms).then(results => { - results.forEach(e => { - e.start = new Date(e.start.dateTime); - e.end = new Date(e.end.dateTime); - if (e.status === 'confirmed') - this.addEvent(e); - else if (e.status === 'cancelled') - this.removeEvent(e); - }); - this.syncToken = r.nextSyncToken; - }); - })).catch(e => { - if (e === GApiError.invalidSyncToken) { - this.syncToken = ''; - this.sync(); - } else throw e; - }); - } - - getEvents(start, end) { - let r = this.dateRangeToCacheKeys({ start, end }); - let query = {}; - for (let k = r.start; k <= r.end; k++) - if (!this.cache.has(k)) - { - if (!query.hasOwnProperty('start')) - query.start = k; - query.end = k; - } - //console.log(`start: ${start} end: ${end}`); - if (query.hasOwnProperty('start')) - { - console.assert(query.start <= query.end); - if (query.end - query.start + 1 > this.options.largeQuery) { - console.log(`encounter large query, use direct fetch`); - return this.token.then(token => getEvents(this.calId, token, null, - start.toISOString(), end.toISOString()).then(r => { - let results = []; - r.results.forEach(e => { - console.assert(e.start); - e.start = new Date(e.start.dateTime); - e.end = new Date(e.end.dateTime); - results.push(e); - }); - return results.filter(e => !(e.start >= end || e.end <= start)).map(e => { - return { - id: e.id, - start: e.start < start ? start: e.start, - end: e.end > end ? end: e.end, - summary: e.summary, - }; - }); - })); - } - - console.log(`fetching short event list`); - return this.token.then(token => getEvents(this.calId, token, null, - this.slotStartDate(query.start).toISOString(), - this.slotEndDate(query.end).toISOString()).then(r => { - r.results.forEach(e => { - if (e.status === 'confirmed') - { - console.assert(e.start); - e.start = new Date(e.start.dateTime); - e.end = new Date(e.end.dateTime); - this.addEvent(e, true); - } - }); - if (this.syncToken === '') - this.syncToken = r.nextSyncToken; - })).then(() => this.sync()) - .then(() => this.getCachedEvents({ start, end })); - } - else - { - console.log(`cache hit`); - return this.sync().then(() => this.getCachedEvents({ start, end })); - } - } -} diff --git a/src/gapi.ts b/src/gapi.ts new file mode 100644 index 0000000..bf45ffc --- /dev/null +++ b/src/gapi.ts @@ -0,0 +1,369 @@ +/* global chrome */ + +import LRU from "lru-cache"; + +const gapiBase = 'https://www.googleapis.com/calendar/v3'; + +enum GApiError { + invalidSyncToken = "invalidSyncToken", + notLoggedIn = "notLoggedIn", + notLoggedOut = "notLoggedOut", + otherError = "otherError", +} + +function to_params(dict: Object) { + return Object.entries(dict).filter(([k, v] : string[]) => v) + .map(([k, v]: string[]) => ( + `${encodeURIComponent(k)}=${encodeURIComponent(v)}` + )).join('&'); +} + +let loggedIn = false; + +function _getAuthToken(interactive = false): Promise { + return new Promise(resolver => + chrome.identity.getAuthToken( + { interactive }, token => resolver([token, !chrome.runtime.lastError]))) + .then(([token, ok] : [string, boolean]) => { + if (ok) return token; + else throw GApiError.notLoggedIn; + }); +} + +function _removeCachedAuthToken(token: string) { + return new Promise(resolver => + chrome.identity.removeCachedAuthToken({ token }, () => resolver())); +} + +export function getLoggedIn() { + if (loggedIn === null) + { + return _getAuthToken(false) + .then(() => loggedIn = true) + .catch(() => loggedIn = false) + .then(() => loggedIn); + } + else return Promise.resolve(loggedIn); +} + +export function getAuthToken(): Promise { + return getLoggedIn().then(b => { + if (b) return _getAuthToken(false); + else throw GApiError.notLoggedIn; + }); +} + +export function login() { + return getLoggedIn().then(b => { + if (!b) return _getAuthToken(true).then(() => loggedIn = true); + else throw GApiError.notLoggedOut; + }); +} + +export async function logout() { + let token = await getAuthToken(); + let response = await fetch( + `https://accounts.google.com/o/oauth2/revoke?${to_params({ token })}`, { method: 'GET' }); + //if (response.status === 200) + await _removeCachedAuthToken(token); + //else throw GApiError.otherError; + loggedIn = false; +} + +export async function getCalendars(token: string) { + let response = await fetch( + `${gapiBase}/users/me/calendarList?${to_params({access_token: token})}`, { method: 'GET' }); + return (await response.json()).items; +} + +export async function getColors(token: string) { + let response = await fetch( + `${gapiBase}/colors?${to_params({access_token: token})}`, { method: 'GET' }); + return response.json(); +} + +async function getEvent(calId: string, eventId: string, token: string) { + let response = await fetch( + `${gapiBase}/calendars/${calId}/events/${eventId}?${to_params({access_token: token})}`, + { method: 'GET' }); + return response.json(); +} + +function getEvents(calId: string, token: string, + syncToken=null as string, + timeMin=null as string, + timeMax=null as string, + resultsPerRequest=100 as number): + Promise<{ results: any[], nextSyncToken: string }> { + let results = [] as any[]; + const singleFetch = async (pageToken: string, syncToken: string): + Promise<{nextSyncToken: string, results: any[]}> => { + let response = await fetch(`${gapiBase}/calendars/${calId}/events?${to_params({ + access_token: token, + pageToken, + syncToken, + timeMin, + timeMax, + maxResults: resultsPerRequest + })}`, { method: 'GET' }); + if (response.status === 200) + { + let data = await response.json(); + results.push(...data.items); + if (data.nextPageToken) { + return singleFetch(data.nextPageToken, ''); + } else { + return ({ + nextSyncToken: data.nextSyncToken, + results + }); + } + } + else if (response.status === 410) + throw GApiError.invalidSyncToken; + else throw GApiError.otherError; + }; + + return singleFetch('', syncToken); +} + +type GCalendarOptions = { + maxCachedItems: number, + nDaysPerSlot: number, + largeQuery: number +}; + +type GCalendarEvent = { + start: Date, + end: Date, + id: string +}; + +type GCalendarSlot = { [id: string]: GCalendarEvent }; + +export class GCalendar { + calId: string; + name: string; + syncToken: string; + cache: LRU; + eventMeta: { [id: string]: { keys: Set, summary: string } }; + options: GCalendarOptions; + divider: number; + + constructor(calId: string, name: string, + options={maxCachedItems: 100, nDaysPerSlot: 10, largeQuery: 10}) { + this.calId = calId; + this.name = name; + this.syncToken = ''; + this.cache = new LRU({ + max: options.maxCachedItems, + dispose: (k, v) => this.onRemoveSlot(k, v) + }); + this.eventMeta = {}; + this.options = options; + this.divider = 8.64e7 * this.options.nDaysPerSlot; + } + + get token() { return getAuthToken(); } + + dateToCacheKey(date: Date) { + return Math.floor(date.getTime() / this.divider); + } + + dateRangeToCacheKeys(range: { start: Date, end: Date }) { + return { + start: this.dateToCacheKey(range.start), + end: this.dateToCacheKey(new Date(range.end.getTime() - 1)) + }; + } + + getSlot(k: number) { + if (!this.cache.has(k)) + { + let res = {}; + this.cache.set(k, res); + return res; + } + else return this.cache.get(k); + } + + onRemoveSlot(k: number, v: GCalendarSlot) { + for (let id in v) { + console.assert(this.eventMeta.hasOwnProperty(id)); + let keys = this.eventMeta[id].keys; + keys.delete(k); + if (keys.size === 0) + delete this.eventMeta[id]; + } + } + + slotStartDate(k: number) { return new Date(k * this.divider); } + slotEndDate(k: number) { return new Date((k + 1) * this.divider); } + + addEvent(e: {start: Date, end: Date, id: string, summary: string}, evict = false) { + //console.log('adding event', e); + if (this.eventMeta.hasOwnProperty(e.id)) + this.removeEvent(e); + let r = this.dateRangeToCacheKeys(e); + let ks = r.start; + let ke = r.end; + let t = this.cache.length; + let keys = new Set(); + for (let i = ks; i <= ke; i++) + { + keys.add(i); + if (!this.cache.has(i)) t++; + } + this.eventMeta[e.id] = { + keys, + summary: e.summary, + }; + if (!evict && t > this.options.maxCachedItems) return; + if (ks === ke) + this.getSlot(ks)[e.id] = { + start: e.start, + end: e.end, + id: e.id }; + else + { + this.getSlot(ks)[e.id] = { + start: e.start, + end: this.slotEndDate(ks), + id: e.id }; + this.getSlot(ke)[e.id] = { + start: this.slotStartDate(ke), + end: e.end, + id: e.id }; + for (let k = ks + 1; k < ke; k++) + this.getSlot(k)[e.id] = { + start: this.slotStartDate(k), + end: this.slotEndDate(k), + id: e.id}; + } + } + + removeEvent(e: {id: string}) { + let keys = this.eventMeta[e.id].keys; + keys.forEach(k => delete this.getSlot(k)[e.id]); + delete this.eventMeta[e.id]; + } + + getSlotEvents(k: number, r: {start: Date, end: Date}) { + let s = this.getSlot(k); + //console.log(s); + let results = []; + for (let id in s) { + if (!(s[id].start >= r.end || s[id].end <= r.start)) + { + results.push({ + id, + start: s[id].start < r.start ? r.start: s[id].start, + end: s[id].end > r.end ? r.end: s[id].end, + summary: this.eventMeta[id].summary + }); + } + } + return results; + } + + getCachedEvents(_r: {start: Date, end: Date}) { + let r = this.dateRangeToCacheKeys(_r); + let ks = r.start; + let ke = r.end; + let results = this.getSlotEvents(ks, _r); + for (let k = ks + 1; k < ke; k++) + { + let s = this.getSlot(k); + for (let id in s) + results.push({...s[id], summary: this.eventMeta[id].summary}); + } + if (ke > ks) + results.push(...this.getSlotEvents(ke, _r)); + return results; + } + + async sync() { + try { + let token = await this.token; + let r = await getEvents(this.calId, token, this.syncToken); + let results = await Promise.all( + r.results.map(e => e.start ? Promise.resolve(e) : getEvent(this.calId, e.id, token))); + results.forEach(e => { + e.start = new Date(e.start.dateTime); + e.end = new Date(e.end.dateTime); + if (e.status === 'confirmed') + this.addEvent(e); + else if (e.status === 'cancelled') + this.removeEvent(e); + }); + this.syncToken = r.nextSyncToken; + } catch(err) { + if (err === GApiError.invalidSyncToken) { + this.syncToken = ''; + this.sync(); + } else throw err; + } + } + + async getEvents(start: Date, end: Date) { + let r = this.dateRangeToCacheKeys({ start, end }); + let query = { + start: null as number, + end: null as number + }; + for (let k = r.start; k <= r.end; k++) + if (!this.cache.has(k)) + { + if (query.start === null) + query.start = k; + query.end = k; + } + //console.log(`start: ${start} end: ${end}`); + if (query.start !== null) + { + console.assert(query.start <= query.end); + if (query.end - query.start + 1 > this.options.largeQuery) { + console.log(`encounter large query, use direct fetch`); + let token = await this.token; + let r = await getEvents(this.calId, token, null, + start.toISOString(), end.toISOString()); + return r.results.map(e => { + console.assert(e.start); + e.start = new Date(e.start.dateTime); + e.end = new Date(e.end.dateTime); + return e; + }).filter(e => !(e.start >= end || e.end <= start)).map(e => ({ + id: e.id, + start: e.start < start ? start: e.start, + end: e.end > end ? end: e.end, + summary: e.summary, + })); + } + + console.log(`fetching short event list`); + let token = await this.token; + let r = await getEvents(this.calId, token, null, + this.slotStartDate(query.start).toISOString(), + this.slotEndDate(query.end).toISOString()); + r.results.forEach(e => { + if (e.status === 'confirmed') + { + console.assert(e.start); + e.start = new Date(e.start.dateTime); + e.end = new Date(e.end.dateTime); + this.addEvent(e, true); + } + }); + if (this.syncToken === '') + this.syncToken = r.nextSyncToken; + await this.sync(); + return this.getCachedEvents({ start, end }); + } + else + { + console.log(`cache hit`); + await this.sync(); + return this.getCachedEvents({ start, end }); + } + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index adcb634..0000000 --- a/src/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Dashboard from './Dashboard'; -import * as serviceWorker from './serviceWorker'; - -ReactDOM.render(, 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/index.tsx b/src/index.tsx new file mode 100644 index 0000000..f5c836b --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import Dashboard from './Dashboard'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/src/msg.js b/src/msg.js deleted file mode 100644 index 2e72ea7..0000000 --- a/src/msg.js +++ /dev/null @@ -1,97 +0,0 @@ -/* global chrome */ -const _updatePatterns = "updatePatterns"; -const _getPatterns = "getPatterns"; -const _updateCalendars = "updateCalendars"; -const _getCalendars = "getCalendars"; -const _getCalEvents = "getCalEvents"; -const _updateConfig = "updateConfig"; -const _getConfig = "getConfig"; -const _getGraphData = "getGraphData"; - -export const msgType = Object.freeze({ - updatePatterns: Symbol(_updatePatterns), - getPatterns: Symbol(_getPatterns), - updateCalendars: Symbol(_updateCalendars), - getCalendars: Symbol(_getCalendars), - getCalEvents: Symbol(_getCalEvents), - updateConfig: Symbol(_updateConfig), - getConfig: Symbol(_getConfig), - getGraphData: Symbol(_getGraphData), -}); - -function stringifyMsgType(mt) { - switch (mt) { - case msgType.updatePatterns: return _updatePatterns; - case msgType.getPatterns: return _getPatterns; - case msgType.updateCalendars: return _updateCalendars; - case msgType.getCalendars: return _getCalendars; - case msgType.getCalEvents: return _getCalEvents; - case msgType.updateConfig: return _updateConfig; - case msgType.getConfig: return _getConfig; - case msgType.getGraphData: return _getGraphData; - default: console.error("unreachable"); - } -} - -function parseMsgType(s) { - switch(s) { - case _updatePatterns: return msgType.updatePatterns; - case _getPatterns: return msgType.getPatterns; - case _updateCalendars: return msgType.updateCalendars; - case _getCalendars: return msgType.getCalendars; - case _getCalEvents: return msgType.getCalEvents; - case _updateConfig: return msgType.updateConfig; - case _getConfig: return msgType.getConfig; - case _getGraphData: return msgType.getGraphData; - default: console.error("unreachable"); - } -} - -export class Msg { - constructor(id, type, data) { - this.id = id; - this.type = type; - this.data = data; - } - genResp(data) { return new Msg(this.id, this.type, data); } - deflate() { - return { - id: this.id, - type: stringifyMsgType(this.type), - data: this.data - } - } - static inflate = obj => new Msg(obj.id, parseMsgType(obj.type), obj.data); -} - -export class MsgClient { - constructor(channelName) { - let port = chrome.runtime.connect({name: channelName}); - const getCallBack = rcb => this.requestCallback; - port.onMessage.addListener(function(msg) { - console.log(msg); - let rcb = getCallBack(msg.type); - let cb = rcb.inFlight[msg.id]; - console.assert(cb !== undefined); - rcb.ids.push(msg.id); - cb(msg); - }); - this.port = port; - this.requestCallback = {inFlight: {}, ids: [], maxId: 0}; - } - - sendMsg = ({ type, data }) => { - let rcb = this.requestCallback; - let cb; - let pm = new Promise(resolve => { cb = resolve; }); - let id; - if (rcb.ids.length > 0) { - id = rcb.ids.pop(); - } else { - id = rcb.maxId++; - } - rcb.inFlight[id] = cb; - this.port.postMessage((new Msg(id, type, data)).deflate()); - return pm; - } -} diff --git a/src/msg.ts b/src/msg.ts new file mode 100644 index 0000000..12eb2bc --- /dev/null +++ b/src/msg.ts @@ -0,0 +1,87 @@ +/* global chrome */ + +export enum MsgType { + updatePatterns = "updatePatterns", + getPatterns = "getPatterns", + updateCalendars = "updateCalendars", + getCalendars = "getCalendars", + getCalEvents = "getCalEvents", + updateConfig = "updateConfig", + getConfig = "getConfig", + getGraphData = "getGraphData" +} + +function stringifyMsgType(mt: MsgType): string { return MsgType[mt]; } + +function parseMsgType(s: string): MsgType { + switch (s) { + case "updatePatterns": return MsgType.updatePatterns; + case "getPatterns": return MsgType.getPatterns; + case "updateCalendars" : return MsgType.updateCalendars; + case "getCalendars": return MsgType.getCalendars; + case "updateConfig": return MsgType.updateConfig; + case "getConfig": return MsgType.getConfig; + case "getGraphData": return MsgType.getGraphData; + default: console.error("unreachable"); + } +} + +export class Msg { + id: number; + mt: MsgType; + data: T; + constructor(id: number, mt: MsgType, data: T) { + this.id = id; + this.mt = mt; + this.data = data; + } + genResp(data: T) { return new Msg(this.id, this.mt, data); } + deflate() { + return { + id: this.id, + mt: stringifyMsgType(this.mt), + data: this.data + } + } + static inflate = (obj: {id: number, mt: MsgType, data: T}) => ( + new Msg(obj.id, parseMsgType(obj.mt), obj.data) + ); +} + +export class MsgClient { + requestCallback: { + ids: number[], + inFlight: {[id: number]: (msg: Msg) => any; }, + maxId: number + }; + port: chrome.runtime.Port; + + constructor(channelName: string) { + let port = chrome.runtime.connect({name: channelName}); + const rcb = this.requestCallback; + port.onMessage.addListener(function(msg) { + console.log(msg); + let cb = rcb.inFlight[msg.id]; + console.assert(cb !== undefined); + rcb.ids.push(msg.id); + cb(msg); + }); + this.port = port; + this.requestCallback = {inFlight: {}, ids: [], maxId: 0}; + } + + sendMsg({ mt, data }: { mt: MsgType, data: any }) { + const rcb = this.requestCallback; + let cb; + let pm = new Promise(resolve => { cb = resolve; }); + let id; + if (rcb.ids.length > 0) { + id = rcb.ids.pop(); + } else { + id = rcb.maxId++; + } + rcb.inFlight[id] = cb; + this.port.postMessage((new Msg(id, mt, data)).deflate()); + return pm; + } +} diff --git a/src/pattern.js b/src/pattern.js deleted file mode 100644 index 858f2a3..0000000 --- a/src/pattern.js +++ /dev/null @@ -1,48 +0,0 @@ -export class Pattern { - constructor(id, isRegex, value, label) { - this.id = id; - this.isRegex = isRegex; - this.value = value; - this.label = label; - } - - get regex() { return new RegExp(this.isRegex ? this.value : `^${this.value}$`); } - get isEmpty() { return this.label === null; } - deflate() { - return { - id: this.id, - isRegex: this.isRegex, - value: this.value, - label: this.label - }; - } - static emptyPattern = () => new Pattern(0, true, '', null); - static anyPattern = () => new Pattern('any', true, '.*', 'Any'); - static inflate = obj => new Pattern(obj.id, obj.isRegex, obj.value, obj.label); -} - -export class PatternEntry { - constructor(name, idx, calPattern, eventPattern, color) { - this.name = name; - this.idx = idx; - this.cal = calPattern; - this.event = eventPattern; - this.color = color; - } - - deflate() { - return { - name: this.name, - idx: this.idx, - cal: this.cal.deflate(), - event: this.event.deflate(), - color: this.color - }; - } - - static defaultPatternEntry = (idx) => new PatternEntry('', idx, Pattern.emptyPattern(), Pattern.anyPattern(), {background: null}); - static inflate = obj => new PatternEntry( - obj.name, obj.idx, - Pattern.inflate(obj.cal), Pattern.inflate(obj.event), - obj.color); -} diff --git a/src/pattern.ts b/src/pattern.ts new file mode 100644 index 0000000..cae35a9 --- /dev/null +++ b/src/pattern.ts @@ -0,0 +1,84 @@ +interface PatternFlat { + id: number | string; + isRegex: boolean; + value: string; + label: string; +} + +export class Pattern { + id: number | string; + isRegex: boolean; + value: string; + label: string; + + constructor(id: number | string, isRegex: boolean, value: string, label: string) { + this.id = id; + this.isRegex = isRegex; + this.value = value; + this.label = label; + } + + get regex() { return new RegExp(this.isRegex ? this.value : `^${this.value}$`); } + get isEmpty() { return this.label === null; } + deflate() { + return { + id: this.id, + isRegex: this.isRegex, + value: this.value, + label: this.label + }; + } + static emptyPattern = () => new Pattern(0, true, '', null); + static anyPattern = () => new Pattern('any', true, '.*', 'Any'); + static inflate = (obj: PatternFlat) => new Pattern(obj.id, obj.isRegex, obj.value, obj.label); +} + +interface PatternEntryColor { + background: string +} + +interface PatternEntryFlat { + name: string; + idx: number; + cal: PatternFlat; + event: PatternFlat; + color: PatternEntryColor; +} + +export class PatternEntry { + name: string; + idx: number; + cal: Pattern; + event: Pattern; + color: PatternEntryColor; + + constructor(name: string, idx: number, + calPattern: Pattern, eventPattern: Pattern, + color: PatternEntryColor) { + this.name = name; + this.idx = idx; + this.cal = calPattern; + this.event = eventPattern; + this.color = color; + } + + deflate() { + return { + name: this.name, + idx: this.idx, + cal: this.cal.deflate(), + event: this.event.deflate(), + color: this.color + }; + } + + static defaultPatternEntry = (idx: number) => ( + new PatternEntry('', idx, + Pattern.emptyPattern(), + Pattern.anyPattern(), {background: null})); + + static inflate = (obj: PatternEntryFlat) => new PatternEntry( + obj.name, obj.idx, + Pattern.inflate(obj.cal), Pattern.inflate(obj.event), + obj.color); +} diff --git a/src/popup.js b/src/popup.js deleted file mode 100644 index c93ce91..0000000 --- a/src/popup.js +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import * as serviceWorker from './serviceWorker'; -import { withStyles, MuiThemeProvider } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import IconButton from '@material-ui/core/IconButton'; -import RefreshIcon from '@material-ui/icons/Refresh'; -import Logo from './Logo'; -import Typography from '@material-ui/core/Typography'; -import { theme } from './theme'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import { PatternEntry } from './pattern'; -import { Duration } from './duration'; -import { msgType, MsgClient } from './msg'; -import { StyledPatternPieChart } from './Chart'; -import Divider from '@material-ui/core/Divider'; -import moment from 'moment'; - -function openOptions() { - chrome.tabs.create({ url: "index.html" }); -} - -const styles = theme => ({ - content: { - padding: theme.spacing.unit * 1, - overflow: 'auto', - }, - buttons: { - width: 400, - height: 48, - lineHeight: '48px' - }, - buttonSpacer: { - marginBottom: theme.spacing.unit * 2, - }, -}); - -class Popup extends React.Component { - state = { - patternGraphData: [], - loading: false, - }; - constructor(props) { - super(props); - this.msgClient = new MsgClient('main'); - this.loading = true; - this.loadGraphData(false).then(() => this.setState({ loading: false })); - } - - loadGraphData(sync) { - return this.msgClient.sendMsg({ - type: msgType.getGraphData, - data: { sync } - }).then(msg => { - this.setState({ patternGraphData: msg.data.map(d => ({ - name: d.name, - data: d.data, - start: new Date(d.start), - end: new Date(d.end) - }))}); - }); - } - - render() { - let { classes } = this.props; - let data = this.state.patternGraphData; - return ( - - -
-
- - - ( - new Promise(resolver => ( - this.setState({ loading: true }, resolver))) - .then(() => this.loadGraphData(true)) - .then(() => this.setState({ loading: false })) - )}> - -
-
- { - data.map((d, idx) => ( -
- - {d.name} - - - {`${moment(d.start).format('ddd, MMM Do, YYYY')} - - ${moment(d.end).format('ddd, MMM Do, YYYY')}`} - - {(d.data.some(dd => dd.value > 1e-3) && - ) || - - No data available - } - {idx + 1 < data.length && } -
- )) - } -
-
- ); - } -} - -const StyledPopup = withStyles(styles)(Popup); - -ReactDOM.render(, 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/popup.tsx b/src/popup.tsx new file mode 100644 index 0000000..5474476 --- /dev/null +++ b/src/popup.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { withStyles, MuiThemeProvider } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import Logo from './Logo'; +import Typography from '@material-ui/core/Typography'; +import { theme } from './theme'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import { PatternEntry } from './pattern'; +import { Duration } from './duration'; +import { MsgType, MsgClient } from './msg'; +import { StyledPatternPieChart } from './Chart'; +import Divider from '@material-ui/core/Divider'; +import moment from 'moment'; + +function openOptions() { + chrome.tabs.create({ url: "index.html" }); +} + +const styles = theme => ({ + content: { + padding: theme.spacing.unit * 1, + overflow: 'auto', + }, + buttons: { + width: 400, + height: 48, + lineHeight: '48px' + }, + buttonSpacer: { + marginBottom: theme.spacing.unit * 2, + }, +}); + +class Popup extends React.Component { + state = { + patternGraphData: [], + loading: false, + }; + constructor(props) { + super(props); + this.msgClient = new MsgClient('main'); + this.loading = true; + this.loadGraphData(false).then(() => this.setState({ loading: false })); + } + + loadGraphData(sync) { + return this.msgClient.sendMsg({ + type: MsgType.getGraphData, + data: { sync } + }).then(msg => { + this.setState({ patternGraphData: msg.data.map(d => ({ + name: d.name, + data: d.data, + start: new Date(d.start), + end: new Date(d.end) + }))}); + }); + } + + render() { + let { classes } = this.props; + let data = this.state.patternGraphData; + return ( + + +
+
+ + + ( + new Promise(resolver => ( + this.setState({ loading: true }, resolver))) + .then(() => this.loadGraphData(true)) + .then(() => this.setState({ loading: false })) + )}> + +
+
+ { + data.map((d, idx) => ( +
+ + {d.name} + + + {`${moment(d.start).format('ddd, MMM Do, YYYY')} - + ${moment(d.end).format('ddd, MMM Do, YYYY')}`} + + {(d.data.some(dd => dd.value > 1e-3) && + ) || + + No data available + } + {idx + 1 < data.length && } +
+ )) + } +
+
+ ); + } +} + +const StyledPopup = withStyles(styles)(Popup); + +ReactDOM.render(, document.getElementById('root')); diff --git a/src/serviceWorker.js b/src/serviceWorker.js deleted file mode 100644 index 2283ff9..0000000 --- a/src/serviceWorker.js +++ /dev/null @@ -1,135 +0,0 @@ -// 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(); - }); - } -} diff --git a/src/theme.js b/src/theme.js deleted file mode 100644 index 0269fd3..0000000 --- a/src/theme.js +++ /dev/null @@ -1,18 +0,0 @@ -import { createMuiTheme } from '@material-ui/core/styles'; -import orange from '@material-ui/core/colors/orange'; -import deepOrange from '@material-ui/core/colors/deepOrange'; - -export const defaultChartColor = deepOrange[300]; -export const theme = createMuiTheme({ - palette: { - primary: { - light: orange[300], - main: orange[500], - dark: orange[700], - contrastText: "#fff" - } - }, - typography: { - useNextVariants: true, - } -}); diff --git a/src/theme.tsx b/src/theme.tsx new file mode 100644 index 0000000..0269fd3 --- /dev/null +++ b/src/theme.tsx @@ -0,0 +1,18 @@ +import { createMuiTheme } from '@material-ui/core/styles'; +import orange from '@material-ui/core/colors/orange'; +import deepOrange from '@material-ui/core/colors/deepOrange'; + +export const defaultChartColor = deepOrange[300]; +export const theme = createMuiTheme({ + palette: { + primary: { + light: orange[300], + main: orange[500], + dark: orange[700], + contrastText: "#fff" + } + }, + typography: { + useNextVariants: true, + } +}); -- cgit v1.2.3-70-g09d2