snapshot of https://github.com/traggo/server.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
build / build (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-16 05:24:45 +05:00
commit cda7c1a474
272 changed files with 31973 additions and 0 deletions

2
ui/.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
build
__generated__

11
ui/.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"printWidth": 130,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": false,
"jsxBracketSameLine": true,
"arrowParens": "always"
}

82
ui/package.json Normal file
View File

@@ -0,0 +1,82 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"homepage": ".",
"dependencies": {
"@apollo/react-hooks": "^3.1.3",
"@date-io/moment": "^1.1.0",
"@fullcalendar/core": "^4.3.1",
"@fullcalendar/daygrid": "^4.3.0",
"@fullcalendar/interaction": "^4.3.0",
"@fullcalendar/moment": "^4.3.0",
"@fullcalendar/react": "^4.3.0",
"@fullcalendar/timegrid": "^4.3.0",
"@material-ui/core": "^4.6.1",
"@material-ui/icons": "^4.5.1",
"@material-ui/pickers": "^3.2.8",
"@rooks/use-interval": "^1.2.0",
"@rooks/use-timeout": "^3.0.1",
"apollo-boost": "^0.4.4",
"color-hash": "^1.0.3",
"downshift": "^3.2.10",
"get-best-contrast-color": "^0.2.2",
"graphql": "^14.5.8",
"lodash.clonedeep": "^4.5.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"notistack": "^0.6.1",
"pretty-ms": "^5.0.0",
"react": "^16.12.0",
"react-apollo": "^3.1.3",
"react-color": "3.0.0-beta.3",
"react-dom": "^16.12.0",
"react-grid-layout": "^0.16.6",
"react-infinite": "^0.13.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"recharts": "^1.8.5",
"typeface-roboto": "^0.0.54",
"typescript": "^3.7.2"
},
"devDependencies": {
"@types/graphql": "^14.0.7",
"@types/jest": "^24.0.15",
"@types/lodash.clonedeep": "^4.5.6",
"@types/moment-timezone": "^0.5.12",
"@types/node": "11.11.3",
"@types/pretty-ms": "^4.0.0",
"@types/react": "16.8.8",
"@types/react-color": "^2.17.0",
"@types/react-dom": "^16.9.4",
"@types/react-grid-layout": "^0.16.7",
"@types/react-infinite": "^0.0.34",
"@types/react-router": "^5.0.1",
"@types/react-router-dom": "^5.1.2",
"@types/recharts": "^1.8.3",
"apollo": "^2.21.0",
"prettier": "^1.19.1",
"react-scripts": "^3.2.0",
"tslint": "^5.20.1",
"tslint-sonarts": "^1.9.0"
},
"scripts": {
"lint": "tslint --fix --project .",
"lint:check": "tslint --project .",
"generate": "apollo client:codegen --target typescript --localSchemaFile ../schema.graphql --excludes **/*local.ts --globalTypesFile src/gql/__generated__/globalTypes.ts",
"format": "prettier \"**/*.{html,ts,tsx,css}\" --write",
"format:check": "prettier \"**/*.{html,ts,tsx,css}\" --list-different",
"start": "react-scripts start",
"build": "react-scripts build",
"postbuild": "rimraf build/**/*.map",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"proxy": "http://localhost:3030"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
ui/public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
ui/public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

21
ui/public/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/favicon-192x192.png" />
<link rel="icon" type="image/png" sizes="256x256" href="%PUBLIC_URL%/favicon-256x256.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<title>Traggo</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

29
ui/public/manifest.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "Traggo",
"short_name": "Traggo",
"description": "Tag-based time tracking",
"theme_color": "#9bd7fe",
"background_color": "#3f51b5",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
}, {
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
}, {
"src": "/favicon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}, {
"src": "/favicon-256x256.png",
"sizes": "256x256",
"type": "image/png"
}
]
}

54
ui/serve.go Normal file
View File

@@ -0,0 +1,54 @@
package ui
import (
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
)
//go:embed build
var uiDir embed.FS
var buildDir, _ = fs.Sub(uiDir, "build")
// Register registers the ui on the root path.
func Register(r *mux.Router) {
r.Handle("/", serveFile("index.html", "text/html"))
r.Handle("/index.html", serveFile("index.html", "text/html"))
r.Handle("/manifest.json", serveFile("manifest.json", "application/json"))
r.Handle("/service-worker.js", serveFile("service-worker.js", "text/javascript"))
r.Handle("/asset-manifest.json", serveFile("asset-manifest.json", "application/json"))
fileServer := http.FileServer(http.FS(buildDir))
r.Handle("/static/{type}/{resource}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
fileServer.ServeHTTP(w, r)
}))
r.Handle("/favicon.ico", serveFile("favicon.ico", "image/x-icon"))
for _, size := range []string{"16x16", "32x32", "192x192", "256x256"} {
fileName := fmt.Sprintf("favicon-%s.png", size)
r.Handle("/"+fileName, serveFile(fileName, "image/png"))
}
}
func serveFile(name, contentType string) http.HandlerFunc {
file, err := buildDir.Open(name)
if err != nil {
log.Panic().Err(err).Msgf("could not find %s", file)
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
log.Panic().Err(err).Msgf("could not read %s", file)
}
return func(writer http.ResponseWriter, reg *http.Request) {
writer.Header().Set("Content-Type", contentType)
_, _ = writer.Write(content)
}
}

31
ui/src/Root.tsx Normal file
View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import './global.css';
import 'react-resizable/css/styles.css';
import 'react-grid-layout/css/styles.css';
import 'typeface-roboto';
import {ThemeProvider} from './provider/ThemeProvider';
import {ApolloProvider} from './provider/ApolloProvider';
import {SnackbarProvider} from './provider/SnackbarProvider';
import {MuiPickersUtilsProvider} from '@material-ui/pickers';
import MomentUtils from '@date-io/moment';
import {Router} from './Router';
import {HashRouter} from 'react-router-dom';
import {BootUserSettings} from './provider/UserSettingsProvider';
export const Root = () => {
return (
<ApolloProvider>
<BootUserSettings>
<ThemeProvider>
<MuiPickersUtilsProvider utils={MomentUtils}>
<SnackbarProvider>
<HashRouter>
<Router />
</HashRouter>
</SnackbarProvider>
</MuiPickersUtilsProvider>
</ThemeProvider>
</BootUserSettings>
</ApolloProvider>
);
};

81
ui/src/Router.tsx Normal file
View File

@@ -0,0 +1,81 @@
import * as React from 'react';
import {useQuery} from '@apollo/react-hooks';
import {CurrentUser} from './gql/__generated__/CurrentUser';
import * as gqlUser from './gql/user';
import {CenteredSpinner} from './common/CenteredSpinner';
import {LoginPage} from './login/LoginPage';
import {Typography} from '@material-ui/core';
import Button from '@material-ui/core/Button';
import {ApolloError} from 'apollo-boost';
import Grid from '@material-ui/core/Grid';
import {DefaultPaper} from './common/DefaultPaper';
import {Page} from './common/Page';
import {Redirect, Route, Switch} from 'react-router';
import {DailyPage} from './timespan/DailyPage';
import {DevicesPage} from './devices/DevicesPage';
import {UsersPage} from './user/UsersPage';
import {DashboardPage} from './dashboard/DashboardPage';
import {DashboardsPage} from './dashboard/DashboardsPage';
import {TagPage} from './tag/TagPage';
import {SettingsPage} from './setting/SettingsPage';
import {CalendarPage} from './timespan/calendar/CalendarPage';
export const Router = () => {
const {loading, error, data, refetch} = useQuery<CurrentUser>(gqlUser.CurrentUser);
if (loading) {
return <CenteredSpinner />;
}
if (error) {
return <Error refetch={refetch} error={error} />;
}
const loggedIn = data && data.user;
const admin = data && data.user && data.user.admin;
return (
<Switch>
<Route path="/user/login">{loggedIn ? <Redirect to="/" /> : <LoginPage />}</Route>
{loggedIn ? null : <Redirect to="/user/login" />}
<Page>
<Route path="/dashboards" component={DashboardsPage} />
<Route path="/dashboard/:id" component={DashboardPage} />
<Route exact path="/timesheet/list" component={DailyPage} />
<Route exact path="/timesheet/calendar" component={CalendarPage} />
<Route exact path="/user/settings" component={SettingsPage} />
<Route exact path="/user/devices" component={DevicesPage} />
<Route exact path="/user/tags" component={TagPage} />
{admin ? <Route exact path="/admin/users" component={UsersPage} /> : null}
<Route exact path="/" render={() => <Redirect to="/timesheet/list" />} />
</Page>
</Switch>
);
};
const Error: React.FC<{error: ApolloError; refetch: () => void}> = ({error, refetch}) => {
return (
<Grid container direction="row" alignItems="center" justify="center" style={{height: '100%'}}>
<Grid item>
<DefaultPaper>
<Typography variant="h3" component="h1" gutterBottom={true}>
Error
</Typography>
<Typography component="p">
{error.networkError && error.networkError.name + ': ' + error.networkError.message}
{error.graphQLErrors.map((gqlError) => gqlError.message).join(', ')}
</Typography>
<Button style={{marginTop: 10}} size="large" variant="outlined" onClick={() => refetch()}>
Retry
</Button>
</DefaultPaper>
</Grid>
</Grid>
);
};
export const TODO = () => {
return (
<Typography align="center" variant={'h2'}>
TODO
</Typography>
);
};

5
ui/src/common/Center.tsx Normal file
View File

@@ -0,0 +1,5 @@
import * as React from 'react';
export const Center: React.FC = ({children}) => {
return <div style={{display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%'}}>{children}</div>;
};

View File

@@ -0,0 +1,11 @@
import CircularProgress from '@material-ui/core/CircularProgress';
import Grid from '@material-ui/core/Grid';
import * as React from 'react';
export const CenteredSpinner = () => (
<Grid container={true} direction="row" alignItems="center" justify="center" style={{height: '95%'}}>
<Grid item>
<CircularProgress size={100} />
</Grid>
</Grid>
);

View File

@@ -0,0 +1,36 @@
import Button from '@material-ui/core/Button';
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 React from 'react';
interface Props {
title: string;
fClose: () => void;
fOnSubmit: () => void;
}
export const ConfirmDialog: React.FC<Props> = ({children, title, fClose, fOnSubmit}) => {
const submitAndClose = () => {
fOnSubmit();
fClose();
};
return (
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" className="confirm-dialog">
<DialogTitle id="form-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText>{children}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={fClose} className="cancel">
No
</Button>
<Button onClick={submitAndClose} autoFocus color="primary" variant="contained" className="confirm">
Yes
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import {KeyboardDateTimePicker} from '@material-ui/pickers';
import * as moment from 'moment';
import {uglyConvertToLocalTime} from '../timespan/timeutils';
import {useSettings} from '../gql/settings';
import {DateTimeInputStyle} from '../gql/__generated__/globalTypes';
interface DateTimeSelectorProps {
selectedDate: moment.Moment;
onSelectDate: (date: moment.Moment) => void;
showDate: boolean;
label: string;
popoverOpen?: (open: boolean) => void;
}
export const DateTimeSelector: React.FC<DateTimeSelectorProps> = React.memo(
({selectedDate, onSelectDate, showDate, label, popoverOpen = () => {}}) => {
const {done, dateTimeInputStyle} = useSettings();
if (!done) {
return <span>...</span>;
}
if (dateTimeInputStyle === DateTimeInputStyle.Native) {
return (
<input
type="datetime-local"
value={selectedDate.format(selectedDate.format('YYYY-MM-DDTHH:mm'))}
onChange={(e) => {
onSelectDate(moment.default(e.target.value));
}}
/>
);
}
const [open, setOpen] = React.useState(false);
const localeData = moment.localeData();
const time = localeData.longDateFormat('LT').replace('A', 'a');
const ampm = time.indexOf('a') !== -1;
const format = showDate ? localeData.longDateFormat('L') + ' ' + time : time;
return (
<KeyboardDateTimePicker
className="time-picker"
variant="inline"
InputProps={{disableUnderline: true}}
title={selectedDate.format()}
style={{width: (showDate ? 185 : 105) + (ampm ? 20 : 0)}}
PopoverProps={{
onEntered: () => {
popoverOpen(true);
setOpen(true);
},
onExited: () => {
popoverOpen(false);
setOpen(false);
},
}}
margin="none"
value={uglyConvertToLocalTime(selectedDate).format()}
onChange={(date: moment.Moment) => {
if (!date || !date.isValid()) {
return;
}
if (!showDate && !open) {
date = date.set({
date: selectedDate.date(),
month: selectedDate.month(),
year: selectedDate.year(),
});
}
if (uglyConvertToLocalTime(selectedDate).isSame(date)) {
return;
}
onSelectDate(date);
}}
ampm={ampm}
format={format}
label={label}
openTo={showDate ? 'date' : 'hours'}
/>
);
}
);

View File

@@ -0,0 +1,22 @@
import Paper from '@material-ui/core/Paper';
import {makeStyles} from '@material-ui/core/styles';
import * as React from 'react';
const useStyles = makeStyles((theme) => ({
root: {
...theme.mixins.gutters(),
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(3),
textAlign: 'center',
maxWidth: 400,
borderTop: `5px solid ${theme.palette.primary.main}`,
},
}));
export const DefaultPaper: React.FC = ({children}) => {
const classes = useStyles();
return (
<Paper elevation={10} square={true} className={classes.root}>
{children}
</Paper>
);
};

31
ui/src/common/Fade.tsx Normal file
View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import {CSSTransition} from 'react-transition-group';
const duration = 100;
const defaultStyle = {
transition: `all ${duration}ms ease-in-out`,
opacity: 0,
};
export const Fade: React.FC<{fullyVisible: boolean; opacity?: number}> = ({fullyVisible, children, opacity = 0.4}) => {
const transitionStyles: Record<string, React.CSSProperties> = {
entering: {opacity: 1},
entered: {opacity: 1},
exiting: {opacity},
exited: {opacity},
};
return (
<CSSTransition in={fullyVisible} timeout={duration}>
{(state) => (
<div
style={{
...defaultStyle,
...transitionStyles[state],
}}>
{children}
</div>
)}
</CSSTransition>
);
};

308
ui/src/common/Page.tsx Normal file
View File

@@ -0,0 +1,308 @@
import * as React from 'react';
import AppBar from '@material-ui/core/AppBar';
import CssBaseline from '@material-ui/core/CssBaseline';
import Divider from '@material-ui/core/Divider';
import Drawer from '@material-ui/core/Drawer';
import Hidden from '@material-ui/core/Hidden';
import IconButton from '@material-ui/core/IconButton';
import UsersIcon from '@material-ui/icons/SupervisorAccount';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import DashboardIcon from '@material-ui/icons/Dashboard';
import MenuIcon from '@material-ui/icons/Menu';
import SettingsIcon from '@material-ui/icons/Settings';
import DevicesIcon from '@material-ui/icons/DevicesOther';
import LabelIcon from '@material-ui/icons/Label';
import DashboardManageIcon from '@material-ui/icons/ListAlt';
import TimeLineIcon from '@material-ui/icons/Timeline';
import CalendarIcon from '@material-ui/icons/CalendarToday';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import {ListSubheader, Menu} from '@material-ui/core';
import HrefLink from '@material-ui/core/Link';
import * as gqlUser from '../gql/user';
import * as gqlVersion from '../gql/version';
import Button from '@material-ui/core/Button';
import AccountCircle from '@material-ui/icons/AccountCircle';
import {Link} from 'react-router-dom';
import MenuItem from '@material-ui/core/MenuItem';
import {useMutation, useQuery} from '@apollo/react-hooks';
import {Logout} from '../gql/__generated__/Logout';
import {Version} from '../gql/__generated__/Version';
import {CurrentUser} from '../gql/__generated__/CurrentUser';
import * as gqlDashboard from '../gql/dashboard';
import {Dashboards} from '../gql/__generated__/Dashboards';
import makeStyles from '@material-ui/core/styles/makeStyles';
import {Route, RouteChildrenProps, Switch} from 'react-router';
const drawerWidth = 240;
const useStyles = makeStyles((theme) => ({
root: {
display: 'flex',
height: '100%',
},
drawer: {
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0,
},
},
appBar: {
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`,
},
},
menuButton: {
marginRight: 20,
[theme.breakpoints.up('md')]: {
display: 'none',
},
},
toolbar: {
...theme.mixins.toolbar,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
},
drawerPaper: {
width: drawerWidth,
},
content: {
flexGrow: 1,
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
},
grow: {
flexGrow: 1,
},
sectionDesktop: {
display: 'none',
[theme.breakpoints.up('xs')]: {
display: 'flex',
},
},
sectionMobile: {
display: 'flex',
[theme.breakpoints.up('xs')]: {
display: 'none',
},
},
}));
// tslint:disable-next-line:no-any
const routerLink = (to: string): any => {
return React.forwardRef<HTMLAnchorElement>((props, ref) => <Link innerRef={ref} to={to} {...props} />);
};
export const Page: React.FC = ({children}) => {
const classes = useStyles();
const {data} = useQuery<CurrentUser>(gqlUser.CurrentUser);
const [mobileOpen, setMobileOpen] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState<null | HTMLElement>(null);
const [logout] = useMutation<Logout>(gqlUser.Logout, {refetchQueries: [{query: gqlUser.CurrentUser}]});
const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery<Version>(
gqlVersion.Version
);
const dashboardsQuery = useQuery<Dashboards>(gqlDashboard.Dashboards);
const dashboards = (dashboardsQuery.data && dashboardsQuery.data.dashboards) || [];
const username = (data && data.user && data.user.name) || 'unknown';
const admin = data && data.user && data.user.admin;
const drawer = (
<div>
<div className={classes.toolbar}>
<HrefLink href="https://github.com/traggo" underline="none" target="_blank">
<Typography variant="h5" align="center" color="textPrimary">
traggo
</Typography>
</HrefLink>
<HrefLink href="https://github.com/traggo/server/releases" underline="none" target="_blank">
<Typography variant="subtitle2" align="center" color="textPrimary">
{version.name}@{version.commit.slice(0, 8)}
</Typography>
</HrefLink>
</div>
<Divider />
<List subheader={<ListSubheader>Dashboards</ListSubheader>} dense={true}>
{dashboards.map(({id, name}) => (
<ListItem key={id} button component={routerLink(`/dashboard/${id}/${encodeURIComponent(name)}`)}>
<ListItemIcon>
<DashboardIcon />
</ListItemIcon>
<ListItemText primary={name} />
</ListItem>
))}
{dashboards.length === 0 ? (
<ListItem button dense={true} disabled={true}>
<ListItemText primary={'no dashboards added'} />
</ListItem>
) : null}
<ListItem button component={routerLink(`/dashboards`)}>
<ListItemIcon>
<DashboardManageIcon />
</ListItemIcon>
<ListItemText primary={'Manage'} />
</ListItem>
</List>
<Divider />
<List subheader={<ListSubheader>Timesheet</ListSubheader>} dense={true}>
<ListItem button component={routerLink('/timesheet/list')}>
<ListItemIcon>
<TimeLineIcon />
</ListItemIcon>
<ListItemText primary="List" />
</ListItem>
<ListItem button component={routerLink('/timesheet/calendar')}>
<ListItemIcon>
<CalendarIcon />
</ListItemIcon>
<ListItemText primary="Calendar" />
</ListItem>
</List>
<Divider />
<List subheader={<ListSubheader>User</ListSubheader>} dense={true}>
<ListItem button component={routerLink('/user/tags')}>
<ListItemIcon>
<LabelIcon />
</ListItemIcon>
<ListItemText primary="Tags" />
</ListItem>
<ListItem button component={routerLink('/user/devices')}>
<ListItemIcon>
<DevicesIcon />
</ListItemIcon>
<ListItemText primary="Devices" />
</ListItem>
<ListItem button component={routerLink('/user/settings')}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItem>
</List>
{admin ? (
<>
<Divider />
<List subheader={<ListSubheader>Admin</ListSubheader>} dense={true}>
<ListItem button component={routerLink('/admin/users')}>
<ListItemIcon>
<UsersIcon />
</ListItemIcon>
<ListItemText primary="Users" />
</ListItem>
</List>
</>
) : null}
<Divider />
</div>
);
return (
<div className={classes.root}>
<CssBaseline />
<AppBar position="fixed" className={classes.appBar}>
<Toolbar>
<IconButton
color="inherit"
aria-label="Open drawer"
onClick={() => setMobileOpen(!mobileOpen)}
className={classes.menuButton}>
<MenuIcon />
</IconButton>
<Typography variant="h6" color="inherit" noWrap>
<Switch>
<Route exact path="/timesheet/list">
Timesheet / List
</Route>
<Route exact path="/timesheet/calendar">
Timesheet / Calendar
</Route>
<Route exact path="/user/settings">
User / Settings
</Route>
<Route exact path="/user/devices">
User / Devices
</Route>
<Route exact path="/user/tags">
User / Tags
</Route>
<Route exact path="/admin/users">
Admin / Users
</Route>
<Route exact path="/dashboards">
Dashboards / Manage
</Route>
<Route exact path="/dashboard/:id/:name">
{(props: RouteChildrenProps<{id: string}>) => {
const db = dashboards.find(
(dashboard) => dashboard.id === parseInt(props.match!.params.id, 10)
);
return 'Dashboards / ' + (db ? db.name : '...');
}}
</Route>
</Switch>
</Typography>
<div className={classes.grow} />
<div className={classes.sectionDesktop}>
<Button color="inherit" onClick={(e) => setUserMenuOpen(e.currentTarget)}>
<AccountCircle />
&nbsp;{username}
</Button>
<Menu
anchorEl={userMenuOpen}
anchorOrigin={{vertical: 'top', horizontal: 'right'}}
transformOrigin={{vertical: 'top', horizontal: 'right'}}
open={!!userMenuOpen}
onClose={() => setUserMenuOpen(null)}>
<MenuItem onClick={() => setUserMenuOpen(null)} component={routerLink('/user/settings')}>
Settings
</MenuItem>
<MenuItem
onClick={() => {
setUserMenuOpen(null);
logout();
}}>
Logout
</MenuItem>
</Menu>
</div>
</Toolbar>
</AppBar>
<nav className={classes.drawer}>
<Hidden mdUp implementation="js">
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
classes={{
paper: classes.drawerPaper,
}}>
{drawer}
</Drawer>
</Hidden>
<Hidden smDown implementation="js">
<Drawer
classes={{
paper: classes.drawerPaper,
}}
variant="permanent"
open>
{drawer}
</Drawer>
</Hidden>
</nav>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import * as React from 'react';
import {TextField} from '@material-ui/core';
import {parseRelativeTime} from '../utils/time';
import Typography from '@material-ui/core/Typography';
import useTimeout from '@rooks/use-timeout';
interface RelativeDateTimeSelectorProps {
value: string;
onChange: (value: string, valid: boolean) => void;
type: 'startOf' | 'endOf';
label?: string;
disabled?: boolean;
disableUnderline?: boolean;
style?: React.CSSProperties;
small?: boolean;
}
export const RelativeDateTimeSelector: React.FC<RelativeDateTimeSelectorProps> = ({
value,
onChange: setValue,
type,
style,
label,
small = false,
disableUnderline = false,
disabled = false,
}) => {
const [errVisible, setErrVisible] = React.useState(false);
const [error, setError] = React.useState('');
const {start, stop} = useTimeout(() => setErrVisible(true), 200);
const parsed = parseRelativeTime(value, type);
return (
<TextField
fullWidth
style={style}
value={value}
disabled={disabled}
InputProps={{disableUnderline}}
onChange={(e) => {
const newValue = e.target.value;
const result = parseRelativeTime(newValue, type);
setErrVisible(false);
stop();
if (!result.success) {
setError(result.error);
start();
} else {
setError('');
}
setValue(newValue, result.success);
}}
error={error !== ''}
helperText={
small ? (
undefined
) : errVisible ? (
<Typography color={'secondary'} variant={'caption'}>
{error}
</Typography>
) : (
<Typography variant={'caption'}>{!parsed.success ? '...' : parsed.value.format('llll')}</Typography>
)
}
label={label}
/>
);
};

View File

@@ -0,0 +1,21 @@
import moment from 'moment';
import * as React from 'react';
import {timeRunning} from '../timespan/timeutils';
import useInterval from '@rooks/use-interval';
export const RelativeToNow: React.FC<{from: moment.Moment}> = ({from}) => {
const [now, setNow] = React.useState(moment());
useInterval(
() => {
setNow(moment());
},
1000,
true
);
return <RelativeTime from={from} to={now} />;
};
export const RelativeTime: React.FC<{from: moment.Moment; to: moment.Moment}> = ({from, to}) => {
return <>{timeRunning(from, to)}</>;
};

37
ui/src/common/TagChip.tsx Normal file
View File

@@ -0,0 +1,37 @@
import Chip from '@material-ui/core/Chip';
import * as React from 'react';
// @ts-ignore
import bestContrast from 'get-best-contrast-color';
import {makeStyles, Theme} from '@material-ui/core';
const useStyles = makeStyles((theme: Theme) => ({
chip: {
margin: theme.spacing(0.5, 0.6),
cursor: 'text',
minHeight: '32px',
height: 'fit-content',
whiteSpace: 'normal',
wordBreak: 'break-word',
},
}));
interface TagChipProps {
label: string;
color: string;
onClick?: (e: React.MouseEvent) => void;
}
export const TagChip: React.FC<TagChipProps> = ({color, label, onClick}) => {
const classes = useStyles();
const textColor = bestContrast(color, ['#fff', '#000']);
return (
<Chip
tabIndex={-1}
variant="outlined"
className={classes.chip}
style={{background: color, color: textColor}}
label={label}
onClick={onClick}
/>
);
};

2
ui/src/common/tsutil.ts Normal file
View File

@@ -0,0 +1,2 @@
export type Diff<T extends string, U extends string> = ({[P in T]: P} & {[P in U]: never} & {[x: string]: never})[T];
export type Omit<T, K extends Extract<keyof T, string>> = Pick<T, Diff<Extract<keyof T, string>, K>>;

View File

@@ -0,0 +1,63 @@
import * as React from 'react';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {useMutation} from '@apollo/react-hooks';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
import * as gqlDashboard from '../gql/dashboard';
import {CreateDashboard, CreateDashboardVariables} from '../gql/__generated__/CreateDashboard';
interface AddTagDialogProps {
open: boolean;
close: () => void;
}
export const AddDashboardDialog: React.FC<AddTagDialogProps> = ({close, open}) => {
const [name, setName] = React.useState('');
const {enqueueSnackbar} = useSnackbar();
const [addUser] = useMutation<CreateDashboard, CreateDashboardVariables>(gqlDashboard.CreateDashboard, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
addUser({variables: {name}})
.then(() => {
enqueueSnackbar('Dashboard created', {variant: 'success'});
close();
})
.catch(handleError('Create Dashboard', enqueueSnackbar));
};
return (
<Dialog open={open} onClose={close} aria-labelledby="form-dialog-title" fullWidth>
<form onSubmit={submit} noValidate autoComplete="off">
<DialogTitle id="form-dialog-title">Create Dashboard</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="name"
label="Name"
type="text"
fullWidth
value={name}
onChange={(e) => setName(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button type="submit" onClick={submit} color="primary">
Create Dashboard
</Button>
</DialogActions>
</form>
</Dialog>
);
};

View File

@@ -0,0 +1,293 @@
import * as React from 'react';
import {useMutation, useQuery} from '@apollo/react-hooks';
import * as gqlDashboard from '../gql/dashboard';
import {default as ReactGrid, Layout, WidthProvider} from 'react-grid-layout';
import {Dashboards, Dashboards_dashboards_items} from '../gql/__generated__/Dashboards';
import {UpdatePos, UpdatePosVariables} from '../gql/__generated__/UpdatePos';
import {EntryType, StatsInterval} from '../gql/__generated__/globalTypes';
import {DashboardEntry} from './Entry/DashboardEntry';
import Button from '@material-ui/core/Button';
import clone from 'lodash.clonedeep';
import {EditPopup} from './Entry/EditPopup';
import {EditGlass} from './Entry/EditGlass';
import {Fade} from '../common/Fade';
import {CenteredSpinner} from '../common/CenteredSpinner';
import {AddPopup} from './Entry/AddPopup';
import {Paper} from '@material-ui/core';
import {Center} from '../common/Center';
import {RemoveDashboardEntry, RemoveDashboardEntryVariables} from '../gql/__generated__/RemoveDashboardEntry';
import {RouteChildrenProps} from 'react-router';
import {useSnackbar} from 'notistack';
import {DateRanges} from './DateRanges';
import {Range} from '../utils/range';
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
enum ViewType {
Mobile = 'mobile',
Desktop = 'desktop',
}
const cols: Record<ViewType, number> = {
[ViewType.Mobile]: 4,
[ViewType.Desktop]: 20,
};
const WidthAwareReactGrid = WidthProvider(ReactGrid);
const EditId = -1;
const newEntry = (): Dashboards_dashboards_items => {
return {
__typename: 'DashboardEntry',
title: '',
id: EditId,
entryType: EntryType.PieChart,
statsSelection: {
__typename: 'StatsSelection',
interval: StatsInterval.Single,
range: {
__typename: 'RelativeOrStaticRange',
from: 'now-1w/w',
to: 'now-1w/w',
},
rangeId: null,
tags: [],
excludeTags: [],
includeTags: [],
},
pos: {
__typename: 'ResponsiveDashboardEntryPos',
[ViewType.Desktop]: {
x: 0,
y: 0,
h: 6,
w: 6,
minH: 0,
minW: 0,
__typename: 'DashboardEntryPos',
},
[ViewType.Mobile]: {
x: 0,
y: 0,
h: 5,
w: 2,
minH: 0,
minW: 0,
__typename: 'DashboardEntryPos',
},
},
total: false,
};
};
type RouterProps = RouteChildrenProps<{id?: string}>;
export const DashboardPage: React.FC<RouterProps> = ({match, history}) => {
const [addRef, setAddRef] = React.useState<null | HTMLElement>(null);
const endRef = React.useRef<null | HTMLDivElement>(null);
const [changeMode, setChangeMode] = React.useState(false);
const [viewType, setViewType] = React.useState<ViewType>(ViewType.Desktop);
const [preview, setPreview] = React.useState(false);
const [diagramRanges, setDiagramRanges] = React.useState<Record<number, Range>>({});
const [ranges, setRanges] = useStateAndDelegateWithDelayOnChange<Record<number, Range>>({}, setDiagramRanges, 2000);
const [addEntry, setAddEntry] = React.useState<null | Dashboards_dashboards_items>(null);
const [[editElement, editEntry], setEdit] = React.useState<[null] | [HTMLElement, Dashboards_dashboards_items]>([null]);
const {loading, data, error} = useQuery<Dashboards>(gqlDashboard.Dashboards);
const [updatePos] = useMutation<UpdatePos, UpdatePosVariables>(gqlDashboard.UpdatePos, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const [removeDashboardEntry] = useMutation<RemoveDashboardEntry, RemoveDashboardEntryVariables>(
gqlDashboard.RemoveDashboardEntry,
{
refetchQueries: [{query: gqlDashboard.Dashboards}],
}
);
const {enqueueSnackbar} = useSnackbar();
if (loading) {
return <CenteredSpinner />;
}
if (error || !data) {
return <span>error</span>;
}
const dashboards = data.dashboards || [];
if (!match || !match.params.id) {
enqueueSnackbar('id parameter is missing in url', {variant: 'warning'});
history.push('/dashboards');
return <></>;
}
const dashboard = dashboards.find((db) => '' + db.id === match.params.id);
if (!dashboard) {
enqueueSnackbar('dashboard does not exist', {variant: 'warning'});
history.push('/dashboards');
return <></>;
}
const dashboardEntries = dashboard.items;
const l: Layout[] = dashboard.items.map((item) => ({
i: '' + item.id,
x: item.pos[viewType].x,
w: item.pos[viewType].w,
h: item.pos[viewType].h,
y: item.pos[viewType].y,
minH: item.pos[viewType].minH,
minW: item.pos[viewType].minW,
}));
const maxY = l.reduce((a, b) => Math.max(a, b.y + b.h), 0);
if (addEntry) {
const newEntryLayout: Layout = {i: '' + EditId, ...addEntry.pos[viewType], y: maxY, static: true};
l.push(newEntryLayout);
}
const rangeToName = () =>
dashboard.ranges.reduce(
(a, range) => ({...a, [range.id]: `${range.name} (${range.range.from} - ${range.range.to})`}),
{}
);
const indexedRanges = dashboard.ranges.reduce((a, range) => ({...a, [range.id]: diagramRanges[range.id] || range.range}), {});
const updatePosHandler = (_1: unknown, _2: unknown, newItem: Layout) => {
updatePos({
variables: {
entryId: parseInt(newItem.i!, 10),
pos: {[viewType]: {x: newItem.x, y: newItem.y, w: newItem.w, h: newItem.h}},
},
}).then(console.log);
};
return (
<>
<div style={{display: 'flex'}}>
<div style={{flex: 1}}>
<DateRanges setRanges={setRanges} ranges={ranges} changeMode={changeMode} dashboard={dashboard} />
</div>
<div>
{changeMode ? (
<Button
onClick={() => setViewType(viewType === ViewType.Desktop ? ViewType.Mobile : ViewType.Desktop)}
variant={'outlined'}
style={{marginRight: 10}}>
{viewType === ViewType.Mobile ? 'Desktop View' : 'Mobile View'}
</Button>
) : null}
{changeMode ? (
<Button
onClick={() => {
setAddEntry(newEntry());
endRef.current!.scrollIntoView({behavior: 'smooth'});
}}
variant={'outlined'}
style={{marginRight: 10}}>
Add Entry
</Button>
) : null}
<Button
onClick={() => {
setViewType(ViewType.Desktop);
setChangeMode(!changeMode);
}}
variant={'outlined'}
color={'primary'}
style={{marginRight: 10}}>
{changeMode ? 'Exit Editing' : 'Edit Dashboard'}
</Button>
</div>
</div>
<div
style={
viewType === ViewType.Desktop
? {}
: {borderRight: '5px dotted grey', borderLeft: '5px dotted grey', maxWidth: 700, margin: '0 auto'}
}>
<WidthAwareReactGrid
key={viewType}
cols={cols[viewType]}
className="layout"
rowHeight={50}
preventCollision={true}
useCSSTransforms={false}
autoSize={true}
layout={l}
onDragStop={updatePosHandler}
onResizeStop={updatePosHandler}
compactType={null}
isResizable={changeMode}
isDraggable={changeMode}>
{dashboardEntries.map((entry) => {
const currentEditedAndPreviewed = preview && editEntry && editEntry.id === entry.id;
return (
<div key={'' + entry.id}>
<DashboardEntry ranges={indexedRanges} entry={currentEditedAndPreviewed ? editEntry! : entry} />
{changeMode ? (
<Fade fullyVisible={!currentEditedAndPreviewed} opacity={0}>
<EditGlass
doEdit={(elm) => setEdit([elm, clone(entry)])}
doDelete={() => removeDashboardEntry({variables: {id: entry.id}})}
/>
</Fade>
) : null}
</div>
);
})}
{addEntry ? (
<div
key={'' + EditId}
ref={(ref: HTMLDivElement) => {
setAddRef(ref);
if (ref) {
ref.scrollIntoView({behavior: 'smooth'});
}
}}>
{preview ? (
<DashboardEntry ranges={indexedRanges} key="uff" entry={addEntry} />
) : (
<Paper style={{width: '100%', height: '100%'}}>
<Center>New Entry</Center>
</Paper>
)}
{addRef ? (
<AddPopup
maxY={maxY}
dashboardId={dashboard.id}
preview={preview}
ranges={rangeToName()}
doPreview={setPreview}
entry={addEntry}
anchorEl={addRef}
onChange={(e) => {
return setAddEntry(clone(e));
}}
finish={() => {
setAddEntry(null);
setAddRef(null);
}}
/>
) : null}
</div>
) : (
[]
)}
</WidthAwareReactGrid>
</div>
{editElement !== null && editEntry !== null ? (
<EditPopup
preview={preview}
ranges={rangeToName()}
doPreview={setPreview}
entry={editEntry!}
anchorEl={editElement}
onChange={(entry) => {
if (entry === null) {
setEdit([null]);
} else {
setEdit([editElement, entry]);
}
}}
/>
) : null}
<div ref={endRef} />
</>
);
};

View File

@@ -0,0 +1,147 @@
import * as React from 'react';
import Paper from '@material-ui/core/Paper';
import {useMutation, useQuery} from '@apollo/react-hooks';
import {CenteredSpinner} from '../common/CenteredSpinner';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import DoneIcon from '@material-ui/icons/Done';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import {useSnackbar} from 'notistack';
import * as gqlDashboard from '../gql/dashboard';
import {TextField} from '@material-ui/core';
import Button from '@material-ui/core/Button';
import {Dashboards} from '../gql/__generated__/Dashboards';
import {RemoveDashboard, RemoveDashboardVariables} from '../gql/__generated__/RemoveDashboard';
import {UpdateDashboard, UpdateDashboardVariables} from '../gql/__generated__/UpdateDashboard';
import {AddDashboardDialog} from './AddDashboardDialog';
import makeStyles from '@material-ui/core/styles/makeStyles';
import {ConfirmDialog} from '../common/ConfirmDialog';
const useStyles = makeStyles((theme) => ({
root: {
...theme.mixins.gutters(),
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
maxWidth: 800,
margin: '0 auto',
},
}));
const NoEdit = [-1, ''] as const;
export const DashboardsPage = () => {
const classes = useStyles();
const {loading, data} = useQuery<Dashboards>(gqlDashboard.Dashboards);
const [addDashboard, setAddDashboard] = React.useState(false);
const [removeDashboardConfirm, setRemoveDashboardConfirm] = React.useState<false | [number, string]>(false);
const refetch = {refetchQueries: [{query: gqlDashboard.Dashboards}]};
const {enqueueSnackbar} = useSnackbar();
const [removeDashboard] = useMutation<RemoveDashboard, RemoveDashboardVariables>(gqlDashboard.RemoveDashboard, refetch);
const [[editId, editName], setEditing] = React.useState<Readonly<[number, string]>>(NoEdit);
const [updateDashboard] = useMutation<UpdateDashboard, UpdateDashboardVariables>(gqlDashboard.UpdateDashboard, refetch);
if (loading || !data || !data.dashboards) {
return <CenteredSpinner />;
}
const onClickDelete = () => {
if (removeDashboardConfirm) {
removeDashboard({variables: {id: removeDashboardConfirm[0]}}).then(() =>
enqueueSnackbar('dashboard deleted', {variant: 'success'})
);
}
};
const dashboards = data.dashboards.map((dashboard) => {
const onClickSubmit = () => {
setEditing(NoEdit);
updateDashboard({
variables: {
id: editId,
name: editName,
},
}).then(() => enqueueSnackbar('dashboard edited', {variant: 'success'}));
};
const isEdited = editId === dashboard.id;
return (
<TableRow key={dashboard.id}>
<TableCell>{dashboard.id}</TableCell>
<TableCell>
{isEdited ? (
<TextField
value={editName}
onChange={(e) => setEditing([editId, e.target.value])}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickSubmit();
}
}}
onSubmit={onClickSubmit}
/>
) : (
dashboard.name
)}
</TableCell>
<TableCell align="right">
{isEdited ? (
<>
<IconButton onClick={onClickSubmit} title="Save">
<DoneIcon />
</IconButton>
<IconButton onClick={() => setEditing(NoEdit)} title="Cancel">
<CloseIcon />
</IconButton>
</>
) : (
<>
<IconButton onClick={() => setEditing([dashboard.id, dashboard.name])} title="Edit">
<EditIcon />
</IconButton>
<IconButton onClick={() => setRemoveDashboardConfirm([dashboard.id, dashboard.name])} title="Delete">
<DeleteIcon />
</IconButton>
</>
)}
</TableCell>
</TableRow>
);
});
return (
<Paper elevation={1} square={true} className={classes.root}>
<Button
color={'primary'}
variant={'outlined'}
size="small"
onClick={() => setAddDashboard(true)}
fullWidth
style={{marginBottom: 10}}>
Create Dashboard
</Button>
{addDashboard && <AddDashboardDialog open={true} close={() => setAddDashboard(false)} />}
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell style={{width: 150}} />
</TableRow>
</TableHead>
<TableBody>{dashboards}</TableBody>
</Table>
{removeDashboardConfirm ? (
<ConfirmDialog
title={`Delete Dashboard ${removeDashboardConfirm[1]}`}
fClose={() => setRemoveDashboardConfirm(false)}
fOnSubmit={onClickDelete}>
<b>This operation cannot be undone.</b> Deleting the dashboard will remove all its dashboard entries.
</ConfirmDialog>
) : null}
</Paper>
);
};

View File

@@ -0,0 +1,213 @@
import * as React from 'react';
import {useMutation} from '@apollo/react-hooks';
import * as gqlDashboard from '../gql/dashboard';
import {Dashboards_dashboards, Dashboards_dashboards_ranges} from '../gql/__generated__/Dashboards';
import {IconButton, Paper, Typography} from '@material-ui/core';
import {RelativeDateTimeSelector} from '../common/RelativeDateTimeSelector';
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
import MoreVert from '@material-ui/icons/MoreVert';
import PlusIcon from '@material-ui/icons/Add';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import {RemoveDashboardRange, RemoveDashboardRangeVariables} from '../gql/__generated__/RemoveDashboardRange';
import {UpdateDashboardRange, UpdateDashboardRangeVariables} from '../gql/__generated__/UpdateDashboardRange';
import {stripTypename} from '../utils/strip';
import {Range} from '../utils/range';
import {AddDashboardRange, AddDashboardRangeVariables} from '../gql/__generated__/AddDashboardRange';
import Input from '@material-ui/core/Input';
interface Props {
changeMode: boolean;
dashboard: Dashboards_dashboards;
setRanges: (cb: (ranges: Record<number, Range>) => Record<number, Range>) => void;
ranges: Record<number, Range>;
}
export const DateRanges: React.FC<Props> = ({changeMode, dashboard, ranges, setRanges}) => {
const saveRef = React.useRef<number | null>(null);
const [editedRanges, setEditedRanges] = React.useState<Record<number, Range>>({});
const [editedNames, setEditedNames] = React.useState<Record<number, string>>({});
const [openMenu, setOpenMenu] = React.useState<null | [HTMLButtonElement, number]>(null);
const [removeRange] = useMutation<RemoveDashboardRange, RemoveDashboardRangeVariables>(gqlDashboard.RemoveDashboardRange, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const [updateRange] = useMutation<UpdateDashboardRange, UpdateDashboardRangeVariables>(gqlDashboard.UpdateDashboardRange, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const [addRange] = useMutation<AddDashboardRange, AddDashboardRangeVariables>(gqlDashboard.AddDashboardRange, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const saveRanges = (range: Dashboards_dashboards_ranges, newRange: Range, newName: string) => {
if (saveRef.current) {
clearTimeout(saveRef.current);
}
saveRef.current = window.setTimeout(() => {
updateRange({
variables: {
rangeId: range.id,
range: {
editable: !range.editable,
range: newRange,
name: newName,
},
},
});
}, 500);
};
React.useEffect(() => {
if (changeMode) {
setEditedRanges({});
setEditedNames({});
}
}, [changeMode]);
return (
<>
{dashboard.ranges
.filter((range) => range.editable || changeMode)
.map((range) => {
const name = editedNames[range.id] !== undefined ? editedNames[range.id] : range.name;
const dateRange = (changeMode ? editedRanges[range.id] : ranges[range.id]) || stripTypename(range.range);
return (
<React.Fragment key={range.id}>
<Paper
style={{display: 'inline-block', padding: '3px 10px', marginLeft: 10, marginBottom: 10}}
elevation={1}>
<div style={{display: 'inline-block'}}>
<Typography variant={'subtitle2'} component="div">
{changeMode ? (
<Input
disableUnderline={true}
fullWidth
onChange={(e) => {
const value = e.target.value;
setEditedNames((old) => ({
...old,
[range.id]: value,
}));
saveRanges(range, dateRange, value);
}}
value={name}
/>
) : (
name
)}
</Typography>
<RelativeDateTimeSelector
small={!changeMode}
disableUnderline={!changeMode}
style={{width: changeMode ? 170 : 120}}
value={dateRange.from}
onChange={(value, valid) => {
(changeMode ? setEditedRanges : setRanges)((old) => ({
...old,
[range.id]: {...dateRange, from: value},
}));
if (valid && changeMode) {
saveRanges(range, {...dateRange, from: value}, name);
}
}}
type="startOf"
/>
<ArrowForwardIcon style={{margin: '0 10px'}} />
<RelativeDateTimeSelector
small={!changeMode}
disableUnderline={!changeMode}
style={{width: changeMode ? 170 : 120}}
value={dateRange.to}
onChange={(value, valid) => {
(changeMode ? setEditedRanges : setRanges)((old) => ({
...old,
[range.id]: {...dateRange, to: value},
}));
if (valid && changeMode) {
saveRanges(
range,
{
...dateRange,
to: value,
},
name
);
}
}}
type="endOf"
/>
</div>
{changeMode ? (
<>
<Typography style={{height: 50, display: 'inline-block'}} component="div">
<IconButton size="medium" onClick={(e) => setOpenMenu([e.currentTarget, range.id])}>
<MoreVert />
</IconButton>
</Typography>
</>
) : (
undefined
)}
{openMenu && openMenu[1] === range.id ? (
<Menu
key="uff"
aria-haspopup="true"
anchorEl={openMenu[0]}
onClose={() => setOpenMenu(null)}
open={openMenu !== null}>
<MenuItem
onClick={() => {
const used = dashboard.items.filter(
(entry) => entry.statsSelection.rangeId === range.id
);
if (used.length === 0) {
removeRange({variables: {rangeId: range.id}});
} else {
alert('Range is used by ' + used.map((entry) => entry.title).join(', '));
}
setOpenMenu(null);
}}>
Delete
</MenuItem>
<MenuItem
onClick={() => {
updateRange({
variables: {
rangeId: range.id,
range: {
editable: !range.editable,
range: stripTypename(range.range),
name: range.name,
},
},
});
setOpenMenu(null);
}}>
{range.editable ? 'make static' : 'make editable'}
</MenuItem>
</Menu>
) : (
undefined
)}
</Paper>
</React.Fragment>
);
})}
{changeMode ? (
<Paper style={{display: 'inline-block', padding: '3px 10px', marginLeft: 10, marginBottom: 10}} elevation={1}>
<IconButton
onClick={() => {
addRange({
variables: {
dashboardId: dashboard.id,
range: {name: 'new range', editable: true, range: {from: 'now/w', to: 'now/w'}},
},
});
}}>
<PlusIcon />
</IconButton>
</Paper>
) : (
undefined
)}
</>
);
};

View File

@@ -0,0 +1,121 @@
import * as React from 'react';
import Popper from '@material-ui/core/Popper';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import {Paper} from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
import {useMutation} from '@apollo/react-hooks';
import * as gqlDashboard from '../../gql/dashboard';
import {Fade} from '../../common/Fade';
import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm';
import {AddDashboardEntry, AddDashboardEntryVariables} from '../../gql/__generated__/AddDashboardEntry';
import {handleError} from '../../utils/errors';
import {useSnackbar} from 'notistack';
interface EditPopupProps {
dashboardId: number;
entry: Dashboards_dashboards_items;
anchorEl: HTMLElement;
onChange: (entry: Dashboards_dashboards_items | null) => void;
doPreview: (preview: boolean) => void;
preview: boolean;
maxY: number;
finish: () => void;
ranges: Record<number, string>;
}
export const AddPopup: React.FC<EditPopupProps> = ({
entry,
maxY,
anchorEl,
onChange: setEdit,
doPreview,
preview,
dashboardId,
finish,
ranges,
}) => {
const [addEntry] = useMutation<AddDashboardEntry, AddDashboardEntryVariables>(gqlDashboard.AddDashboardEntry, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const valid = isValidDashboardEntry(entry);
const {enqueueSnackbar} = useSnackbar();
return (
<Popper
key="popup"
open={true}
anchorEl={anchorEl}
placement={'right-end'}
disablePortal={false}
style={{zIndex: 99999}}
keepMounted={true}>
<Fade fullyVisible={!preview} opacity={0.4}>
<ClickAwayListener onClickAway={() => setEdit(null)}>
<Paper style={{padding: 10, maxWidth: 500}}>
<Typography variant="h5">Add</Typography>
<DashboardEntryForm ranges={ranges} entry={entry} onChange={setEdit} disabled={preview} />
<div style={{textAlign: 'right', display: 'flex', justifyContent: 'flex-end', paddingTop: 10}}>
<Button
color={'primary'}
variant={'outlined'}
style={{marginRight: 10, flex: 1}}
disabled={!valid}
onClick={() => doPreview(!preview)}>
{preview ? 'Exit Preview' : 'Preview'}
</Button>
<Button
color={'secondary'}
variant={'outlined'}
style={{marginRight: 10}}
onClick={() => setEdit(null)}>
Cancel
</Button>
<Button
color={'primary'}
disabled={!valid}
variant={'contained'}
onClick={() => {
addEntry({
variables: {
dashboardId,
entryType: entry.entryType,
title: entry.title,
total: entry.total,
stats: {
tags: entry.statsSelection.tags,
interval: entry.statsSelection.interval,
range: entry.statsSelection.range
? {
from: entry.statsSelection.range.from,
to: entry.statsSelection.range.to,
}
: null,
rangeId: entry.statsSelection.rangeId,
excludeTags: entry.statsSelection.excludeTags,
includeTags: entry.statsSelection.includeTags,
},
pos: {
desktop: {
y: maxY,
x: 0,
w: 6,
h: 6,
},
},
},
})
.then(finish)
.catch(handleError('Add Dashboard Entry', enqueueSnackbar));
}}>
Add
</Button>
</div>
</Paper>
</ClickAwayListener>
</Fade>
</Popper>
);
};

View File

@@ -0,0 +1,69 @@
import {Stats_stats} from '../../gql/__generated__/Stats';
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import * as React from 'react';
import {Colors} from './colors';
import {ofSeconds} from './unit';
import {ofInterval} from './dateformat';
import {StatsInterval} from '../../gql/__generated__/globalTypes';
import moment from 'moment';
import {TagTooltip} from './TagTooltip';
interface DashboardPieChartProps {
entries: Stats_stats[];
interval: StatsInterval;
type: 'stacked' | 'normal';
total: boolean;
}
interface Indexed {
start: string;
end: string;
data: Record<string, number>;
}
export const DashboardBarChart: React.FC<DashboardPieChartProps> = ({entries, interval, type, total}) => {
const indexedEntries: Indexed[] = entries
.map((entry) => {
return {
start: entry.start,
end: entry.end,
data: entry.entries!.reduce((all: Record<string, number>, current) => {
return {...all, [current.key + ':' + current.value]: current.timeSpendInSeconds};
}, {}),
};
})
.sort((left, right) => moment(left.start).diff(right.start));
const dataMax = indexedEntries.reduce((max, entry) => {
return Math.max(
max,
Object.values(entry.data).reduce((a: number, b: number) => Math.max(a, b), 0),
0
);
}, 0);
const unit = ofSeconds(dataMax);
const dateFormat = ofInterval(interval);
return (
<ResponsiveContainer>
<BarChart data={indexedEntries}>
<CartesianGrid strokeDasharray="3 3" />
<YAxis type="number" unit={unit.short} />
<Tooltip content={<TagTooltip dateFormat={dateFormat} total={total} />} />
<Legend />
<XAxis dataKey={(entry) => dateFormat(moment(entry.start))} interval={'preserveStartEnd'} />
{indexedEntries[0] &&
Object.keys(indexedEntries[0].data).map((key, index) => {
return (
<Bar
key={key}
dataKey={(entry) => unit.toUnit(entry.data[key])}
fill={Colors[index % Colors.length]}
stackId={type === 'stacked' ? 'a' : undefined}
name={key}
/>
);
})}
</BarChart>
</ResponsiveContainer>
);
};

View File

@@ -0,0 +1,99 @@
import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
import * as React from 'react';
import {DashboardPieChart} from './DashboardPieChart';
import {Stats_stats_entries} from '../../gql/__generated__/Stats';
import {useQuery} from '@apollo/react-hooks';
import * as gqlStats from '../../gql/statistics';
import {Paper} from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import moment from 'moment-timezone';
import {EntryType} from '../../gql/__generated__/globalTypes';
import {expectNever} from '../../utils/never';
import {Stats2, Stats2Variables} from '../../gql/__generated__/Stats2';
import {DashboardBarChart} from './DashboardBarChart';
import {DashboardLineChart} from './DashboardLineChart';
import {CenteredSpinner} from '../../common/CenteredSpinner';
import {Center} from '../../common/Center';
import {findRange, Range} from '../../utils/range';
import {DashboardTable} from './DashboardTable';
interface DashboardEntryProps {
entry: Dashboards_dashboards_items;
ranges: Record<number, Range>;
ref?: React.Ref<HTMLElement>;
}
export const DashboardEntry: React.FC<DashboardEntryProps> = React.forwardRef<{}, DashboardEntryProps>(({entry, ranges}, ref) => {
const range = findRange(entry.statsSelection, ranges);
return (
<Paper style={{width: '100%', height: '100%'}} ref={ref}>
<Typography style={{lineHeight: '20px', paddingTop: 15}} component="h4" align="center" variant="h6">
{entry.title}
</Typography>
<Typography style={{position: 'absolute', right: 10, top: 35, fontSize: 10, color: 'gray'}}>
{range.from} to {range.to}
</Typography>
<div style={{height: 'calc(100% - 35px)'}}>
<SpecificDashboardEntry range={range} entry={entry} />
</div>
</Paper>
);
});
// tslint:disable-next-line:cyclomatic-complexity mccabe-complexity
const SpecificDashboardEntry: React.FC<{entry: Dashboards_dashboards_items; range: Range}> = ({entry, range}) => {
const interval = entry.statsSelection.interval;
const stats = useQuery<Stats2, Stats2Variables>(gqlStats.Stats2, {
variables: {
now: moment()
.startOf('hour')
.format(),
stats: {
range,
interval,
tags: entry.statsSelection.tags,
excludeTags: entry.statsSelection.excludeTags,
includeTags: entry.statsSelection.includeTags,
},
},
});
if (stats.loading) {
return <CenteredSpinner />;
}
if (stats.error) {
return (
<Center>
<Typography>error: {stats.error.message}</Typography>
</Center>
);
}
const firstEntries: Stats_stats_entries[] =
(stats.data && stats.data.stats && stats.data.stats[0] && stats.data.stats[0].entries) || [];
if (firstEntries.length === 0) {
return (
<Center>
<Typography>no data</Typography>
</Center>
);
}
const entries = (stats.data && stats.data.stats) || [];
switch (entry.entryType) {
case EntryType.PieChart:
return <DashboardPieChart entries={firstEntries} />;
case EntryType.BarChart:
return <DashboardBarChart entries={entries} interval={interval} type="normal" total={entry.total} />;
case EntryType.StackedBarChart:
return <DashboardBarChart entries={entries} interval={interval} type="stacked" total={entry.total} />;
case EntryType.LineChart:
return <DashboardLineChart entries={entries} interval={interval} total={entry.total} />;
case EntryType.VerticalTable:
return <DashboardTable mode="vertical" entries={entries} interval={interval} total={entry.total} />;
case EntryType.HorizontalTable:
return <DashboardTable mode="horizontal" entries={entries} interval={interval} total={entry.total} />;
default:
return expectNever(entry.entryType);
}
};

View File

@@ -0,0 +1,256 @@
import * as React from 'react';
import TextField from '@material-ui/core/TextField';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import Select from '@material-ui/core/NativeSelect/NativeSelect';
import {useQuery} from 'react-apollo';
import {Tags} from '../../gql/__generated__/Tags';
import * as gqlTags from '../../gql/tags';
import {EntryType, StatsInterval} from '../../gql/__generated__/globalTypes';
import {toTagSelectorEntry} from '../../tag/tagSelectorEntry';
import {FormTagSelector} from '../../tag/FormTagSelector';
import {Dashboards_dashboards_items, Dashboards_dashboards_items_statsSelection_range} from '../../gql/__generated__/Dashboards';
import {RelativeDateTimeSelector} from '../../common/RelativeDateTimeSelector';
import {parseRelativeTime} from '../../utils/time';
import {Grid, Typography, Switch} from '@material-ui/core';
interface EditPopupProps {
entry: Dashboards_dashboards_items;
onChange: (entry: Dashboards_dashboards_items | null) => void;
disabled?: boolean;
ranges: Record<string, string>;
}
export const isValidDashboardEntry = (item: Dashboards_dashboards_items): boolean => {
return (
item.statsSelection.tags !== null &&
item.statsSelection.tags.length > 0 &&
((item.statsSelection.range &&
parseRelativeTime(item.statsSelection.range.from, 'startOf').success &&
parseRelativeTime(item.statsSelection.range.to, 'startOf').success) ||
!!item.statsSelection.rangeId) &&
item.title.length > 0
);
};
export const DashboardEntryForm: React.FC<EditPopupProps> = ({entry, onChange: setEntry, disabled = false, ranges}) => {
const [staticRange, setStaticRange] = React.useState(!entry.statsSelection.rangeId);
const tagsResult = useQuery<Tags>(gqlTags.Tags);
let tagKeys;
let excludeTags;
let includeTags;
if (!tagsResult.error && !tagsResult.loading && tagsResult.data && tagsResult.data.tags) {
const keyInputTags = (entry.statsSelection.tags || []).map((key) => ({key, value: ''}));
tagKeys = toTagSelectorEntry(tagsResult.data.tags, keyInputTags);
excludeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.excludeTags || []);
includeTags = toTagSelectorEntry(tagsResult.data.tags, entry.statsSelection.includeTags || []);
}
const range: Dashboards_dashboards_items_statsSelection_range = entry.statsSelection.range
? entry.statsSelection.range
: {
from: 'now/w',
to: 'now/w',
__typename: 'RelativeOrStaticRange',
};
return (
<>
<TextField
label={'Title'}
value={entry.title}
disabled={disabled}
onChange={(e) => {
entry.title = e.target.value;
setEntry(entry);
}}
fullWidth
/>
<FormControl margin={'normal'} fullWidth>
<InputLabel>Type</InputLabel>
<Select
fullWidth
value={entry.entryType}
disabled={disabled}
onChange={(e) => {
entry.entryType = e.target.value as EntryType;
setEntry(entry);
}}>
{Object.values(EntryType).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</FormControl>
<FormControl margin={'normal'} fullWidth>
<InputLabel>Interval</InputLabel>
<Select
fullWidth
disabled={disabled}
value={entry.statsSelection.interval}
onChange={(e) => {
entry.statsSelection.interval = e.target.value as StatsInterval;
setEntry(entry);
}}>
{Object.values(StatsInterval).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</FormControl>
<Typography component="div">
<Grid component="label" container alignItems="center" spacing={1}>
<Grid item>Range: </Grid>
<Grid item>Global</Grid>
<Grid item>
<Switch
checked={staticRange}
onChange={(e) => {
if (e.target.checked) {
setStaticRange(true);
entry.statsSelection.rangeId = null;
setEntry(entry);
} else {
setStaticRange(false);
if (entry.statsSelection.range === null) {
entry.statsSelection.range = range;
setEntry(entry);
}
}
}}
/>
</Grid>
<Grid item>Static</Grid>
</Grid>
</Typography>
{staticRange ? (
<>
<RelativeDateTimeSelector
label={'From'}
disabled={disabled}
value={range.from}
onChange={(startDate) => {
entry.statsSelection.rangeId = null;
entry.statsSelection.range = {...range, from: startDate};
setEntry(entry);
}}
type="startOf"
/>
<RelativeDateTimeSelector
label={'To'}
disabled={disabled}
value={range.to}
onChange={(endDate) => {
entry.statsSelection.rangeId = null;
entry.statsSelection.range = {...range, to: endDate};
setEntry(entry);
}}
type="endOf"
/>
</>
) : (
<Select
fullWidth
disabled={disabled}
value={entry.statsSelection.rangeId || ''}
onChange={(e) => {
if (e.target.value === '') {
return;
}
entry.statsSelection.rangeId = parseInt(e.target.value, 10);
entry.statsSelection.range = null;
setEntry(entry);
}}>
{entry.statsSelection.rangeId ? (
undefined
) : (
<option key={''} value={''}>
Select a date range
</option>
)}
{Object.keys(ranges).map((key) => (
<option key={key} value={key}>
{ranges[key]}
</option>
))}
</Select>
)}
{entry.entryType !== EntryType.PieChart ? (
<Typography component="div">
<Grid component="label" container alignItems="center" spacing={1}>
<Grid item>{getTotalTitle(entry.entryType)}</Grid>
<Grid item>
<Switch
checked={entry.total}
onChange={(e) => {
entry.total = e.target.checked;
setEntry(entry);
}}
/>
</Grid>
</Grid>
</Typography>
) : (
undefined
)}
<FormTagSelector
label="Tags"
selectedEntries={tagKeys || []}
onSelectedEntriesChanged={(tags) => {
entry.statsSelection.tags = tags.map((tag) => tag.tag.key);
setEntry(entry);
}}
createTags={false}
onlySelectKeys
/>
<FormTagSelector
label="Exclude"
selectedEntries={excludeTags || []}
onSelectedEntriesChanged={(tags) => {
entry.statsSelection.excludeTags = tags.map((tag) => ({
key: tag.tag.key,
value: tag.value,
__typename: 'TimeSpanTag',
}));
setEntry(entry);
}}
allowDuplicateKeys
createTags={false}
/>
<FormTagSelector
label="Include"
selectedEntries={includeTags || []}
onSelectedEntriesChanged={(tags) => {
entry.statsSelection.includeTags = tags.map((tag) => ({
key: tag.tag.key,
value: tag.value,
__typename: 'TimeSpanTag',
}));
setEntry(entry);
}}
allowDuplicateKeys
createTags={false}
/>
</>
);
};
const getTotalTitle = (entryType: EntryType) => {
switch (entryType) {
case EntryType.HorizontalTable:
return 'Show "Total" row:';
case EntryType.VerticalTable:
return 'Show "Total" column:';
case EntryType.BarChart:
case EntryType.StackedBarChart:
case EntryType.LineChart:
return 'Show "Total" in tooltip:';
default:
return '';
}
};

View File

@@ -0,0 +1,68 @@
import {Stats_stats} from '../../gql/__generated__/Stats';
import {CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import * as React from 'react';
import {Colors} from './colors';
import {ofSeconds} from './unit';
import {ofInterval} from './dateformat';
import {StatsInterval} from '../../gql/__generated__/globalTypes';
import moment from 'moment';
import {TagTooltip} from './TagTooltip';
interface DashboardPieChartProps {
entries: Stats_stats[];
interval: StatsInterval;
total: boolean;
}
interface Indexed {
start: string;
end: string;
data: Record<string, number>;
}
export const DashboardLineChart: React.FC<DashboardPieChartProps> = ({entries, interval, total}) => {
const indexedEntries: Indexed[] = entries
.map((entry) => {
return {
start: entry.start,
end: entry.end,
data: entry.entries!.reduce((all: Record<string, number>, current) => {
return {...all, [current.key + ':' + current.value]: current.timeSpendInSeconds};
}, {}),
};
})
.sort((left, right) => moment(left.start).diff(right.start));
const dataMax = indexedEntries.reduce((max, entry) => {
return Math.max(
max,
Object.values(entry.data).reduce((a: number, b: number) => Math.max(a, b), 0),
0
);
}, 0);
const unit = ofSeconds(dataMax);
const dateFormat = ofInterval(interval);
return (
<ResponsiveContainer>
<LineChart data={indexedEntries}>
<CartesianGrid strokeDasharray="3 3" />
<YAxis type="number" unit={unit.short} />
<Tooltip content={<TagTooltip dateFormat={dateFormat} total={total} />} />
<Legend />
<XAxis dataKey={(entry) => dateFormat(moment(entry.start))} interval={'preserveStartEnd'} />
{indexedEntries[0] &&
Object.keys(indexedEntries[0].data).map((key, index) => {
return (
<Line
key={key}
dataKey={(entry) => unit.toUnit(entry.data[key])}
strokeWidth={3}
stroke={Colors[index % Colors.length]}
name={key}
/>
);
})}
</LineChart>
</ResponsiveContainer>
);
};

View File

@@ -0,0 +1,58 @@
import {Stats_stats_entries} from '../../gql/__generated__/Stats';
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, TooltipProps} from 'recharts';
import * as React from 'react';
import {Colors} from './colors';
import {Typography} from '@material-ui/core';
import prettyMs from 'pretty-ms';
import Paper from '@material-ui/core/Paper';
interface DashboardPieChartProps {
entries: Stats_stats_entries[];
}
export const DashboardPieChart: React.FC<DashboardPieChartProps> = ({entries}) => {
const total = entries.map((e) => e.timeSpendInSeconds).reduce((x, y) => x + y, 0);
return (
<ResponsiveContainer>
<PieChart>
<Pie
isAnimationActive={false}
dataKey="timeSpendInSeconds"
nameKey={(entry) => {
// tslint:disable-next-line:no-any
return (entry.key + ':' + entry.value) as any;
}}
data={entries}
labelLine={false}
fill="#8884d8"
legendType={'square'}>
{entries.map((_, index) => (
<Cell key={index} fill={Colors[index % Colors.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip total={total} />} />
<Legend />
</PieChart>
</ResponsiveContainer>
);
};
interface CustomTooltipProps extends TooltipProps {
total: number;
}
const CustomTooltip = ({active, payload, total}: CustomTooltipProps) => {
if (active && payload) {
const first = payload[0];
return (
<Paper style={{padding: 10}} elevation={4}>
<Typography>
{first.payload.key}:{first.payload.value}: {prettyMs(first.payload.timeSpendInSeconds * 1000)} (
{total > 0 ? ((first.payload.timeSpendInSeconds / total) * 100).toFixed(2) : '0.00'}%)
</Typography>
</Paper>
);
}
return null;
};

View File

@@ -0,0 +1,94 @@
import {Stats_stats} from '../../gql/__generated__/Stats';
import * as React from 'react';
import {StatsInterval} from '../../gql/__generated__/globalTypes';
import moment from 'moment';
import {Table, TableBody, TableCell, TableRow} from '@material-ui/core';
import TableHead from '@material-ui/core/TableHead';
import {ofInterval} from './dateformat';
import prettyMs from 'pretty-ms';
interface DashboardTableProps {
entries: Stats_stats[];
interval: StatsInterval;
mode: 'vertical' | 'horizontal';
total: boolean;
}
interface Indexed {
start: string;
end: string;
data: Record<string, number>;
}
export const DashboardTable: React.FC<DashboardTableProps> = ({entries, interval, mode, total}) => {
const indexedEntries: Indexed[] = entries
.map((entry) => {
const result = {
start: entry.start,
end: entry.end,
data: entry.entries!.reduce((all: Record<string, number>, current) => {
return {...all, [current.key + ':' + current.value]: current.timeSpendInSeconds};
}, {}),
};
if (total) {
result.data['Total'] = Object.values(result.data).reduce((acc, value) => acc + value, 0);
}
return result;
})
.sort((left, right) => moment(left.start).diff(right.start));
const dateFormat = ofInterval(interval);
const keys = Object.keys((indexedEntries[0] && indexedEntries[0].data) || {});
return (
<div style={{overflow: 'auto', height: '100%', margin: 2}}>
<Table size="small">
{mode === 'vertical' ? (
<>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
{keys.map((key) => {
return <TableCell key={key}>{key}</TableCell>;
})}
</TableRow>
</TableHead>
<TableBody>
{indexedEntries.map((entry) => {
return (
<TableRow key={entry.start}>
<TableCell>{dateFormat(moment(entry.start))}</TableCell>
{keys.map((key) => (
<TableCell key={key + entry.start}>{prettyMs(entry.data[key] * 1000)}</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</>
) : (
<>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
{indexedEntries.map((entry) => {
return <TableCell key={entry.start}>{dateFormat(moment(entry.start))}</TableCell>;
})}
</TableRow>
</TableHead>
<TableBody>
{keys.map((key) => {
return (
<TableRow key={key}>
<TableCell>{key}</TableCell>
{indexedEntries.map((entry) => (
<TableCell key={key + entry.start}>{prettyMs(entry.data[key] * 1000)}</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</>
)}
</Table>
</div>
);
};

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import EditIcon from '@material-ui/icons/Edit';
import Typography from '@material-ui/core/Typography';
import {Center} from '../../common/Center';
import DeleteIcon from '@material-ui/icons/Delete';
import {Button} from '@material-ui/core';
interface EditGlassProps {
doEdit: (elm: HTMLElement) => void;
doDelete: () => void;
}
export const EditGlass: React.FC<EditGlassProps> = ({doEdit, doDelete}) => {
const [confirm, askConfirmation] = React.useState(false);
return (
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
background: 'rgba(110,110,110,.5)',
}}>
{confirm ? (
<div>
<Typography>Confirm Deletion</Typography>
<Button onClick={() => askConfirmation(false)}>Cancel</Button>
<Button color={'primary'} onClick={doDelete}>
Yeah, Delete
</Button>
</div>
) : (
<>
<div style={{height: '30%', display: 'flex', cursor: 'pointer'}}>
<div
style={{height: '100%', width: '65%', background: 'rgba(255,255,255,.4)'}}
onClick={(e) => doEdit(e.currentTarget)}>
<Center>
<EditIcon />
</Center>
</div>
<div
style={{height: '100%', width: '35%', background: 'rgba(192, 57, 43, .4)'}}
onClick={() => askConfirmation(true)}>
<Center>
<DeleteIcon fontSize={'small'} />
</Center>
</div>
</div>
<div style={{height: '70%', cursor: 'grab'}}>
<Center>
<div style={{background: 'rgba(120,120,120, .8)', padding: 10}}>
<Typography variant={'h5'} style={{textAlign: 'center', width: '100%'}}>
drag here
</Typography>
<Typography variant={'body1'} style={{textAlign: 'center'}}>
resize on corner
</Typography>
</div>
</Center>
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,99 @@
import * as React from 'react';
import Popper from '@material-ui/core/Popper';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import {Paper} from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import {Dashboards_dashboards_items} from '../../gql/__generated__/Dashboards';
import {useMutation} from '@apollo/react-hooks';
import * as gqlDashboard from '../../gql/dashboard';
import {UpdateDashboardEntry, UpdateDashboardEntryVariables} from '../../gql/__generated__/UpdateDashboardEntry';
import {Fade} from '../../common/Fade';
import {DashboardEntryForm, isValidDashboardEntry} from './DashboardEntryForm';
import {handleError} from '../../utils/errors';
import {useSnackbar} from 'notistack';
interface EditPopupProps {
entry: Dashboards_dashboards_items;
anchorEl: HTMLElement;
onChange: (entry: Dashboards_dashboards_items | null) => void;
doPreview: (preview: boolean) => void;
preview: boolean;
ranges: Record<number, string>;
}
export const EditPopup: React.FC<EditPopupProps> = ({entry, anchorEl, onChange: setEdit, doPreview, preview, ranges}) => {
const [updateEntry] = useMutation<UpdateDashboardEntry, UpdateDashboardEntryVariables>(gqlDashboard.UpdateDashboardEntry, {
refetchQueries: [{query: gqlDashboard.Dashboards}],
});
const valid = isValidDashboardEntry(entry);
const {enqueueSnackbar} = useSnackbar();
return (
<Popper
key="popup"
open={true}
anchorEl={anchorEl}
placement={'right-start'}
disablePortal={false}
style={{zIndex: 99999}}
keepMounted={true}>
<Fade fullyVisible={!preview} opacity={0.2}>
<ClickAwayListener onClickAway={() => setEdit(null)}>
<Paper style={{padding: 10, maxWidth: 500}}>
<Typography variant="h5">Edit</Typography>
<DashboardEntryForm ranges={ranges} entry={entry} onChange={setEdit} disabled={preview} />
<div style={{textAlign: 'right', display: 'flex', justifyContent: 'flex-end', paddingTop: 10}}>
<Button
color={'primary'}
variant={'outlined'}
style={{marginRight: 10, flex: 1}}
disabled={!valid}
onClick={() => doPreview(!preview)}>
{preview ? 'Exit Preview' : 'Preview'}
</Button>
<Button
color={'secondary'}
variant={'outlined'}
style={{marginRight: 10}}
onClick={() => setEdit(null)}>
Cancel
</Button>
<Button
color={'primary'}
variant={'contained'}
disabled={!valid}
onClick={() => {
updateEntry({
variables: {
entryId: entry.id,
entryType: entry.entryType,
title: entry.title,
total: entry.total,
stats: {
tags: entry.statsSelection.tags,
interval: entry.statsSelection.interval,
range: entry.statsSelection.range
? {
from: entry.statsSelection.range.from,
to: entry.statsSelection.range.to,
}
: null,
rangeId: entry.statsSelection.rangeId,
excludeTags: entry.statsSelection.excludeTags,
includeTags: entry.statsSelection.includeTags,
},
},
})
.then(() => setEdit(null))
.catch(handleError('Edit Dashboard Entry', enqueueSnackbar));
}}>
Save
</Button>
</div>
</Paper>
</ClickAwayListener>
</Fade>
</Popper>
);
};

View File

@@ -0,0 +1,39 @@
import {TooltipProps, TooltipPayload} from 'recharts';
import {FInterval} from './dateformat';
import Paper from '@material-ui/core/Paper';
import {Typography} from '@material-ui/core';
import moment from 'moment-timezone';
import prettyMs from 'pretty-ms';
import * as React from 'react';
export const TagTooltip = ({active, payload, dateFormat, total}: TooltipProps & {dateFormat: FInterval; total: boolean}) => {
if (active && payload) {
const first = payload[0];
const start = dateFormat(moment(first.payload.start));
const end = dateFormat(moment(first.payload.end));
return (
<Paper style={{padding: 10}} elevation={4}>
{first && <Typography variant={'subtitle2'}>{start === end ? `${start}` : `${start} - ${end}`}</Typography>}
{payload.map((entry) => (
<Typography key={entry.name}>
{entry.name}: {prettyMs((entry.payload.data[entry.name] as number) * 1000)}
</Typography>
))}
{total ? (
<Typography>
Total:
{prettyMs(sum(payload) * 1000)}
</Typography>
) : (
undefined
)}
</Paper>
);
}
return null;
};
const sum = (payload: readonly TooltipPayload[]): number =>
payload.reduce((acc, entry) => (acc + entry.payload.data[entry.name]) as number, 0);

View File

@@ -0,0 +1 @@
export const Colors = ['#5DA5DA', '#60BD68', '#B2912F', '#F17CB0', '#FAA43A', '#F15854', '#DECF3F', '#B276B2', '#4D4D4D'];

View File

@@ -0,0 +1,21 @@
import {StatsInterval} from '../../gql/__generated__/globalTypes';
import * as moment from 'moment-timezone';
import {expectNever} from '../../utils/never';
export type FInterval = (date: moment.Moment) => string;
export const ofInterval = (interval: StatsInterval): FInterval => {
switch (interval) {
case StatsInterval.Weekly:
case StatsInterval.Monthly:
case StatsInterval.Yearly:
return (d) => d.tz('utc').format('l');
case StatsInterval.Hourly:
return (d) => d.tz('utc').format('lll');
case StatsInterval.Single:
case StatsInterval.Daily:
return (d) => d.tz('utc').format('dddd') + ', ' + d.format('l');
default:
return expectNever(interval);
}
};

View File

@@ -0,0 +1,33 @@
interface Unit {
toUnit: (seconds: number) => number;
short: string;
name: string;
}
const Minutes: Unit = {
toUnit: (s) => s / 60,
name: 'minutes',
short: 'm',
};
const Hours: Unit = {
toUnit: (s) => Minutes.toUnit(s) / 60,
name: 'hours',
short: 'h',
};
const Days: Unit = {
toUnit: (s) => Hours.toUnit(s) / 24,
name: 'days',
short: 'd',
};
export const ofSeconds = (seconds: number): Unit => {
if (seconds < 2 * 60 * 60) {
return Minutes;
}
if (seconds < 30 * 60 * 60) {
return Hours;
}
return Days;
};

View File

@@ -0,0 +1,110 @@
import * as React from 'react';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
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 * as gqlDevice from '../gql/device';
import {MutationFetchResult} from 'react-apollo';
import {useMutation} from '@apollo/react-hooks';
import {CreateDevice, CreateDeviceVariables} from '../gql/__generated__/CreateDevice';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
import {DeviceType} from '../gql/__generated__/globalTypes';
import {deviceTypeToString} from './typeutils';
import Select from '@material-ui/core/NativeSelect';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
interface AddTagDialogProps {
initialName: string;
open: boolean;
close: () => void;
}
export const AddDeviceDialog: React.FC<AddTagDialogProps> = ({close, open, initialName}) => {
const [token, setToken] = React.useState('');
const [name, setName] = React.useState(initialName);
const [deviceType, setDeviceType] = React.useState(DeviceType.NoExpiry);
const {enqueueSnackbar} = useSnackbar();
const [addDevice] = useMutation<CreateDevice, CreateDeviceVariables>(gqlDevice.CreateDevice, {
refetchQueries: [{query: gqlDevice.Devices}],
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
addDevice({variables: {deviceType, name}})
.then((result: MutationFetchResult<CreateDevice>) => {
if (result.data && result.data.device) {
enqueueSnackbar('Client created', {variant: 'success'});
setToken(result.data.device.token);
}
})
.catch(handleError('Create Dialog', enqueueSnackbar));
};
return (
<Dialog
open={open}
onClose={() => {
if (token === '') {
close();
}
}}
aria-labelledby="form-dialog-title"
fullWidth>
{token === '' ? (
<form onSubmit={submit} noValidate autoComplete="off">
<DialogTitle id="form-dialog-title">Create Device</DialogTitle>
<DialogContent>
<DialogContentText />
<TextField
autoFocus
margin="dense"
id="name"
label="Name"
type="text"
fullWidth
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FormControl margin={'normal'} fullWidth>
<InputLabel>Expires after</InputLabel>
<Select value={deviceType} onChange={(e) => setDeviceType(e.target.value as DeviceType)} fullWidth>
<option value={DeviceType.NoExpiry}>{deviceTypeToString(DeviceType.NoExpiry)}</option>
<option value={DeviceType.ShortExpiry}>{deviceTypeToString(DeviceType.ShortExpiry)}</option>
<option value={DeviceType.LongExpiry}>{deviceTypeToString(DeviceType.LongExpiry)}</option>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button type="submit" onClick={submit} color="primary">
Create Device
</Button>
</DialogActions>
</form>
) : (
<>
<DialogTitle id="form-dialog-title">Device Created</DialogTitle>
<DialogContent>
<DialogContentText>
The device has the following authentication token, copy it and save it somewhere because you cannot
obtain the token after closing this dialog
<br /> <b>{token}</b>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Close
</Button>
</DialogActions>
</>
)}
</Dialog>
);
};

View File

@@ -0,0 +1,162 @@
import * as React from 'react';
import Paper from '@material-ui/core/Paper';
import {makeStyles} from '@material-ui/core/styles';
import {useMutation, useQuery} from '@apollo/react-hooks';
import * as gqlDevice from '../gql/device';
import * as gqlUser from '../gql/user';
import {Devices} from '../gql/__generated__/Devices';
import {CenteredSpinner} from '../common/CenteredSpinner';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import moment from 'moment-timezone';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import DoneIcon from '@material-ui/icons/Done';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import {RemoveDevice, RemoveDeviceVariables} from '../gql/__generated__/RemoveDevice';
import {UpdateDevice, UpdateDeviceVariables} from '../gql/__generated__/UpdateDevice';
import {useSnackbar} from 'notistack';
import {TextField} from '@material-ui/core';
import Button from '@material-ui/core/Button';
import {AddDeviceDialog} from './AddDeviceDialog';
import {DeviceType} from '../gql/__generated__/globalTypes';
import {deviceTypeToString} from './typeutils';
import Select from '@material-ui/core/NativeSelect';
const useStyles = makeStyles((theme) => ({
root: {
...theme.mixins.gutters(),
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
maxWidth: 1200,
margin: '0 auto',
},
}));
export const DevicesPage = () => {
const classes = useStyles();
const {data, loading} = useQuery<Devices>(gqlDevice.Devices);
const refetch = {refetchQueries: [{query: gqlDevice.Devices}, {query: gqlUser.CurrentUser}]};
const {enqueueSnackbar} = useSnackbar();
const [removeDevice] = useMutation<RemoveDevice, RemoveDeviceVariables>(gqlDevice.RemoveDevice, refetch);
const [[editId, editName, editDeviceType], setEditing] = React.useState<[number, string, DeviceType]>([
-1,
'',
DeviceType.NoExpiry,
]);
const [addActive, setAddActive] = React.useState(false);
const [updateDevice] = useMutation<UpdateDevice, UpdateDeviceVariables>(gqlDevice.UpdateDevice, refetch);
if (loading || !data || !data.currentDevice || !data.devices) {
return <CenteredSpinner />;
}
const devices = data.devices.map((device) => {
const onClickDelete = () =>
removeDevice({variables: {id: device.id}}).then(() => enqueueSnackbar('device deleted', {variant: 'success'}));
const onClickSubmit = () => {
setEditing([-1, '', DeviceType.NoExpiry]);
updateDevice({
variables: {
id: editId,
name: editName,
deviceType: editDeviceType,
},
}).then(() => enqueueSnackbar('device edited', {variant: 'success'}));
};
const isCurrent = device.id === data.currentDevice!.id;
const isEdited = editId === device.id;
return (
<TableRow key={device.id} selected={isCurrent}>
<TableCell>{device.id}</TableCell>
<TableCell>
{isEdited ? (
<TextField
value={editName}
onChange={(e) => setEditing([editId, e.target.value, editDeviceType])}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickSubmit();
}
}}
onSubmit={onClickSubmit}
style={{minWidth: 128}}
/>
) : (
device.name + (isCurrent ? ' (current)' : '')
)}
</TableCell>
<TableCell title={device.createdAt}>{moment(device.createdAt).fromNow()}</TableCell>
<TableCell title={device.type} style={{minWidth: 128}}>
{isEdited ? (
<Select
value={editDeviceType}
onChange={(e) => setEditing([editId, editName, e.target.value as DeviceType])}>
<option value={DeviceType.NoExpiry}>{deviceTypeToString(DeviceType.NoExpiry)}</option>
<option value={DeviceType.ShortExpiry}>{deviceTypeToString(DeviceType.ShortExpiry)}</option>
<option value={DeviceType.LongExpiry}>{deviceTypeToString(DeviceType.LongExpiry)}</option>
</Select>
) : (
deviceTypeToString(device.type)
)}
</TableCell>
<TableCell title={device.activeAt}>{moment(device.activeAt).fromNow()}</TableCell>
<TableCell align="right">
{isEdited ? (
<>
<IconButton onClick={onClickSubmit} title="Save">
<DoneIcon />
</IconButton>
<IconButton onClick={() => setEditing([-1, '', DeviceType.NoExpiry])} title="Cancel">
<CloseIcon />
</IconButton>
</>
) : (
<>
<IconButton onClick={() => setEditing([device.id, device.name, device.type])} title="Edit">
<EditIcon />
</IconButton>
<IconButton onClick={onClickDelete} title="Delete">
<DeleteIcon />
</IconButton>
</>
)}
</TableCell>
</TableRow>
);
});
return (
<Paper elevation={1} square={true} className={classes.root}>
<Button
color={'primary'}
variant={'outlined'}
size="small"
onClick={() => setAddActive(true)}
fullWidth
style={{marginBottom: 10}}>
Create Device
</Button>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Created</TableCell>
<TableCell>Expires after</TableCell>
<TableCell>Last Active</TableCell>
<TableCell style={{width: 150}} />
</TableRow>
</TableHead>
<TableBody>
{addActive ? <AddDeviceDialog initialName={''} open={true} close={() => setAddActive(false)} /> : null}
{devices}
</TableBody>
</Table>
</Paper>
);
};

View File

@@ -0,0 +1,15 @@
import {DeviceType} from '../gql/__generated__/globalTypes';
import {expectNever} from '../utils/never';
export const deviceTypeToString = (type: DeviceType) => {
switch (type) {
case DeviceType.LongExpiry:
return 'A month of inactivity';
case DeviceType.ShortExpiry:
return 'An hour of inactivity';
case DeviceType.NoExpiry:
return 'Never';
default:
return expectNever(type);
}
};

37
ui/src/global.css Normal file
View File

@@ -0,0 +1,37 @@
#root,
body,
html {
height: 100%;
}
/* Allows the tag Chips to wrap lines */
.MuiChip-label {
white-space: normal !important;
}
/* The wrapping box around the input and icon for the datetime picker */
.time-picker .MuiInputBase-root.MuiInput-root.MuiInputBase-formControl.MuiInput-formControl.MuiInputBase-adornedEnd {
margin-top: 0;
}
/* The calendar icon for the datetime picker */
.time-picker
.MuiInputBase-root.MuiInputBase-formControl.MuiInputBase-adornedEnd
.MuiInputAdornment-root.MuiInputAdornment-positionEnd
.MuiButtonBase-root.MuiIconButton-root {
position: absolute;
left: 0;
}
/* The label for the datetime picker */
.time-picker.MuiFormControl-root.MuiTextField-root .MuiFormLabel-root.MuiInputLabel-root.MuiInputLabel-formControl {
margin-left: 48px;
}
/* The input box for the datetime picker */
.time-picker.MuiFormControl-root.MuiTextField-root .MuiInputBase-input.MuiInput-input.MuiInputBase-inputAdornedEnd {
margin-left: 48px;
/* margin-top: 8px; */
margin-top: 12px;
min-width: calc(100% - 48px);
}

157
ui/src/gql/dashboard.ts Normal file
View File

@@ -0,0 +1,157 @@
import {gql} from 'apollo-boost';
export const Dashboards = gql`
query Dashboards {
dashboards {
id
name
ranges {
id
name
editable
range {
from
to
}
}
items {
id
title
total
entryType
statsSelection {
range {
from
to
}
rangeId
interval
tags
excludeTags {
key
value
}
includeTags {
key
value
}
}
pos {
mobile {
w
h
x
y
minH
minW
}
desktop {
w
h
x
y
minH
minW
}
}
}
}
}
`;
export const RemoveDashboard = gql`
mutation RemoveDashboard($id: Int!) {
removeDashboard(id: $id) {
id
}
}
`;
export const UpdateDashboard = gql`
mutation UpdateDashboard($id: Int!, $name: String!) {
updateDashboard(id: $id, name: $name) {
id
}
}
`;
export const CreateDashboard = gql`
mutation CreateDashboard($name: String!) {
createDashboard(name: $name) {
id
}
}
`;
export const UpdatePos = gql`
mutation UpdatePos($entryId: Int!, $pos: InputResponsiveDashboardEntryPos!) {
updateDashboardEntry(entryId: $entryId, pos: $pos) {
id
}
}
`;
export const UpdateDashboardEntry = gql`
mutation UpdateDashboardEntry(
$entryId: Int!
$entryType: EntryType!
$title: String!
$total: Boolean!
$stats: InputStatsSelection!
) {
updateDashboardEntry(entryId: $entryId, entryType: $entryType, title: $title, total: $total, stats: $stats) {
id
}
}
`;
export const AddDashboardEntry = gql`
mutation AddDashboardEntry(
$dashboardId: Int!
$entryType: EntryType!
$title: String!
$total: Boolean!
$stats: InputStatsSelection!
$pos: InputResponsiveDashboardEntryPos
) {
addDashboardEntry(
dashboardId: $dashboardId
entryType: $entryType
title: $title
total: $total
stats: $stats
pos: $pos
) {
id
}
}
`;
export const RemoveDashboardEntry = gql`
mutation RemoveDashboardEntry($id: Int!) {
removeDashboardEntry(id: $id) {
id
}
}
`;
export const RemoveDashboardRange = gql`
mutation RemoveDashboardRange($rangeId: Int!) {
removeDashboardRange(rangeId: $rangeId) {
id
}
}
`;
export const UpdateDashboardRange = gql`
mutation UpdateDashboardRange($rangeId: Int!, $range: InputNamedDateRange!) {
updateDashboardRange(rangeId: $rangeId, range: $range) {
name
}
}
`;
export const AddDashboardRange = gql`
mutation AddDashboardRange($dashboardId: Int!, $range: InputNamedDateRange!) {
addDashboardRange(dashboardId: $dashboardId, range: $range) {
name
}
}
`;

38
ui/src/gql/device.ts Normal file
View File

@@ -0,0 +1,38 @@
import {gql} from 'apollo-boost';
export const Devices = gql`
query Devices {
devices {
id
name
type
createdAt
activeAt
}
currentDevice {
id
}
}
`;
export const RemoveDevice = gql`
mutation RemoveDevice($id: Int!) {
removeDevice(id: $id) {
id
}
}
`;
export const UpdateDevice = gql`
mutation UpdateDevice($id: Int!, $name: String!, $deviceType: DeviceType!) {
updateDevice(id: $id, name: $name, type: $deviceType) {
id
}
}
`;
export const CreateDevice = gql`
mutation CreateDevice($name: String!, $deviceType: DeviceType!) {
device: createDevice(name: $name, type: $deviceType) {
token
}
}
`;

41
ui/src/gql/settings.ts Normal file
View File

@@ -0,0 +1,41 @@
import {gql} from 'apollo-boost';
import {useQuery} from '@apollo/react-hooks';
import {Settings as SettingsQueryResponse} from './__generated__/Settings';
import {DateLocale, Theme, WeekDay, DateTimeInputStyle} from './__generated__/globalTypes';
import {stripTypename} from '../utils/strip';
export const Settings = gql`
query Settings {
userSettings {
theme
dateLocale
firstDayOfTheWeek
dateTimeInputStyle
}
}
`;
export const SetSettings = gql`
mutation SetSettings($settings: InputUserSettings!) {
setUserSettings(settings: $settings) {
theme
dateTimeInputStyle
}
}
`;
const defaultSettings = {
theme: Theme.GruvboxDark,
dateLocale: DateLocale.American,
firstDayOfTheWeek: WeekDay.Monday,
dateTimeInputStyle: DateTimeInputStyle.Fancy,
} as const;
export const useSettings = (): {done: boolean} & Omit<SettingsQueryResponse['userSettings'], '__typename'> => {
const data = useQuery<SettingsQueryResponse>(Settings);
if (data.loading || data.error || !data.data) {
return {...defaultSettings, done: false};
}
return {done: true, ...stripTypename(data.data.userSettings)};
};

28
ui/src/gql/statistics.ts Normal file
View File

@@ -0,0 +1,28 @@
import {gql} from 'apollo-boost';
export const Stats = gql`
query Stats($ranges: [Range!], $tags: [String!], $excludeTags: [InputTimeSpanTag!], $requireTags: [InputTimeSpanTag!]) {
stats(ranges: $ranges, tags: $tags, excludeTags: $excludeTags, requireTags: $requireTags) {
start
end
entries {
key
value
timeSpendInSeconds
}
}
}
`;
export const Stats2 = gql`
query Stats2($now: Time!, $stats: InputStatsSelection!) {
stats: stats2(now: $now, stats: $stats) {
start
end
entries {
key
value
timeSpendInSeconds
}
}
}
`;

52
ui/src/gql/tags.ts Normal file
View File

@@ -0,0 +1,52 @@
import {gql} from 'apollo-boost';
export const SuggestTag = gql`
query SuggestTag($query: String!) {
tags: suggestTag(query: $query) {
color
key
}
}
`;
export const Tags = gql`
query Tags {
tags {
color
key
usages
}
}
`;
export const SuggestTagValue = gql`
query SuggestTagValue($tag: String!, $query: String!) {
values: suggestTagValue(key: $tag, query: $query)
}
`;
export const AddTag = gql`
mutation AddTag($name: String!, $color: String!) {
createTag(key: $name, color: $color) {
color
key
}
}
`;
export const UpdateTag = gql`
mutation UpdateTag($key: String!, $newKey: String, $color: String!) {
updateTag(key: $key, newKey: $newKey, color: $color) {
color
key
}
}
`;
export const RemoveTag = gql`
mutation RemoveTag($key: String!) {
removeTag(key: $key) {
color
key
}
}
`;

134
ui/src/gql/timeSpan.ts Normal file
View File

@@ -0,0 +1,134 @@
import {gql} from 'apollo-boost';
export const Trackers = gql`
query Trackers {
timers {
id
start
end
tags {
key
value
}
oldStart
note
}
}
`;
export const TimeSpansInRange = gql`
query TimeSpansInRange($start: Time!, $end: Time!) {
timeSpans(fromInclusive: $start, toInclusive: $end) {
timeSpans {
id
start
end
tags {
key
value
}
oldStart
note
}
cursor {
startId
offset
pageSize
}
}
}
`;
export const TimeSpans = gql`
query TimeSpans($cursor: InputCursor) {
timeSpans(cursor: $cursor) @connection(key: "AllTimeSpans") {
timeSpans {
id
start
end
tags {
key
value
}
oldStart
note
}
cursor {
startId
offset
pageSize
}
}
}
`;
export const StartTimer = gql`
mutation StartTimer($start: Time!, $tags: [InputTimeSpanTag!], $note: String!) {
createTimeSpan(start: $start, tags: $tags, note: $note) {
id
start
end
tags {
key
value
}
oldStart
note
}
}
`;
export const StopTimer = gql`
mutation StopTimer($id: Int!, $end: Time!) {
stopTimeSpan(id: $id, end: $end) {
id
start
end
tags {
key
value
}
oldStart
note
}
}
`;
export const AddTimeSpan = gql`
mutation AddTimeSpan($start: Time!, $end: Time!, $tags: [InputTimeSpanTag!], $note: String!) {
createTimeSpan(start: $start, end: $end, tags: $tags, note: $note) {
id
start
end
tags {
key
value
}
oldStart
note
}
}
`;
export const UpdateTimeSpan = gql`
mutation UpdateTimeSpan($id: Int!, $start: Time!, $end: Time, $tags: [InputTimeSpanTag!], $oldStart: Time, $note: String!) {
updateTimeSpan(id: $id, start: $start, end: $end, tags: $tags, oldStart: $oldStart, note: $note) {
id
start
end
tags {
key
value
}
oldStart
note
}
}
`;
export const RemoveTimeSpan = gql`
mutation RemoveTimeSpan($id: Int!) {
removeTimeSpan(id: $id) {
id
}
}
`;

72
ui/src/gql/user.ts Normal file
View File

@@ -0,0 +1,72 @@
import {gql} from 'apollo-boost';
export const CurrentUser = gql`
query CurrentUser {
user: currentUser {
name
admin
id
}
}
`;
export const Login = gql`
mutation Login($name: String!, $pass: String!, $deviceType: DeviceType!) {
login(username: $name, pass: $pass, deviceName: "web ui", type: $deviceType, cookie: true) {
user {
id
name
admin
}
}
}
`;
export const Logout = gql`
mutation Logout {
user: removeCurrentDevice {
name
}
}
`;
export const Users = gql`
query Users {
users {
id
name
admin
}
currentUser {
id
}
}
`;
export const RemoveUser = gql`
mutation RemoveUser($id: Int!) {
removeUser(id: $id) {
id
name
admin
}
}
`;
export const UpdateUser = gql`
mutation UpdateUser($id: Int!, $name: String!, $admin: Boolean!, $pass: String) {
updateUser(id: $id, name: $name, admin: $admin, pass: $pass) {
id
name
admin
}
}
`;
export const CreateUser = gql`
mutation CreateUser($name: String!, $admin: Boolean!, $pass: String!) {
createUser(name: $name, admin: $admin, pass: $pass) {
id
name
admin
}
}
`;

79
ui/src/gql/utils.ts Normal file
View File

@@ -0,0 +1,79 @@
import {DataProxy} from 'apollo-cache';
import {TimeSpans} from './__generated__/TimeSpans';
import * as gqlTimeSpan from './timeSpan';
import moment from 'moment';
import {AddTimeSpan_createTimeSpan} from './__generated__/AddTimeSpan';
import {TimeSpansInRange, TimeSpansInRangeVariables} from './__generated__/TimeSpansInRange';
import {Trackers} from './__generated__/Trackers';
import {StopTimer} from './__generated__/StopTimer';
export const addTimeSpanToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeSpan) => {
let oldTimeSpans: TimeSpans | null = null;
try {
oldTimeSpans = cache.readQuery<TimeSpans>({query: gqlTimeSpan.TimeSpans});
} catch {}
if (!oldTimeSpans) {
return;
}
cache.writeQuery<TimeSpans>({
query: gqlTimeSpan.TimeSpans,
data: {
timeSpans: {
__typename: 'PagedTimeSpans',
timeSpans: oldTimeSpans.timeSpans.timeSpans
.concat([ts])
.sort((a, b) => moment(b.start).unix() - moment(a.start).unix()),
cursor: oldTimeSpans.timeSpans.cursor,
},
},
});
};
export const addTimeSpanInRangeToCache = (cache: DataProxy, ts: AddTimeSpan_createTimeSpan, vars: TimeSpansInRangeVariables) => {
const oldTimeSpans = cache.readQuery<TimeSpansInRange>({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
if (!oldTimeSpans) {
return;
}
cache.writeQuery<TimeSpansInRange>({
query: gqlTimeSpan.TimeSpansInRange,
variables: vars,
data: {
timeSpans: {
__typename: 'PagedTimeSpans',
timeSpans: oldTimeSpans.timeSpans.timeSpans
.concat([ts])
.sort((a, b) => moment(b.start).unix() - moment(a.start).unix()),
cursor: oldTimeSpans.timeSpans.cursor,
},
},
});
};
export const removeFromTrackersCache = (cache: DataProxy, data: StopTimer) => {
const oldTrackers = cache.readQuery<Trackers>({query: gqlTimeSpan.Trackers});
if (!oldTrackers || !data || !data.stopTimeSpan) {
return;
}
cache.writeQuery<Trackers>({
query: gqlTimeSpan.Trackers,
data: {
timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== data.stopTimeSpan!.id),
},
});
};
export const removeFromTimeSpanInRangeCache = (cache: DataProxy, id: number, vars: TimeSpansInRangeVariables) => {
const old = cache.readQuery<TimeSpansInRange>({query: gqlTimeSpan.TimeSpansInRange, variables: vars});
if (!old) {
return;
}
cache.writeQuery<TimeSpansInRange>({
query: gqlTimeSpan.TimeSpansInRange,
variables: vars,
data: {
timeSpans: {
__typename: 'PagedTimeSpans',
timeSpans: old.timeSpans.timeSpans.filter((ts) => ts.id !== id),
cursor: old.timeSpans.cursor,
},
},
});
};

16
ui/src/gql/version.ts Normal file
View File

@@ -0,0 +1,16 @@
import {gql} from 'apollo-boost';
import {Version as VersionResponse} from './__generated__/Version';
export const Version = gql`
query Version {
version {
name
commit
buildDate
}
}
`;
export const VersionDefault: VersionResponse = {
version: {__typename: 'Version', commit: 'unknown', buildDate: 'unknown', name: 'vUnknown'},
};

8
ui/src/index.tsx Normal file
View File

@@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {Root} from './Root';
import 'moment/locale/de';
import 'moment/locale/en-au';
import 'moment/locale/en-gb';
ReactDOM.render(<Root />, document.getElementById('root'));

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
import * as gqlUser from '../gql/user';
import {Login, LoginVariables} from '../gql/__generated__/Login';
import {useMutation} from '@apollo/react-hooks';
import {Checkbox} from '@material-ui/core';
import {DeviceType} from '../gql/__generated__/globalTypes';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import makeStyles from '@material-ui/core/styles/makeStyles';
import {Settings as SettingsGQL} from '../gql/settings';
const useStyles = makeStyles((theme) => ({
button: {
marginTop: theme.spacing(1),
},
}));
export const LoginForm = () => {
const classes = useStyles();
const [login] = useMutation<Login, LoginVariables>(gqlUser.Login, {
update: (cache, {data}) => {
cache.writeQuery({query: gqlUser.CurrentUser, data: {user: data && data.login && data.login.user}});
},
refetchQueries: [{query: SettingsGQL}],
});
const {enqueueSnackbar} = useSnackbar();
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [remember, setRemember] = React.useState(false);
const submit = (e: React.FormEvent) => {
e.preventDefault();
login({
variables: {
name: username,
pass: password,
deviceType: remember ? DeviceType.LongExpiry : DeviceType.ShortExpiry,
},
})
.then(() => enqueueSnackbar('Login successful', {variant: 'success'}))
.catch(handleError('Login failed', enqueueSnackbar));
};
return (
<form noValidate autoComplete="off" onSubmit={submit}>
<TextField
required
margin="dense"
variant="outlined"
label="username"
autoFocus
fullWidth
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
required
type="password"
margin="dense"
variant="outlined"
label="password"
fullWidth
onChange={(e) => setPassword(e.target.value)}
/>
<FormControlLabel
style={{float: 'right'}}
control={<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} />}
label="Remember Me"
/>
<Button
type="submit"
className={classes.button}
fullWidth
size="large"
onClick={submit}
variant="contained"
color="primary">
Login
</Button>
</form>
);
};

View File

@@ -0,0 +1,52 @@
import * as React from 'react';
import Typography from '@material-ui/core/Typography';
import Grid from '@material-ui/core/Grid';
import {LoginForm} from './LoginForm';
import Link from '@material-ui/core/Link';
import {DefaultPaper} from '../common/DefaultPaper';
import * as gqlVersion from '../gql/version';
import {useQuery} from '@apollo/react-hooks';
import {Version} from '../gql/__generated__/Version';
import makeStyles from '@material-ui/core/styles/makeStyles';
const useStyles = makeStyles(() => ({
footerLink: {
margin: '0 2px',
},
}));
export const LoginPage = () => {
const classes = useStyles();
const {data: {version = gqlVersion.VersionDefault.version} = gqlVersion.VersionDefault} = useQuery<Version>(
gqlVersion.Version
);
return (
<Grid container={true} direction="row" alignItems="center" justify="center" style={{height: '95%'}}>
<Grid item>
<DefaultPaper>
<Typography variant="h1" component="h1" gutterBottom={true}>
traggo
</Typography>
<LoginForm />
</DefaultPaper>
</Grid>
<div style={{position: 'absolute', bottom: 10, display: 'flex'}}>
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
<Link href="https://github.com/traggo/server">Source Code</Link>
</Typography>
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
</Typography>
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
<Link href="https://github.com/traggo/server/issues">Bug Tracker</Link>
</Typography>
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
|
</Typography>
<Typography variant="subtitle1" component="span" className={classes.footerLink}>
{version.name}@{version.commit.slice(0, 8)}
</Typography>
</div>
</Grid>
);
};

View File

@@ -0,0 +1,16 @@
import ApolloClient from 'apollo-boost';
import * as React from 'react';
import {ApolloProvider as Provider} from 'react-apollo';
import {ApolloProvider as ApolloProviderHooks} from '@apollo/react-hooks';
const client = new ApolloClient({
uri: './graphql',
});
export const ApolloProvider: React.FC = ({children}) => {
return (
<Provider client={client}>
<ApolloProviderHooks client={client}>{children}</ApolloProviderHooks>
</Provider>
);
};

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import {SnackbarProvider as Provider} from 'notistack';
import {makeStyles} from '@material-ui/core/styles';
const useStyles = makeStyles(() => ({
error: {
background: '#E53935',
color: '#fff',
},
warning: {
background: '#d35400',
color: '#fff',
},
info: {
background: '#2980b9',
color: '#fff',
},
}));
export const SnackbarProvider: React.FC = ({children}) => {
const classes = useStyles();
return (
<Provider
maxSnack={3}
classes={{variantError: classes.error, variantWarning: classes.warning, variantInfo: classes.info}}
autoHideDuration={3500}>
{children}
</Provider>
);
};

View File

@@ -0,0 +1,141 @@
import * as React from 'react';
import {MuiThemeProvider, Theme} from '@material-ui/core/styles';
import createMuiTheme from '@material-ui/core/styles/createMuiTheme';
import CssBaseline from '@material-ui/core/CssBaseline';
import {useSettings} from '../gql/settings';
import {Theme as SettingTheme} from '../gql/__generated__/globalTypes';
const themes: Record<SettingTheme, Theme> = {
[SettingTheme.GruvboxDark]: createMuiTheme({
overrides: {
MuiLink: {
root: {
color: '#458588',
},
},
MuiIconButton: {
root: {
color: 'inherit',
},
},
MuiListItemIcon: {
root: {
color: 'inherit',
},
},
MuiToolbar: {
root: {
background: '#a89984',
},
},
},
palette: {
background: {
default: '#282828',
paper: '#32302f',
},
text: {
primary: '#fbf1d4',
},
primary: {
main: '#a89984',
},
secondary: {
main: '#f44336',
},
type: 'dark',
},
}),
[SettingTheme.GruvboxLight]: createMuiTheme({
overrides: {
MuiLink: {
root: {
color: '#458588',
},
},
MuiIconButton: {
root: {
color: 'inherit',
},
},
MuiListItemIcon: {
root: {
color: 'inherit',
},
},
MuiToolbar: {
root: {
background: '#7c6f64',
},
},
},
palette: {
background: {
default: '#fbf1c7',
paper: '#f9f5d7',
},
text: {
primary: '#282828',
},
primary: {
main: '#7c6f64',
},
secondary: {
main: '#f44336',
},
type: 'light',
},
}),
[SettingTheme.MaterialLight]: createMuiTheme({
overrides: {
MuiLink: {
root: {
color: '#2980b9',
},
},
MuiIconButton: {
root: {
color: 'inherit',
},
},
},
palette: {
background: {default: '#eeeeee'},
primary: {
main: '#455a64',
},
secondary: {
main: '#f44336',
},
type: 'light',
},
}),
[SettingTheme.MaterialDark]: createMuiTheme({
overrides: {
MuiLink: {
root: {
color: '#3498db',
},
},
},
palette: {
primary: {
main: '#455a64',
},
secondary: {
main: '#f44336',
},
type: 'dark',
},
}),
};
export const ThemeProvider: React.FC = ({children}) => {
const {theme} = useSettings();
return (
<MuiThemeProvider theme={themes[theme] || themes[SettingTheme.GruvboxDark]}>
<CssBaseline />
{children}
</MuiThemeProvider>
);
};

View File

@@ -0,0 +1,82 @@
import * as React from 'react';
import {useSettings} from '../gql/settings';
import {CenteredSpinner} from '../common/CenteredSpinner';
import moment, {LocaleSpecification} from 'moment';
import {DateLocale, WeekDay} from '../gql/__generated__/globalTypes';
import {expectNever} from '../utils/never';
const setLocale = (locale: DateLocale, spec: LocaleSpecification) => {
switch (locale) {
case DateLocale.American:
moment.locale('en', spec);
return;
case DateLocale.American24h:
moment.locale('en', {
...spec,
longDateFormat: {
LTS: 'HH:mm:ss',
LT: 'HH:mm',
L: 'MM/DD/YYYY',
LL: 'MMMM D, YYYY',
LLL: 'MMMM D, YYYY HH:mm',
LLLL: 'dddd, MMMM D, YYYY HH:mm',
},
});
return;
case DateLocale.Australian:
moment.locale('en-au', spec);
return;
case DateLocale.British:
moment.locale('en-gb', spec);
return;
case DateLocale.German:
moment.locale('de', spec);
return;
default:
expectNever(locale);
return;
}
};
const weekDayToMoment = (s: WeekDay): number => {
switch (s) {
case WeekDay.Sunday:
return 0;
case WeekDay.Monday:
return 1;
case WeekDay.Tuesday:
return 2;
case WeekDay.Wednesday:
return 3;
case WeekDay.Thursday:
return 4;
case WeekDay.Friday:
return 5;
case WeekDay.Saturday:
return 6;
default:
return expectNever(s);
}
};
export const BootUserSettings: React.FC = ({children}): React.ReactElement => {
const {done, firstDayOfTheWeek, dateLocale} = useSettings();
React.useEffect(() => {
if (!done) {
return;
}
setLocale(dateLocale, {
week: {
dow: weekDayToMoment(firstDayOfTheWeek),
doy: moment.localeData(moment.locale()).firstDayOfYear(),
},
});
}, [dateLocale, firstDayOfTheWeek, done]);
if (!done) {
return <CenteredSpinner />;
}
return <>{children}</>;
};

1
ui/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,153 @@
import * as React from 'react';
import makeStyles from '@material-ui/core/styles/makeStyles';
import {Paper} from '@material-ui/core';
import {SetSettings as SetSettingsGQL, Settings as SettingsGQL, useSettings} from '../gql/settings';
import {useMutation} from '@apollo/react-hooks';
import {SetSettings, SetSettingsVariables} from '../gql/__generated__/SetSettings';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import Select from '@material-ui/core/NativeSelect/NativeSelect';
import {DateLocale, Theme, WeekDay, DateTimeInputStyle} from '../gql/__generated__/globalTypes';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
const useStyles = makeStyles((theme) => ({
root: {
...theme.mixins.gutters(),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(3),
maxWidth: 500,
margin: '0 auto',
},
}));
export const SettingsPage: React.FC = () => {
const classes = useStyles();
const {done, ...settings} = useSettings();
const {enqueueSnackbar} = useSnackbar();
const [setSettings] = useMutation<SetSettings, SetSettingsVariables>(SetSettingsGQL, {
refetchQueries: [{query: SettingsGQL}],
});
return (
<Paper elevation={1} className={classes.root}>
<FormControl margin={'normal'} fullWidth>
<InputLabel>Date Locale</InputLabel>
<Select
fullWidth
value={settings.dateLocale}
onChange={(e) => {
setSettings({
variables: {
settings: {
...settings,
dateLocale: e.target.value as DateLocale,
},
},
})
.then(() => {
enqueueSnackbar('date locale changed', {
variant: 'success',
});
enqueueSnackbar('a reload of the page is required for the new date locale to fully function', {
variant: 'info',
preventDuplicate: true,
persist: true,
});
})
.catch(handleError('set date locale', enqueueSnackbar));
}}>
{Object.values(DateLocale).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</FormControl>
<FormControl margin={'normal'} fullWidth>
<InputLabel>Theme</InputLabel>
<Select
fullWidth
value={settings.theme}
onChange={(e) => {
setSettings({variables: {settings: {...settings, theme: e.target.value as Theme}}})
.then(() =>
enqueueSnackbar('theme changed', {
variant: 'success',
})
)
.catch(handleError('set theme', enqueueSnackbar));
}}>
{Object.values(Theme).map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</FormControl>
<FormControl margin={'normal'} fullWidth>
<InputLabel>First day of the week</InputLabel>
<Select
fullWidth
value={settings.firstDayOfTheWeek}
onChange={(e) => {
setSettings({
variables: {
settings: {
...settings,
firstDayOfTheWeek: e.target.value as WeekDay,
},
},
})
.then(() =>
enqueueSnackbar('first day of the week changed', {
variant: 'success',
})
)
.catch(handleError('set first day of the week', enqueueSnackbar));
}}>
{[
WeekDay.Sunday,
WeekDay.Monday,
WeekDay.Tuesday,
WeekDay.Wednesday,
WeekDay.Thursday,
WeekDay.Friday,
WeekDay.Saturday,
].map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</FormControl>
<FormControl margin={'normal'} fullWidth>
<InputLabel>Datetime input style</InputLabel>
<Select
fullWidth
value={settings.dateTimeInputStyle}
onChange={(e) => {
setSettings({
variables: {
settings: {
...settings,
dateTimeInputStyle: e.target.value as DateTimeInputStyle,
},
},
})
.then(() =>
enqueueSnackbar('datetime input style changed', {
variant: 'success',
})
)
.catch(handleError('set datetime input style', enqueueSnackbar));
}}>
{[DateTimeInputStyle.Fancy, DateTimeInputStyle.Native].map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</FormControl>
</Paper>
);
};

View File

@@ -0,0 +1,81 @@
import * as React from 'react';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
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 {SliderPicker} from 'react-color';
import {InputLabel} from '@material-ui/core';
import FormControl from '@material-ui/core/FormControl';
import {MutationFetchResult} from 'react-apollo';
import {AddTag, AddTagVariables} from '../gql/__generated__/AddTag';
import * as gqlTags from '../gql/tags';
import {TagSelectorEntry} from './tagSelectorEntry';
import {useMutation} from '@apollo/react-hooks';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
interface AddTagDialogProps {
initialName: string;
open: boolean;
close: () => void;
onAdded?: (tag: TagSelectorEntry['tag']) => void;
}
export const AddTagDialog: React.FC<AddTagDialogProps> = ({close, open, initialName, onAdded = () => {}}) => {
const [name, setName] = React.useState(initialName);
const [color, setColor] = React.useState('#e6b3b3');
const {enqueueSnackbar} = useSnackbar();
const [addTag] = useMutation<AddTag, AddTagVariables>(gqlTags.AddTag, {refetchQueries: [{query: gqlTags.Tags}]});
const submit = (e: React.FormEvent) => {
e.preventDefault();
addTag({variables: {name, color}})
.then((result: MutationFetchResult<AddTag> | void) => {
close();
if (result && result.data && result.data.createTag) {
onAdded(result.data.createTag);
}
})
.catch(handleError('Add Tag', enqueueSnackbar));
};
return (
<Dialog open={open} onClose={close} aria-labelledby="form-dialog-title" fullWidth>
<form onSubmit={submit} noValidate autoComplete="off">
<DialogTitle id="form-dialog-title">Create Tag</DialogTitle>
<DialogContent>
<DialogContentText />
<TextField
autoFocus
margin="dense"
id="name"
label="Name"
type="text"
fullWidth
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FormControl fullWidth margin="dense">
<InputLabel htmlFor="color-picker" shrink={true}>
Color
</InputLabel>
<div id="color-picker" style={{marginTop: 25}}>
<SliderPicker onChange={(c) => setColor(c.hex)} color={color} />
</div>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button type="submit" onClick={submit} color="primary">
Create Tag
</Button>
</DialogActions>
</form>
</Dialog>
);
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import FormControl from '@material-ui/core/FormControl';
import Box from '@material-ui/core/Box';
import InputLabel from '@material-ui/core/InputLabel';
import {TagSelector, TagSelectorProps} from './TagSelector';
interface FormTagSelectorProps extends TagSelectorProps {
label: string;
required?: boolean;
}
export const FormTagSelector = ({label, required = false, ...props}: FormTagSelectorProps) => {
return (
<Box mt={1}>
<FormControl fullWidth required>
<Box pt={2}>
<InputLabel shrink> {label} </InputLabel>
<Box className="MuiInput-underline">
<TagSelector {...props} />
</Box>
</Box>
</FormControl>
</Box>
);
};

161
ui/src/tag/TagPage.tsx Normal file
View File

@@ -0,0 +1,161 @@
import * as React from 'react';
import Paper from '@material-ui/core/Paper';
import {makeStyles} from '@material-ui/core/styles';
import {useMutation, useQuery} from '@apollo/react-hooks';
import * as gqlTag from '../gql/tags';
import * as gqlDashboard from '../gql/dashboard';
import {CenteredSpinner} from '../common/CenteredSpinner';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import DoneIcon from '@material-ui/icons/Done';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import {useSnackbar} from 'notistack';
import {TextField} from '@material-ui/core';
import Button from '@material-ui/core/Button';
import {Tags} from '../gql/__generated__/Tags';
import {RemoveTag, RemoveTagVariables} from '../gql/__generated__/RemoveTag';
import {UpdateTag, UpdateTagVariables} from '../gql/__generated__/UpdateTag';
import {AddTagDialog} from './AddTagDialog';
import {SliderPicker} from 'react-color';
import {TagChip} from '../common/TagChip';
import {handleError} from '../utils/errors';
import {ConfirmDialog} from '../common/ConfirmDialog';
const useStyles = makeStyles((theme) => ({
root: {
...theme.mixins.gutters(),
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
maxWidth: 1200,
margin: '0 auto',
},
}));
export const TagPage = () => {
const classes = useStyles();
const {data, loading} = useQuery<Tags>(gqlTag.Tags);
const [removeTagConfirm, setRemoveTagConfirm] = React.useState('');
const refetch = {refetchQueries: [{query: gqlTag.Tags}, {query: gqlDashboard.Dashboards}]};
const {enqueueSnackbar} = useSnackbar();
const [removeTag] = useMutation<RemoveTag, RemoveTagVariables>(gqlTag.RemoveTag, refetch);
const [[editKey, editKeyNew, editColor], setEditing] = React.useState<[string, string, string]>(['', '', '']);
const [addActive, setAddActive] = React.useState(false);
const [updateTag] = useMutation<UpdateTag, UpdateTagVariables>(gqlTag.UpdateTag, refetch);
if (loading || !data || !data.tags) {
return <CenteredSpinner />;
}
const onClickDelete = () => {
return removeTag({variables: {key: removeTagConfirm}})
.then(() => enqueueSnackbar('tag deleted', {variant: 'success'}))
.catch(handleError('Delete Tag', enqueueSnackbar));
};
const tags = data.tags.map((tag) => {
const onClickSubmit = () => {
setEditing(['', '', '']);
updateTag({
variables: {
key: editKey,
newKey: editKeyNew,
color: editColor,
},
})
.then(() => enqueueSnackbar('tag edited', {variant: 'success'}))
.catch(handleError('Edit Tag', enqueueSnackbar));
};
const isEdited = editKey === tag.key;
return (
<TableRow key={tag.key}>
<TableCell>
{isEdited ? (
<TextField
value={editKeyNew}
onChange={(e) => setEditing([editKey, e.target.value, editColor])}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickSubmit();
}
}}
onSubmit={onClickSubmit}
style={{minWidth: 128}}
/>
) : (
tag.key
)}
</TableCell>
<TableCell style={{minWidth: 128}}>
{isEdited ? (
<SliderPicker onChange={(c) => setEditing([editKey, editKeyNew, c.hex])} color={editColor} />
) : (
<TagChip label={tag.color} color={tag.color} />
)}
</TableCell>
<TableCell align="right">{tag.usages}</TableCell>
<TableCell align="right">
{isEdited ? (
<>
<IconButton onClick={onClickSubmit} title="Save">
<DoneIcon />
</IconButton>
<IconButton onClick={() => setEditing(['', '', ''])} title="Cancel">
<CloseIcon />
</IconButton>
</>
) : (
<>
<IconButton onClick={() => setEditing([tag.key, tag.key, tag.color])} title="Edit">
<EditIcon />
</IconButton>
<IconButton onClick={() => setRemoveTagConfirm(tag.key)} title="Delete">
<DeleteIcon />
</IconButton>
</>
)}
</TableCell>
</TableRow>
);
});
return (
<Paper elevation={1} square={true} className={classes.root}>
<Button
color={'primary'}
variant={'outlined'}
size="small"
onClick={() => setAddActive(true)}
fullWidth
style={{marginBottom: 10}}>
Create Tag
</Button>
<Table>
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Color</TableCell>
<TableCell style={{width: 100}}>Usages</TableCell>
<TableCell style={{width: 150}} />
</TableRow>
</TableHead>
<TableBody>
{addActive ? <AddTagDialog initialName={''} open={true} close={() => setAddActive(false)} /> : null}
{tags}
{removeTagConfirm ? (
<ConfirmDialog
title={`Delete Tag ${removeTagConfirm}`}
fClose={() => setRemoveTagConfirm('')}
fOnSubmit={onClickDelete}>
<b>This operation cannot be undone.</b> Deleting the tag will remove all references in time spans.
</ConfirmDialog>
) : null}
</TableBody>
</Table>
</Paper>
);
};

279
ui/src/tag/TagSelector.tsx Normal file
View File

@@ -0,0 +1,279 @@
import * as React from 'react';
import {Tags} from '../gql/__generated__/Tags';
import * as gqlTags from '../gql/tags';
import {useQuery} from '@apollo/react-hooks';
import {useError} from '../utils/errors';
import Typography from '@material-ui/core/Typography';
import Tooltip from '@material-ui/core/Tooltip';
import MenuItem from '@material-ui/core/MenuItem';
import {AddTagDialog} from './AddTagDialog';
import {TagSelectorEntry, itemLabel, label, addValues} from './tagSelectorEntry';
import {useSuggest} from './suggest';
import Paper from '@material-ui/core/Paper';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import Input from '@material-ui/core/Input';
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
import {TagChip} from '../common/TagChip';
import {makeStyles, Theme} from '@material-ui/core/styles';
const useStyles = makeStyles((theme: Theme) => ({
root: {
width: '100%',
},
inputRoot: {display: 'flex', flexWrap: 'wrap', cursor: 'text', width: '100%'},
inputInput: {height: 40, minWidth: 150, flexGrow: 1},
paper: {
position: 'absolute',
zIndex: 1,
marginTop: theme.spacing(1),
},
}));
export interface TagSelectorProps {
onSelectedEntriesChanged: (entries: TagSelectorEntry[]) => void;
selectedEntries: TagSelectorEntry[];
dialogOpen?: React.Dispatch<React.SetStateAction<boolean>>;
onCtrlEnter?: () => void;
createTags?: boolean;
allowDuplicateKeys?: boolean;
onlySelectKeys?: boolean;
}
export const TagSelector: React.FC<TagSelectorProps> = ({
selectedEntries,
onSelectedEntriesChanged: setSelectedEntries,
dialogOpen = () => {},
onCtrlEnter,
createTags = true,
allowDuplicateKeys = false,
onlySelectKeys = false,
}) => {
const classes = useStyles();
const [tooltipErrorActive, tooltipError, showTooltipError] = useError(4000);
const [open, setOpen] = React.useState(false);
const [currentValue, setCurrentValueInternal] = React.useState('');
const [highlightedIndex, setHighlightedIndex] = React.useState<number>(0);
const [addDialogOpen, setAddDialogOpen] = useStateAndDelegateWithDelayOnChange<boolean>(false, dialogOpen);
const input = React.useRef<null | HTMLDivElement>(null);
const container = React.useRef<null | HTMLDivElement>(null);
const tagsResult = useQuery<Tags>(gqlTags.Tags);
const suggestions = useSuggest(tagsResult, currentValue, selectedEntries, onlySelectKeys, allowDuplicateKeys, createTags);
if (tagsResult.error || tagsResult.loading || !tagsResult.data || !tagsResult.data.tags) {
return null;
}
const setCurrentValue = (newValue: string) => {
if (currentValue.indexOf(' ') !== -1) {
throw new Error('old value should never contain a space');
}
setHighlightedIndex(0);
if (newValue.indexOf(' ') !== -1) {
const {errors, entries} = addValues(newValue, tagsResult, selectedEntries, onlySelectKeys, allowDuplicateKeys);
setSelectedEntries([...selectedEntries, ...entries]);
if (errors.length > 0) {
if (errors.length === 1) {
showTooltipError(errors[0].error);
} else {
showTooltipError('skipped tags because:\n' + errors.map((e) => e.error).join('\n'));
}
setCurrentValueInternal(errors[errors.length - 1].value);
} else {
setCurrentValueInternal('');
}
return;
}
setCurrentValueInternal(newValue);
};
const focusInput = () => {
if (input.current) {
input.current.focus();
}
};
const trySubmit = (entry: TagSelectorEntry) => {
if (entry.tag.alreadyUsed) {
return;
}
if (entry.tag.create) {
setAddDialogOpen(true);
return;
}
focusInput();
if (!onlySelectKeys && !entry.value) {
const newValue = entry.tag.key + ':';
if (currentValue !== newValue) {
setHighlightedIndex(0);
setCurrentValue(newValue);
} else {
showTooltipError('enter a value after the colon');
}
return;
}
setSelectedEntries([...selectedEntries, entry]);
setCurrentValue('');
return;
};
const onTagClicked = (entry: TagSelectorEntry, edit: boolean) => {
// Prevent overwriting text that's already being edited
if (currentValue && edit) {
showTooltipError('Input is not empty. Use ctrl+click to delete tag without editing.');
return;
}
setSelectedEntries(selectedEntries.filter((selected) => selected !== entry));
if (edit) {
setCurrentValueInternal(itemLabel(entry, onlySelectKeys));
setOpen(true);
}
focusInput();
};
const onKeyDown = (event: React.KeyboardEvent) => {
if (!currentValue && selectedEntries.length && event.key === 'Backspace') {
event.preventDefault();
const last = selectedEntries[selectedEntries.length - 1];
setSelectedEntries(selectedEntries.slice(0, selectedEntries.length - 1));
setCurrentValue(event.ctrlKey ? '' : itemLabel(last, onlySelectKeys));
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex(highlightedIndex === 0 ? suggestions.length - 1 : highlightedIndex - 1);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex(highlightedIndex === suggestions.length - 1 ? 0 : highlightedIndex + 1);
}
if (event.key === 'Enter' && highlightedIndex < suggestions.length) {
event.preventDefault();
if (!currentValue && event.ctrlKey && onCtrlEnter) {
onCtrlEnter();
} else {
trySubmit(suggestions[highlightedIndex]);
}
}
if (event.key === 'Escape' && input.current) {
event.preventDefault();
setOpen(false);
}
if (event.key === 'Tab') {
setOpen(false);
}
};
return (
<ClickAwayListener onClickAway={() => setOpen(false)}>
<div className={classes.root}>
<Tooltip
disableFocusListener
disableHoverListener
disableTouchListener
open={tooltipErrorActive}
PopperProps={{style: {zIndex: 100000}}}
placement={'top'}
title={
<Typography color="inherit" style={{whiteSpace: 'pre-line'}}>
{tooltipError}
</Typography>
}>
<div ref={(ref) => (container.current = ref)} className={classes.inputRoot} onClick={focusInput}>
{toChips(selectedEntries, onlySelectKeys, onTagClicked)}
<Input
margin="none"
value={currentValue}
inputRef={(ref) => (input.current = ref)}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
disableUnderline={true}
onChange={(e) => setCurrentValue(e.target.value)}
placeholder="Enter Tags"
className={classes.inputInput}
/>
</div>
</Tooltip>
{open ? (
<Paper
className={classes.paper}
style={{width: (container.current && container.current.clientWidth) || 300}}
square>
{suggestions.map((entry, index) => (
<Item
key={label(entry)}
entry={entry}
onClick={trySubmit}
selected={index === highlightedIndex}
onlySelectKeys={onlySelectKeys}
/>
))}
</Paper>
) : null}
{addDialogOpen && (
<AddTagDialog
onAdded={(tag) => {
const valuePart = currentValue.split(':')[1] || '';
trySubmit({tag, value: valuePart});
}}
open={true}
initialName={currentValue.split(':')[0]}
close={() => {
setTimeout(focusInput, 50);
setAddDialogOpen(false);
}}
/>
)}
</div>
</ClickAwayListener>
);
};
interface ItemProps {
entry: TagSelectorEntry;
selected: boolean;
onlySelectKeys: boolean;
onClick: (entry: TagSelectorEntry) => void;
}
const Item: React.FC<ItemProps> = ({entry, selected, onlySelectKeys, onClick}) => {
return (
<MenuItem
key={entry.tag.key}
selected={selected}
component="a"
onClick={() => onClick(entry)}
disabled={entry.tag.alreadyUsed}
style={{
fontWeight: selected ? 500 : 400,
}}>
{itemLabel(entry, onlySelectKeys)}
</MenuItem>
);
};
const toChips = (
entries: TagSelectorEntry[],
onlySelectKeys: boolean,
onClick: (entry: TagSelectorEntry, edit: boolean) => void
) => {
return entries.map((entry) => (
<TagChip
key={itemLabel(entry, onlySelectKeys)}
label={itemLabel(entry, onlySelectKeys)}
color={entry.tag.color}
onClick={(e) => onClick(entry, !e.ctrlKey)}
/>
));
};

90
ui/src/tag/suggest.ts Normal file
View File

@@ -0,0 +1,90 @@
import {Tags} from '../gql/__generated__/Tags';
import * as gqlTags from '../gql/tags';
import {useQuery} from '@apollo/react-hooks';
import {SuggestTagValue, SuggestTagValueVariables} from '../gql/__generated__/SuggestTagValue';
import {TagSelectorEntry, specialTag} from './tagSelectorEntry';
import {QueryResult} from 'react-apollo';
export const useSuggest = (
tagResult: QueryResult<Tags, {}>,
inputValue: string,
usedTags: TagSelectorEntry[],
skipValue = false,
allowDuplicateKeys = false,
createTags = true
): TagSelectorEntry[] => {
const [tagKeySomeCase, tagValue] = inputValue.split(':');
const tagKey = tagKeySomeCase.toLowerCase();
const exactMatch = ((tagResult.data && tagResult.data.tags) || []).find((tag) => tag.key === tagKey);
const valueResult = useQuery<SuggestTagValue, SuggestTagValueVariables>(gqlTags.SuggestTagValue, {
variables: {tag: tagKey, query: tagValue},
skip: exactMatch === undefined || skipValue,
fetchPolicy: 'no-cache',
});
const usedKeys = usedTags.map((t) => t.tag.key);
if (exactMatch && tagValue !== undefined && !skipValue && (allowDuplicateKeys || usedKeys.indexOf(exactMatch.key) === -1)) {
const usedValues = usedTags.filter((t) => t.tag.key === tagKey).map((t) => t.value);
return suggestTagValue(exactMatch, tagValue, valueResult, usedValues, createTags);
} else {
return suggestTag(exactMatch, tagResult, tagKey, usedKeys, allowDuplicateKeys, createTags);
}
};
const suggestTag = (
exactMatch: TagSelectorEntry['tag'] | undefined,
tagResult: QueryResult<Tags, {}>,
tagKey: string,
usedTags: string[],
allowDuplicateKeys: boolean,
createTags: boolean
) => {
if (!tagResult.data || tagResult.data.tags === null) {
return [];
}
let availableTags = (tagResult.data.tags || [])
.filter((tag) => allowDuplicateKeys || usedTags.indexOf(tag.key) === -1)
.filter((tag) => tag.key.indexOf(tagKey) === 0);
if (tagKey && !exactMatch && createTags) {
availableTags = [specialTag(tagKey, 'new'), ...availableTags];
}
if (usedTags.indexOf(tagKey) !== -1 && !allowDuplicateKeys) {
availableTags = [specialTag(tagKey, 'used'), ...availableTags];
}
return availableTags
.sort((a, b) => b.usages - a.usages)
.slice(0, 5)
.map((tag) => ({tag, value: ''}));
};
const suggestTagValue = (
exactMatch: TagSelectorEntry['tag'],
tagValue: string,
valueResult: QueryResult<SuggestTagValue, SuggestTagValueVariables>,
usedValues: string[],
includeInputValueOnNoMatch: boolean
): TagSelectorEntry[] => {
let someValues = (valueResult.data && valueResult.data.values) || [];
if (includeInputValueOnNoMatch && someValues.indexOf(tagValue) === -1) {
someValues = [tagValue, ...someValues];
}
if (someValues.length === 0 && !includeInputValueOnNoMatch) {
return [{tag: specialTag(exactMatch.key, 'no_values'), value: tagValue}];
}
someValues = someValues.filter((val) => usedValues.indexOf(val) === -1);
if (someValues.length === 0 && !includeInputValueOnNoMatch) {
return [{tag: specialTag(exactMatch.key, 'all_values_used'), value: ''}];
}
return someValues.map((val) => ({tag: exactMatch, value: val}));
};

View File

@@ -0,0 +1,141 @@
import {Tags, Tags_tags} from '../gql/__generated__/Tags';
import {QueryResult} from 'react-apollo';
export interface TagInputError {
error: string;
value: string;
}
type SpecialTagState = 'used' | 'new' | 'no_values' | 'all_values_used';
export interface SpecialTag {
create?: boolean;
alreadyUsed?: boolean;
noValues?: boolean;
allValuesUsed?: boolean;
}
export interface TagSelectorEntry {
tag: Omit<Tags_tags, 'usages'> & SpecialTag;
value: string;
}
export interface InputTag {
key: string;
value: string;
}
export const toInputTags = (entries: TagSelectorEntry[]): InputTag[] => {
return entries.map((entry) => ({key: entry.tag.key, value: entry.value}));
};
export const toTagSelectorEntry = (tags: Array<TagSelectorEntry['tag']>, entries: InputTag[]): TagSelectorEntry[] => {
return entries.map(
(timerTag): TagSelectorEntry => {
const definition = tags.find((tag) => tag.key === timerTag.key) || specialTag(timerTag.key, 'new');
return {
tag: definition,
value: timerTag.value,
};
}
);
};
export const specialTag = (name: string, state: SpecialTagState): TagSelectorEntry['tag'] & {usages: 0} => {
return {
key: name,
__typename: 'TagDefinition',
color: 'gray',
create: state === 'new',
alreadyUsed: state === 'used',
noValues: state === 'no_values',
allValuesUsed: state === 'all_values_used',
usages: 0,
};
};
const tryAdd = (tagsResult: QueryResult<Tags, {}>, entry: string, onlyKeys: boolean): TagSelectorEntry | TagInputError => {
const [keySomeCase, value, ...other] = entry.split(':');
const key = keySomeCase.toLowerCase();
if (other.length || !tagsResult.data || !tagsResult.data.tags) {
return {error: `${entry} has too many colons`, value: entry};
}
const foundTag = tagsResult.data.tags.find((tag) => tag.key === key);
if (foundTag === undefined) {
return {error: `'${key}' does not exist`, value: entry};
}
if (onlyKeys && value) {
return {error: `'${key}' has a value, but this field doesn't allow them`, value: entry};
}
if (!onlyKeys && !value) {
return {error: `'${key}' requires a value`, value: entry};
}
return {tag: foundTag, value: value || ''};
};
interface EntriesAndErrors {
errors: TagInputError[];
entries: TagSelectorEntry[];
usedTags: string[];
}
const groupAndCheckExistence = (onlyKeys: boolean, allowDuplicateKeys: boolean) => {
return (a: EntriesAndErrors, entry: TagInputError | TagSelectorEntry) => {
if ('tag' in entry) {
if (allowDuplicateKeys || a.usedTags.indexOf(entry.tag.key) === -1) {
a.entries = [...a.entries, entry];
a.usedTags = [...a.usedTags, entry.tag.key];
} else {
a.errors = [...a.errors, {value: itemLabel(entry, onlyKeys), error: `'${entry.tag.key}' is already defined`}];
}
} else {
a.errors = [...a.errors, entry];
}
return a;
};
};
export const addValues = (
newValue: string,
tagsResult: QueryResult<Tags, {}>,
selectedEntries: TagSelectorEntry[],
onlyKeys: boolean,
allowDuplicateKeys: boolean
): EntriesAndErrors => {
return newValue
.split(/\s+/)
.filter((entry) => entry)
.map((entry) => tryAdd(tagsResult, entry, onlyKeys))
.reduce(groupAndCheckExistence(onlyKeys, allowDuplicateKeys), {
errors: [],
entries: [],
usedTags: selectedEntries.map((entry) => entry.tag.key),
});
};
export const itemLabel = (tag: TagSelectorEntry, onlyShowKey = false) => {
if (tag.tag.create) {
return `Create tag '${tag.tag.key}'`;
}
if (tag.tag.alreadyUsed) {
return `Tag '${tag.tag.key}' is already defined`;
}
if (tag.tag.noValues) {
return `Unkown value '${tag.value}' of tag '${tag.tag.key}'`;
}
if (tag.tag.allValuesUsed) {
return `All values of tag '${tag.tag.key}' are used`;
}
if (onlyShowKey) {
return tag.tag.key;
}
return label(tag);
};
export const label = (tag: TagSelectorEntry) => {
return `${tag.tag.key}:${tag.value}`;
};

View File

@@ -0,0 +1,44 @@
import * as React from 'react';
import {useQuery} from '@apollo/react-hooks';
import * as gqlTimeSpan from '../gql/timeSpan';
import * as gqlTag from '../gql/tags';
import {Trackers} from '../gql/__generated__/Trackers';
import {Tags} from '../gql/__generated__/Tags';
import {TimeSpan} from './TimeSpan';
import {toTimeSpanProps} from './timespanutils';
import {Typography} from '@material-ui/core';
export const ActiveTrackers = () => {
const trackersResult = useQuery<Trackers>(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'});
const tagsResult = useQuery<Tags>(gqlTag.Tags, {fetchPolicy: 'cache-and-network'});
const values = React.useMemo(() => {
if (
trackersResult.error ||
trackersResult.loading ||
!trackersResult.data ||
trackersResult.data.timers === null ||
tagsResult.error ||
tagsResult.loading ||
!tagsResult.data ||
tagsResult.data.tags === null
) {
return [];
}
return toTimeSpanProps(trackersResult.data.timers, tagsResult.data.tags);
}, [tagsResult, trackersResult]);
if (!values.length) {
return null;
}
return (
<>
<Typography align="center" variant="h5" style={{marginTop: 10}}>
Active Timers
</Typography>
{values.map((value) => {
return <TimeSpan key={value.id} {...value} />;
})}
</>
);
};

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import {Tracker} from './Tracker';
import {ActiveTrackers} from './ActiveTrackers';
import {DoneTrackers} from './DoneTrackers';
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
import {RefreshTimeSpans} from './RefreshTimespans';
export const DailyPage = () => {
const [selectedEntries, setSelectedEntries] = React.useState<TagSelectorEntry[]>([]);
return (
<div style={{margin: '1px auto', maxWidth: 1000}}>
<Tracker selectedEntries={selectedEntries} onSelectedEntriesChanged={setSelectedEntries} />
<ActiveTrackers />
<DoneTrackers
addTagsToTracker={
selectedEntries.length === 0 ? (entries) => setSelectedEntries(selectedEntries.concat(entries)) : undefined
}
/>
<RefreshTimeSpans />
</div>
);
};

View File

@@ -0,0 +1,149 @@
import * as React from 'react';
import {useQuery} from '@apollo/react-hooks';
import * as gqlTimeSpan from '../gql/timeSpan';
import * as gqlTag from '../gql/tags';
import {TimeSpan, TimeSpanProps} from './TimeSpan';
import {Tags} from '../gql/__generated__/Tags';
import useInterval from '@rooks/use-interval';
import moment from 'moment';
import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans';
import {Typography} from '@material-ui/core';
import {GroupedTimeSpanProps, toGroupedTimeSpanProps} from './timespanutils';
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
import ReactInfinite from 'react-infinite';
import {isSameDate} from '../utils/time';
interface DoneTrackersProps {
addTagsToTracker?: (entries: TagSelectorEntry[]) => void;
}
export const DoneTrackers: React.FC<DoneTrackersProps> = ({addTagsToTracker}) => {
const trackersResult = useQuery<TimeSpans, TimeSpansVariables>(gqlTimeSpan.TimeSpans, {
variables: {cursor: {pageSize: 30}},
});
const loading = React.useRef(false);
const tagsResult = useQuery<Tags>(gqlTag.Tags);
const [infiniteLoading, setInfiniteLoading] = React.useState(false);
const [currentDate, setCurrentDate] = React.useState(moment());
const [heights, setHeights] = React.useState<Record<string, number>>({});
useInterval(
() => {
if (!isSameDate(currentDate, moment())) {
setCurrentDate(moment());
}
},
1000,
true
);
const fetchMore = () => {
if (!trackersResult || !trackersResult.data || trackersResult.loading || loading.current) {
return;
}
loading.current = true;
const {offset, pageSize, startId} = trackersResult.data.timeSpans.cursor;
trackersResult
.fetchMore({
variables: {
cursor: {
startId,
offset,
pageSize,
},
},
updateQuery: (prev, {fetchMoreResult}): TimeSpans => {
if (!fetchMoreResult) {
return prev;
}
return {
timeSpans: {
__typename: 'PagedTimeSpans',
timeSpans: [...prev.timeSpans.timeSpans, ...fetchMoreResult.timeSpans.timeSpans],
cursor: fetchMoreResult.timeSpans.cursor,
},
};
},
})
.then(() => {
loading.current = false;
return setInfiniteLoading(false);
})
.catch(() => {
loading.current = false;
return setInfiniteLoading(false);
});
};
const values: GroupedTimeSpanProps = React.useMemo(() => {
if (
trackersResult.error ||
trackersResult.loading ||
!trackersResult.data ||
trackersResult.data.timeSpans === null ||
tagsResult.error ||
tagsResult.loading ||
!tagsResult.data ||
tagsResult.data.tags === null
) {
return [];
}
return toGroupedTimeSpanProps(trackersResult.data.timeSpans.timeSpans, tagsResult.data.tags, currentDate);
}, [trackersResult, tagsResult, currentDate]);
return (
<div style={{marginTop: 10}}>
<ReactInfinite
key={1}
useWindowAsScrollContainer
preloadBatchSize={window.innerHeight}
onInfiniteLoad={fetchMore}
isInfiniteLoading={infiniteLoading}
infiniteLoadBeginEdgeOffset={2000}
loadingSpinnerDelegate={
<Typography align={'center'} variant={'h5'}>
.. loading time spans ..
</Typography>
}
elementHeight={values.map((m) => heights[m.key] || 500)}>
{values.map(({key, timeSpans}) => {
return (
<DatedTimeSpans
key={key}
name={key}
timeSpans={timeSpans}
addTagsToTracker={addTagsToTracker}
setHeight={setHeights}
height={heights[key] || 500}
/>
);
})}
</ReactInfinite>
</div>
);
};
const DatedTimeSpans: React.FC<{
name: string;
setHeight: (cb: (height: Record<string, number>) => Record<string, number>) => void;
height: number;
timeSpans: TimeSpanProps[];
} & DoneTrackersProps> = ({name, timeSpans, addTagsToTracker, setHeight, height}) => {
const ref = React.useRef<HTMLDivElement | null>();
React.useEffect(() => {
const currentHeight = ref.current && ref.current.getBoundingClientRect().height;
if (currentHeight != null && currentHeight !== height) {
setHeight((old) => ({...old, [name]: currentHeight}));
}
}, [ref, name, setHeight, height]);
return (
<div key={name} ref={(r) => (ref.current = r)}>
<Typography key={name} align="center" variant={'h5'}>
{name}
</Typography>
{timeSpans.map((timeSpanProps) => (
<TimeSpan key={timeSpanProps.id} {...timeSpanProps} addTagsToTracker={addTagsToTracker} />
))}
</div>
);
};

View File

@@ -0,0 +1,61 @@
import * as React from 'react';
import {useQuery} from '@apollo/react-hooks';
import {TimeSpans, TimeSpansVariables} from '../gql/__generated__/TimeSpans';
import * as gqlTimeSpan from '../gql/timeSpan';
import {useSnackbar} from 'notistack';
import {isSameDate} from '../utils/time';
import moment from 'moment';
import {Fab, Zoom} from '@material-ui/core';
import RefreshIcon from '@material-ui/icons/Refresh';
export const RefreshTimeSpans: React.FC = () => {
const {refetch, data} = useQuery<TimeSpans, TimeSpansVariables>(gqlTimeSpan.TimeSpans, {
variables: {cursor: {pageSize: 30}},
});
const {enqueueSnackbar, closeSnackbar} = useSnackbar();
const hasMovedEntries =
data &&
data.timeSpans &&
data.timeSpans.timeSpans.some(({start, oldStart}) => !isSameDate(moment(start), oldStart ? moment(oldStart) : undefined));
React.useEffect(() => {
if (hasMovedEntries) {
const id = enqueueSnackbar('Some messages where moved, use the refresh button to reorder the timespans', {
variant: 'info',
persist: true,
preventDuplicate: true,
});
return () => {
if (id) {
closeSnackbar(id);
}
};
}
return () => {};
}, [hasMovedEntries, enqueueSnackbar, closeSnackbar]);
if (!hasMovedEntries) {
return <></>;
}
return (
<Zoom
in={true}
timeout={50}
style={{
transitionDelay: `5ms`,
}}
unmountOnExit>
<Fab
aria-label={'refresh'}
onClick={() => {
refetch({cursor: {pageSize: 30}}).then(() => {
enqueueSnackbar('Refreshed messages', {variant: 'success'});
});
}}
style={{position: 'fixed', bottom: '30px', right: '30px', zIndex: 100000}}
color="primary">
<RefreshIcon />
</Fab>
</Zoom>
);
};

View File

@@ -0,0 +1,363 @@
import * as React from 'react';
import {TagSelectorEntry, toInputTags} from '../tag/tagSelectorEntry';
import {TagSelector} from '../tag/TagSelector';
import moment from 'moment';
import Paper from '@material-ui/core/Paper';
import {DateTimeSelector} from '../common/DateTimeSelector';
import {Button, TextField, Typography, makeStyles} from '@material-ui/core';
import {inUserTz} from './timeutils';
import {useMutation} from '@apollo/react-hooks';
import {StopTimer, StopTimerVariables} from '../gql/__generated__/StopTimer';
import * as gqlTimeSpan from '../gql/timeSpan';
import {UpdateTimeSpan, UpdateTimeSpanVariables} from '../gql/__generated__/UpdateTimeSpan';
import IconButton from '@material-ui/core/IconButton';
import {MoreVert} from '@material-ui/icons';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import {RemoveTimeSpan, RemoveTimeSpanVariables} from '../gql/__generated__/RemoveTimeSpan';
import {useStateAndDelegateWithDelayOnChange} from '../utils/hooks';
import {TimeSpans} from '../gql/__generated__/TimeSpans';
import {isSameDate} from '../utils/time';
import {Trackers} from '../gql/__generated__/Trackers';
import {addTimeSpanToCache, removeFromTrackersCache} from '../gql/utils';
import {StartTimer, StartTimerVariables} from '../gql/__generated__/StartTimer';
import {RelativeTime, RelativeToNow} from '../common/RelativeTime';
interface Range {
from: moment.Moment;
to?: moment.Moment;
}
export interface TimeSpanProps {
id: number;
range: Range & {oldFrom?: moment.Moment};
initialTags: TagSelectorEntry[];
note: string;
dateSelectorOpen?: React.Dispatch<React.SetStateAction<boolean>>;
rangeChange?: (r: Range) => void;
deleted?: () => void;
stopped?: () => void;
continued?: () => void;
addTagsToTracker?: (tags: TagSelectorEntry[]) => void;
elevation?: number;
}
const useStyles = makeStyles(() => ({
innerTimespan: {
position: 'relative',
width: '100%',
display: 'flex',
alignItems: 'center',
textAlign: 'center',
'@media (max-width: 750px)': {
flexDirection: 'column',
},
},
tagInput: {
width: '100%',
flex: '1',
marginRight: 10,
'@media (max-width: 750px)': {
display: 'flex',
width: 'calc(100% - 48px)',
marginRight: '48px',
},
},
timeSelection: {
display: 'inline-flex',
'@media (max-width: 750px)': {
justifyContent: 'space-evenly',
width: '100%',
flexWrap: 'wrap',
},
},
showMoreButton: {
display: 'flex',
alignItems: 'center',
'@media (max-width: 750px)': {
position: 'absolute',
top: '0',
right: '0',
},
},
}));
export const TimeSpan: React.FC<TimeSpanProps> = React.memo(
({
range: {from, to, oldFrom},
id,
initialTags,
note: initialNote,
dateSelectorOpen = () => {},
rangeChange = () => {},
deleted = () => {},
stopped = () => {},
continued = () => {},
elevation = 1,
addTagsToTracker,
}) => {
const styles = useStyles();
const [showNotes, toggleShowingNotes] = React.useState(initialNote !== '');
const note = React.useRef<{value: string; handle?: number}>({value: initialNote});
const [selectedEntries, setSelectedEntries] = React.useState<TagSelectorEntry[]>(initialTags);
const [openMenu, setOpenMenu] = useStateAndDelegateWithDelayOnChange<null | HTMLElement>(null, (o) =>
dateSelectorOpen(!!o)
);
const [stopTimer] = useMutation<StopTimer, StopTimerVariables>(gqlTimeSpan.StopTimer, {
update: (cache, {data}) => {
if (!data || !data.stopTimeSpan) {
return;
}
removeFromTrackersCache(cache, data);
addTimeSpanToCache(cache, data.stopTimeSpan);
},
});
const [startTimer] = useMutation<StartTimer, StartTimerVariables>(gqlTimeSpan.StartTimer, {
refetchQueries: [{query: gqlTimeSpan.Trackers}],
});
const [updateTimeSpan] = useMutation<UpdateTimeSpan, UpdateTimeSpanVariables>(gqlTimeSpan.UpdateTimeSpan);
const noteAwareUpdateTimeSpan = ({variables}: {variables: Omit<UpdateTimeSpanVariables, 'note'>}) => {
clearTimeout(note.current.handle);
return updateTimeSpan({variables: {...variables, note: note.current.value}});
};
const [removeTimeSpan] = useMutation<RemoveTimeSpan, RemoveTimeSpanVariables>(gqlTimeSpan.RemoveTimeSpan, {
update: (cache, {data}) => {
let oldData: TimeSpans | null = null;
try {
oldData = cache.readQuery<TimeSpans>({query: gqlTimeSpan.TimeSpans});
} catch (e) {}
const oldTrackers = cache.readQuery<Trackers>({query: gqlTimeSpan.Trackers});
if (!data || !data.removeTimeSpan) {
return;
}
const removedId = data.removeTimeSpan.id;
if (oldTrackers) {
cache.writeQuery<Trackers>({
query: gqlTimeSpan.Trackers,
data: {
timers: (oldTrackers.timers || []).filter((tracker) => tracker.id !== removedId),
},
});
}
if (oldData) {
cache.writeQuery<TimeSpans>({
query: gqlTimeSpan.TimeSpans,
data: {
timeSpans: {
__typename: 'PagedTimeSpans',
timeSpans: oldData.timeSpans.timeSpans.filter((ts) => ts.id !== removedId),
cursor: oldData.timeSpans.cursor,
},
},
});
}
},
});
const updateNote = (newValue: string) => {
window.clearTimeout(note.current.handle);
const handle = window.setTimeout(
() =>
updateTimeSpan({
variables: {
oldStart: oldFrom,
id,
start: inUserTz(from).format(),
end: to && inUserTz(to).format(),
tags: toInputTags(selectedEntries),
note: newValue,
},
}),
200
);
note.current = {handle, value: newValue};
};
const wasMoved = !isSameDate(from, oldFrom);
const showDate = to !== undefined && (!isSameDate(from, to) || wasMoved);
return (
<Paper
elevation={elevation}
style={{
display: 'flex',
flexDirection: 'column',
padding: '10px',
margin: '10px 0',
opacity: wasMoved ? 0.5 : 1,
width: '100%',
}}>
<div className={styles.innerTimespan}>
<div className={styles.tagInput}>
<TagSelector
dialogOpen={dateSelectorOpen}
selectedEntries={selectedEntries}
onSelectedEntriesChanged={(entries) => {
setSelectedEntries(entries);
noteAwareUpdateTimeSpan({
variables: {
oldStart: oldFrom,
id,
start: inUserTz(from).format(),
end: to && inUserTz(to).format(),
tags: toInputTags(entries),
},
});
}}
/>
</div>
<div className={styles.timeSelection}>
<div style={{alignItems: 'center', display: 'flex', justifyContent: 'space-evenly', flexWrap: 'wrap'}}>
<DateTimeSelector
popoverOpen={dateSelectorOpen}
selectedDate={from}
onSelectDate={(newFrom) => {
if (!newFrom.isValid()) {
return;
}
newFrom.set({second: 0});
if (to && moment(newFrom).isAfter(to)) {
const newTo = moment(newFrom).add(15, 'minute');
noteAwareUpdateTimeSpan({
variables: {
oldStart: oldFrom,
id,
start: inUserTz(newFrom).format(),
end: inUserTz(newTo).format(),
tags: toInputTags(selectedEntries),
},
}).then(() => rangeChange({from: newFrom, to: newTo}));
} else {
noteAwareUpdateTimeSpan({
variables: {
id,
oldStart: oldFrom,
start: inUserTz(newFrom).format(),
end: to && inUserTz(to).format(),
tags: toInputTags(selectedEntries),
},
}).then(() => rangeChange({from: newFrom, to}));
}
}}
showDate={showDate}
label="start"
/>
{to !== undefined ? (
<DateTimeSelector
popoverOpen={dateSelectorOpen}
selectedDate={to}
onSelectDate={(newTo) => {
if (!newTo.isValid()) {
return;
}
newTo.set({second: 0});
if (moment(newTo).isBefore(from)) {
const newFrom = moment(newTo).subtract(15, 'minute');
noteAwareUpdateTimeSpan({
variables: {
id,
oldStart: oldFrom,
start: inUserTz(newFrom).format(),
end: inUserTz(newTo).format(),
tags: toInputTags(selectedEntries),
},
}).then(() => rangeChange({from: newFrom, to: newTo}));
} else {
noteAwareUpdateTimeSpan({
variables: {
id,
oldStart: oldFrom,
start: inUserTz(from).format(),
end: inUserTz(newTo).format(),
tags: toInputTags(selectedEntries),
},
}).then(() => rangeChange({from, to: newTo}));
}
}}
showDate={showDate}
label="end"
/>
) : (
<Button
onClick={() => {
stopTimer({variables: {id, end: inUserTz(moment()).format()}}).then(stopped);
}}>
Stop
</Button>
)}
</div>
<div style={{alignItems: 'center', display: 'flex'}}>
<Typography
variant="subtitle1"
style={{minWidth: '70px'}}
title="The amount of time between from and to">
{to ? <RelativeTime from={from} to={to} /> : <RelativeToNow from={from} />}
</Typography>
</div>
</div>
<IconButton
className={styles.showMoreButton}
onClick={(e: React.MouseEvent<HTMLElement>) => setOpenMenu(e.currentTarget)}>
<MoreVert />
</IconButton>
<Menu aria-haspopup="true" anchorEl={openMenu} open={openMenu !== null} onClose={() => setOpenMenu(null)}>
{to ? (
<MenuItem
onClick={() => {
setOpenMenu(null);
startTimer({
variables: {
start: inUserTz(moment()).format(),
tags: toInputTags(selectedEntries),
note: note.current.value,
},
}).then(() => continued());
}}>
Continue
</MenuItem>
) : null}
{addTagsToTracker ? (
<MenuItem
onClick={() => {
setOpenMenu(null);
addTagsToTracker(selectedEntries);
}}>
Copy tags
</MenuItem>
) : null}
<MenuItem
onClick={() => {
setOpenMenu(null);
toggleShowingNotes(!showNotes);
}}>
Show Notes
</MenuItem>
<MenuItem
onClick={() => {
setOpenMenu(null);
removeTimeSpan({variables: {id}}).then(() => deleted());
}}>
Delete
</MenuItem>
</Menu>
</div>
{showNotes ? (
<div>
<TextField
label="Note"
fullWidth
multiline
defaultValue={initialNote}
onChange={(e) => updateNote(e.target.value)}
/>
</div>
) : null}
</Paper>
);
}
);

157
ui/src/timespan/Tracker.tsx Normal file
View File

@@ -0,0 +1,157 @@
import * as React from 'react';
import {TagSelectorEntry} from '../tag/tagSelectorEntry';
import {TagSelector} from '../tag/TagSelector';
import moment from 'moment-timezone';
import {Button} from '@material-ui/core';
import {MoreVert} from '@material-ui/icons';
import IconButton from '@material-ui/core/IconButton';
import Paper from '@material-ui/core/Paper';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import {DateTimeSelector} from '../common/DateTimeSelector';
import {useMutation} from '@apollo/react-hooks';
import * as gqlTimeSpan from '../gql/timeSpan';
import {StartTimer, StartTimerVariables} from '../gql/__generated__/StartTimer';
import {InputTimeSpanTag} from '../gql/__generated__/globalTypes';
import {AddTimeSpan, AddTimeSpanVariables} from '../gql/__generated__/AddTimeSpan';
import {useSnackbar} from 'notistack';
import {inUserTz} from './timeutils';
import {addTimeSpanToCache} from '../gql/utils';
enum Type {
Tracker,
Manual,
}
export const calcShowDate = (from: moment.Moment, to: moment.Moment) => {
const fromString = from.format('YYYYMMDD');
return fromString !== to.format('YYYYMMDD') || moment().format('YYYYMMDD') !== fromString;
};
interface TrackerProps {
onSelectedEntriesChanged: (entries: TagSelectorEntry[]) => void;
selectedEntries: TagSelectorEntry[];
}
export const Tracker: React.FC<TrackerProps> = ({selectedEntries, onSelectedEntriesChanged: setSelectedEntries}) => {
const [openMenu, setOpenMenu] = React.useState<null | HTMLElement>(null);
const [type, setType] = React.useState<Type>(Type.Tracker);
const [from, setFrom] = React.useState<moment.Moment>(moment().subtract(15, 'minute'));
const [to, setTo] = React.useState<moment.Moment>(moment());
const [showDate, setShowDate] = React.useState(false);
const [startTimer] = useMutation<StartTimer, StartTimerVariables>(gqlTimeSpan.StartTimer, {
refetchQueries: [{query: gqlTimeSpan.Trackers}],
});
const [addTimeSpan] = useMutation<AddTimeSpan, AddTimeSpanVariables>(gqlTimeSpan.AddTimeSpan, {
update: (cache, {data}) => {
if (!data || !data.createTimeSpan) {
return;
}
addTimeSpanToCache(cache, data.createTimeSpan);
},
});
const {enqueueSnackbar} = useSnackbar();
React.useEffect(() => {
const handle = window.setInterval(() => {
setShowDate(calcShowDate(from, to));
}, 10000);
return () => clearInterval(handle);
}, [showDate, from, to]);
const submit = () => {
const tags = selectedEntries.map(
(entry: TagSelectorEntry): InputTimeSpanTag => ({key: entry.tag.key, value: entry.value})
);
if (type === Type.Tracker) {
startTimer({variables: {start: inUserTz(moment()).format(), tags, note: ''}}).then(() => {
setSelectedEntries([]);
enqueueSnackbar('tracker started', {variant: 'success'});
});
} else {
addTimeSpan({variables: {start: inUserTz(from).format(), end: inUserTz(to).format(), tags, note: ''}}).then(() => {
setSelectedEntries([]);
enqueueSnackbar('time span added', {variant: 'success'});
});
}
};
return (
<ClickAwayListener onClickAway={() => setOpenMenu(null)}>
<Paper style={{display: 'flex', alignItems: 'center', padding: '10px'}}>
<div style={{flex: '1', marginRight: 10}}>
<TagSelector
selectedEntries={selectedEntries}
onSelectedEntriesChanged={setSelectedEntries}
onCtrlEnter={submit}
/>
</div>
{type === Type.Manual ? (
<div>
<DateTimeSelector
selectedDate={from}
onSelectDate={(newFrom) => {
if (!newFrom.isValid()) {
return;
}
setFrom(newFrom);
if (moment(newFrom).isAfter(to)) {
const newTo = moment(newFrom).add(15, 'minute');
setTo(newTo);
setShowDate(calcShowDate(newFrom, newTo));
} else {
setShowDate(calcShowDate(newFrom, to));
}
}}
showDate={showDate}
label="start"
/>
<DateTimeSelector
selectedDate={to}
onSelectDate={(newTo) => {
if (!newTo.isValid()) {
return;
}
setTo(newTo);
if (moment(newTo).isBefore(from)) {
const newFrom = moment(newTo).subtract(15, 'minute');
setFrom(newFrom);
setShowDate(calcShowDate(newFrom, newTo));
} else {
setShowDate(calcShowDate(from, newTo));
}
}}
showDate={showDate}
label="end"
/>
</div>
) : null}
<Button variant="text" style={{height: 50}} onClick={submit}>
{type === Type.Manual ? 'add' : 'start'}
</Button>
<IconButton onClick={(e: React.MouseEvent<HTMLElement>) => setOpenMenu(e.currentTarget)}>
<MoreVert />
</IconButton>
<Menu aria-haspopup="true" anchorEl={openMenu} open={openMenu !== null}>
<MenuItem
selected={type === Type.Tracker}
onClick={() => {
setOpenMenu(null);
setType(Type.Tracker);
}}>
Tracker
</MenuItem>
<MenuItem
selected={type === Type.Manual}
onClick={() => {
setOpenMenu(null);
setType(Type.Manual);
}}>
Manual
</MenuItem>
</Menu>
</Paper>
</ClickAwayListener>
);
};

View File

@@ -0,0 +1,380 @@
import * as React from 'react';
import {Paper, useTheme} from '@material-ui/core';
import moment from 'moment';
import {useApolloClient, useMutation, useQuery} from '@apollo/react-hooks';
import {TimeSpans_timeSpans_timeSpans} from '../../gql/__generated__/TimeSpans';
import * as gqlTimeSpan from '../../gql/timeSpan';
import {Trackers} from '../../gql/__generated__/Trackers';
import {Tags} from '../../gql/__generated__/Tags';
import * as gqlTag from '../../gql/tags';
import FullCalendar from '@fullcalendar/react';
import {calculateColor, ColorMode} from '../colorutils';
import '@fullcalendar/core/main.css';
import '@fullcalendar/daygrid/main.css';
import '@fullcalendar/timegrid/main.css';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import momentPlugin from '@fullcalendar/moment';
import interactionPlugin from '@fullcalendar/interaction';
import {OptionsInput} from '@fullcalendar/core';
import {UpdateTimeSpan, UpdateTimeSpanVariables} from '../../gql/__generated__/UpdateTimeSpan';
import Popper from '@material-ui/core/Popper';
import ClickAwayListener from '@material-ui/core/ClickAwayListener';
import {TimeSpan} from '../TimeSpan';
import {toTagSelectorEntry} from '../../tag/tagSelectorEntry';
import {AddTimeSpan, AddTimeSpanVariables} from '../../gql/__generated__/AddTimeSpan';
import {FullCalendarStyling} from './FullCalendarStyling';
import useInterval from '@rooks/use-interval';
import {EventApi} from '@fullcalendar/core/api/EventApi';
import {StopTimer, StopTimerVariables} from '../../gql/__generated__/StopTimer';
import {
addTimeSpanInRangeToCache,
addTimeSpanToCache,
removeFromTimeSpanInRangeCache,
removeFromTrackersCache,
} from '../../gql/utils';
import {StartTimer, StartTimerVariables} from '../../gql/__generated__/StartTimer';
import {timeRunningCalendar} from '../timeutils';
import {stripTypename} from '../../utils/strip';
import {TimeSpansInRange, TimeSpansInRangeVariables} from '../../gql/__generated__/TimeSpansInRange';
import {ExtendedEventSourceInput} from '@fullcalendar/core/structs/event-source';
const toMoment = (date: Date): moment.Moment => {
return moment(date).tz('utc');
};
declare global {
interface Window {
// tslint:disable-next-line:no-any
__TRAGGO_CALENDAR: any;
}
}
const StartTimerId = '-1';
export const CalendarPage: React.FC = () => {
const apollo = useApolloClient();
const theme = useTheme();
const timeSpansResult = useQuery<TimeSpansInRange, TimeSpansInRangeVariables>(gqlTimeSpan.TimeSpansInRange, {
variables: {
start: moment()
.startOf('week')
.format(),
end: moment()
.endOf('week')
.format(),
},
fetchPolicy: 'cache-and-network',
});
const trackersResult = useQuery<Trackers>(gqlTimeSpan.Trackers, {fetchPolicy: 'cache-and-network'});
const tagsResult = useQuery<Tags>(gqlTag.Tags);
const [startTimer] = useMutation<StartTimer, StartTimerVariables>(gqlTimeSpan.StartTimer, {
refetchQueries: [{query: gqlTimeSpan.Trackers}],
});
const [updateTimeSpanMutation] = useMutation<UpdateTimeSpan, UpdateTimeSpanVariables>(gqlTimeSpan.UpdateTimeSpan);
const [currentDate, setCurrentDate] = React.useState(moment());
const [stopTimer] = useMutation<StopTimer, StopTimerVariables>(gqlTimeSpan.StopTimer, {
update: (cache, {data}) => {
if (!data || !data.stopTimeSpan) {
return;
}
removeFromTrackersCache(cache, data);
addTimeSpanInRangeToCache(cache, data.stopTimeSpan, timeSpansResult.variables);
},
});
useInterval(
() => {
setCurrentDate(moment());
},
60000,
true
);
React.useEffect(() => {
window.__TRAGGO_CALENDAR = {};
return () => (window.__TRAGGO_CALENDAR = undefined);
});
const [ignore, setIgnore] = React.useState<boolean>(false);
const [selected, setSelected] = React.useState<{selected: HTMLElement | null; data: TimeSpans_timeSpans_timeSpans | null}>({
selected: null,
data: null,
});
const [addTimeSpan] = useMutation<AddTimeSpan, AddTimeSpanVariables>(gqlTimeSpan.AddTimeSpan, {
update: (cache, {data}) => {
if (!data || !data.createTimeSpan) {
return;
}
addTimeSpanInRangeToCache(cache, data.createTimeSpan, timeSpansResult.variables);
addTimeSpanToCache(cache, data.createTimeSpan);
},
});
const values: ExtendedEventSourceInput[] = (() => {
if (
timeSpansResult.error ||
timeSpansResult.loading ||
!timeSpansResult.data ||
timeSpansResult.data.timeSpans === null ||
trackersResult.error ||
trackersResult.loading ||
!trackersResult.data ||
trackersResult.data.timers === null ||
tagsResult.error ||
tagsResult.loading ||
!tagsResult.data ||
tagsResult.data.tags === null
) {
return [];
}
return timeSpansResult.data.timeSpans.timeSpans
.concat(trackersResult.data.timers)
.sort((a, b) => a.start.toString().localeCompare(b.start.toString()))
.map((ts) => {
const colorKey = ts
.tags!.map((t) => t.key + ':' + t.value)
.sort((a, b) => a.localeCompare(b))
.join(' ');
const color = calculateColor(colorKey, ColorMode.Bold, theme.palette.type);
const borderColor = calculateColor(colorKey, ColorMode.None, theme.palette.type);
return {
start: moment(ts.start).toDate(),
end: moment(ts.end || currentDate).toDate(),
hasEnd: !!ts.end,
editable: !!ts.end,
backgroundColor: color,
startEditable: true,
id: ts.id,
tags: ts.tags!.map(({value, key}) => ({key, value})),
title: ts.tags!.map((t) => t.key + ':' + t.value).join(' '),
extendedProps: {ts},
textColor: theme.palette.getContrastText(color),
borderColor,
};
});
})();
const onDrop: OptionsInput['eventDrop'] = (data) => {
updateTimeSpanMutation({
variables: {
oldStart: moment(data.oldEvent.start!).format(),
start: moment(data.event.start!).format(),
end: moment(data.event.end!).format(),
id: parseInt(data.event.id, 10),
tags: stripTypename(data.event.extendedProps.ts.tags),
note: data.event.extendedProps.ts.note,
},
});
};
const onResize: OptionsInput['eventResize'] = (data) => {
updateTimeSpanMutation({
variables: {
oldStart: moment(data.prevEvent.start!).format(),
start: moment(data.event.start!).format(),
end: moment(data.event.end!).format(),
id: parseInt(data.event.id, 10),
tags: stripTypename(data.event.extendedProps.ts.tags),
note: data.event.extendedProps.ts.note,
},
});
};
const onSelect: OptionsInput['select'] = (data) => {
addTimeSpan({
variables: {
start: moment(data.start).format(),
end: moment(data.end).format(),
tags: [],
note: '',
},
});
};
const onClick: OptionsInput['eventClick'] = (data) => {
data.jsEvent.preventDefault();
if (data.event.id === StartTimerId) {
startTimer({variables: {start: moment().format(), tags: [], note: ''}}).then(() => {
setCurrentDate(moment());
});
return;
}
// tslint:disable-next-line:no-any
setSelected({data: data.event.extendedProps.ts, selected: data.jsEvent.target as any});
};
if (trackersResult.data && !(trackersResult.data.timers || []).length) {
const startTimerEvent: ExtendedEventSourceInput = {
start: currentDate.toDate(),
end: moment(currentDate)
.add(15, 'minute')
.toDate(),
className: '__start',
editable: false,
id: StartTimerId,
};
values.push(startTimerEvent);
}
return (
<Paper style={{padding: 10, bottom: 10, top: 80, position: 'absolute'}} color="red">
<FullCalendarStyling>
<FullCalendar
defaultView="timeGridWeek"
rerenderDelay={30}
datesRender={(x) => {
const range = {start: moment(x.view.currentStart), end: moment(x.view.currentEnd)};
if (
!moment(timeSpansResult.variables.start).isSame(range.start) ||
!moment(timeSpansResult.variables.end).isSame(range.end)
) {
timeSpansResult.refetch(range);
}
}}
views={{
timeGrid5Day: {
type: 'timeGrid',
duration: {days: 7},
buttonText: '5 day',
hiddenDays: [0, 6],
},
}}
editable={true}
events={values}
allDaySlot={false}
selectable={true}
selectMirror={true}
handleWindowResize={true}
height={'parent'}
selectMinDistance={20}
now={currentDate.toDate()}
defaultTimedEventDuration={{minute: 15}}
eventRender={(e) => {
const content = e.el.getElementsByClassName('fc-content').item(0);
if (content) {
content.innerHTML = getElementContent(e.event, () => {
stopTimer({
variables: {id: e.event.extendedProps.ts.id, end: moment().format()},
});
});
}
e.el.setAttribute('data-has-end', '' + (!e.event.extendedProps.ts || !!e.event.extendedProps.ts.end));
}}
slotLabelInterval={{minute: 60}}
slotDuration={{minute: 15}}
scrollTime={{hour: 6, minute: 30}}
select={onSelect}
firstDay={moment.localeData().firstDayOfWeek()}
eventResize={onResize}
eventClick={onClick}
eventDrop={onDrop}
slotLabelFormat={(s) => toMoment(s.start.marker).format('LT')}
columnHeaderFormat={(s) => toMoment(s.start.marker).format('DD ddd')}
nowIndicator={true}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, momentPlugin]}
header={{
center: 'title',
left: 'prev,next today',
right: 'timeGridWeek,timeGrid5Day,timeGridDay',
}}
/>
</FullCalendarStyling>
{!!selected.selected && (
<Popper open={true} anchorEl={selected.selected} style={{zIndex: 1200, maxWidth: 700}}>
<ClickAwayListener
onClickAway={() => {
if (ignore) {
return;
}
setSelected({selected: null, data: null});
}}>
<div>
<TimeSpan
elevation={10}
id={selected.data!.id}
key={selected.data!.id}
rangeChange={(range) => {
setSelected({
...selected,
data: {...selected.data!, start: range.from.format(), end: range.to && range.to.format()},
});
}}
deleted={() => {
removeFromTimeSpanInRangeCache(apollo.cache, selected.data!.id, timeSpansResult.variables);
setSelected({selected: null, data: null});
}}
continued={() => setCurrentDate(moment())}
range={{
from: moment(selected.data!.start),
to: selected.data!.end ? moment(selected.data!.end) : undefined,
}}
initialTags={toTagSelectorEntry(
tagsResult.data!.tags!,
selected.data!.tags!.map((tag) => ({key: tag.key, value: tag.value}))
)}
note={selected.data!.note}
dateSelectorOpen={setIgnore}
stopped={() => {
setSelected({
...selected,
data: {...selected.data!, end: currentDate.toDate()},
});
}}
/>
</div>
</ClickAwayListener>
</Popper>
)}
</Paper>
);
};
const getElementContent = (event: EventApi, stop: () => void): string => {
if (!event.start || !event.end) {
return '';
}
if (event.id === StartTimerId) {
return 'START';
}
const start = moment(event.start);
const end = moment(event.end);
const diff = end.diff(start, 'minute');
const lines = Math.floor(diff / 15);
const hasEnd = !event.extendedProps.ts || event.extendedProps.ts.end;
let stopButton = '';
if (!hasEnd) {
const id = event.extendedProps.ts.id;
if (!window.__TRAGGO_CALENDAR) {
window.__TRAGGO_CALENDAR = {};
}
window.__TRAGGO_CALENDAR[id] = (e: Event) => {
e.preventDefault();
e.stopPropagation();
stop();
return false;
};
stopButton = `<div class="stop"><a onClick="return window.__TRAGGO_CALENDAR[${id}](event)">STOP ${timeRunningCalendar(
start,
end
)}</a></div>`;
}
const clamp = (amount: number) =>
`<span class="ellipsis" title="${event.title}" style="-webkit-line-clamp: ${amount}">${event.title}</span>`;
const running = hasEnd ? `<span style="float: right">${timeRunningCalendar(start, end)}</span>` : '';
const date = `${start.format('LT')} - ${hasEnd ? end.format('LT') : 'now'} ${running}`;
if (lines < 2) {
return event.title
? `<span class="ellipsis-single" title="${event.title}">${event.title}</span>${stopButton}`
: `${date}${stopButton}`;
}
if (lines === 2) {
if (hasEnd) {
return `${date}<span class="ellipsis-single" title="${event.title}">${event.title}</span>${stopButton}`;
} else {
return `${clamp(2)}${stopButton}`;
}
}
return `${date}<br/>${clamp(lines - 1)}${stopButton}`;
};

View File

@@ -0,0 +1,236 @@
import * as React from 'react';
import {makeStyles} from '@material-ui/core';
const useStyle = makeStyles((theme) => {
return {
root: {
height: '100%',
width: '100%',
// '& .fc-time-grid-event': {
// opacity: 0,
// border: 'none',
// },
'& .fc-head': {
fontFamily: 'monospace',
},
'& .fc-toolbar': {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
'& .fc-left, .fc-center, .fc-right': {
display: 'inline-block',
verticalAlign: 'middle',
textAlign: 'center',
// width: '33.33%',
boxSizing: 'border-box',
// padding: '0 10px',
},
'& .fc-center h2': {
margin: 0,
fontSize: '1.5rem',
},
'@media (max-width: 600px)': {
'& .fc-toolbar': {
display: 'inherit',
justifyContent: 'space-between',
alignItems: 'center',
},
'& .fc-left, .fc-center, .fc-right': {
display: 'block',
width: '100%',
},
'& .fc-left, .fc-right': {
textAlign: 'center',
},
},
'& .fc-time-grid-event.fc-v-event.fc-event': {
borderRadius: 1,
border: 'none',
padding: 5,
opacity: 0.65,
left: '5% !important',
right: '5% !important',
},
'& .fc .fc-time-grid-event': {
minHeight: 1,
},
'& .fc-event': {
fontSize: 'inherit !important',
borderRadius: 1,
},
'& .fc td, .fc th': {
borderStyle: 'solid !important',
borderWidth: '0px !important',
padding: '0 !important',
verticalAlign: 'top !important',
},
'& .fc tr:nth-child(4n)': {
borderStyle: 'solid !important',
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.2)' : 'rgba(0,0,0,.2)',
borderWidth: '0 0 2px 0 !important',
padding: '0 !important',
verticalAlign: 'top !important',
},
'& .fc tbody tr:first-child': {
borderStyle: 'solid !important',
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.2)' : 'rgba(0,0,0,.2)',
borderWidth: '2px 0 0 0 !important',
padding: '0 !important',
verticalAlign: 'top !important',
},
'& .fc td': {
borderStyle: 'solid !important',
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
borderWidth: '0 1px 0 0 !important',
padding: '0 !important',
verticalAlign: 'top !important',
},
'& .fc tr:nth-child(4n+2)': {
borderStyle: 'solid !important',
borderColor: theme.palette.type === 'dark' ? 'rgba(255,255,255,.1)' : 'rgba(0,0,0,.1)',
borderWidth: '0 0 1px 0 !important',
padding: '0 !important',
verticalAlign: 'top !important',
},
'& .fc-event .fc-bg': {
zIndex: '1 !important',
background: 'inherit !important',
opacity: '.25 !important',
},
'& .fc-time-grid-event .fc-time': {
fontWeight: 'normal !important',
},
'& .fc-ltr .fc-h-event.fc-not-end, .fc-rtl .fc-h-event.fc-not-start': {
opacity: '.65 !important',
marginLeft: '12px !important',
padding: '5px !important',
},
'& .fc-day-grid-event.fc-h-event.fc-event.fc-not-start.fc-end': {
opacity: '.65 !important',
marginLeft: '12px !important',
padding: '5px !important',
},
'& .fc-today': {
background: theme.palette.type === 'dark' ? 'rgba(199,199,199,.1) !important' : 'rgba(0,0,0,.1) !important',
},
'& .fc-button': {
display: 'inline-block',
position: 'relative',
cursor: 'pointer',
minHeight: '36px',
minWidth: '88px',
lineHeight: '36px',
verticalAlign: 'middle',
alignItems: 'center',
textAlign: 'center',
borderRadius: '2px',
boxSizing: 'border-box',
userSelect: 'none',
outline: 'none',
border: '0',
padding: '0 6px',
margin: '6px 8px',
letterSpacing: '.01em',
background: 'transparent',
color: 'currentColor',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
fontWeight: '500',
fontSize: '14px',
fontStyle: 'inherit',
fontVariant: 'inherit',
fontFamily: 'inherit',
textDecoration: 'none',
overflow: 'hidden',
borderColor: theme.palette.primary.main,
borderWidth: 1,
borderStyle: 'solid',
transition: 'box-shadow .4s cubic-bezier(.25,.8,.25,1),background-color .4s cubic-bezier(.25,.8,.25,1)',
},
'& .fc-button-active': {
backgroundColor: theme.palette.primary.main + '!important',
},
'& .fc-button:active': {
backgroundColor: theme.palette.primary.main + '!important',
},
'& .fc td.fc-axis.fc-time': {
position: 'absolute',
marginTop: -10,
background: theme.palette.background.paper,
zIndex: 1000,
},
'& .fc td.fc-axis.fc-time span': {
paddingRight: 10,
},
'& .fc .fc-time-grid-event-inset': {
boxShadow: theme.shadows[5],
},
'& .fc .fc-mirror': {
opacity: 0.5,
},
'& .fc .fc-time-grid-event:hover': {
minHeight: 17,
zIndex: '2!important',
},
'& .fc .fc-time-grid-event[data-has-end="false"]': {
minHeight: 40,
},
'& .fc .fc-content': {
height: '100%',
},
'& .fc .fc-content .stop': {
background: theme.palette.type === 'dark' ? 'rgba(255,255,255,.2) !important' : 'rgba(0,0,0,.1) !important',
borderRadius: '6px 0 0 0',
fontSize: '10px',
width: '50%',
padding: '2px 0 1px 0',
textAlign: 'center',
right: 0,
bottom: 0,
position: 'absolute',
display: 'inline-block',
},
'& .fc .fc-content .stop:hover': {
background: theme.palette.type === 'dark' ? 'rgba(255,255,255,.3) !important' : 'rgba(0,0,0,.2) !important',
},
'& .fc .fc-content .stop a': {
width: '100%',
display: 'block',
},
'& .fc .fc-now-indicator': {
zIndex: 5,
},
'& .fc .ellipsis': {
display: '-webkit-box',
boxOrient: 'vertical',
overflow: 'hidden',
},
'& .fc .ellipsis-single': {
textOverflow: 'ellipsis',
display: 'inline-block',
width: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
},
'& .fc .__start': {
background: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
border: 0,
borderRadius: '0 0 10px 10px',
textAlign: 'center',
boxShadow: theme.shadows[1],
cursor: 'pointer',
},
'& .fc .__start:hover': {
background: theme.palette.primary.light,
},
// tslint:disable-next-line:no-any important breaks it
} as any,
};
});
export const FullCalendarStyling: React.FC = ({children}) => {
const classes = useStyle();
return <div className={classes.root}>{children}</div>;
};

View File

@@ -0,0 +1,28 @@
// @ts-ignore
import colorHash from 'color-hash';
const dark = new colorHash({saturation: 0.35, lightness: 0.35});
const darkNone = new colorHash({saturation: 0.35, lightness: 0.5});
const light = new colorHash({saturation: 0.5, lightness: 0.6});
const lightNone = new colorHash({saturation: 0.5, lightness: 0.4});
export enum ColorMode {
Bold,
None,
}
const mapping: Record<ColorMode, Record<'dark' | 'light', (s: string) => string>> = {
[ColorMode.Bold]: {
dark: (s) => dark.hex(s),
light: (s) => light.hex(s),
},
[ColorMode.None]: {
dark: (s) => darkNone.hex(s),
light: (s) => lightNone.hex(s),
},
};
export const calculateColor = (s: string, mode: ColorMode, theme: 'light' | 'dark') => {
return mapping[mode][theme](s);
};

View File

@@ -0,0 +1,69 @@
import {Trackers_timers} from '../gql/__generated__/Trackers';
import {Tags_tags} from '../gql/__generated__/Tags';
import {toTagSelectorEntry} from '../tag/tagSelectorEntry';
import moment from 'moment';
import {TimeSpanProps} from './TimeSpan';
import {TimeSpans_timeSpans_timeSpans} from '../gql/__generated__/TimeSpans';
export const toTimeSpanProps = (timers: Trackers_timers[], tags: Tags_tags[]): TimeSpanProps[] => {
return [...timers].map((timer) => {
const tagEntries = toTagSelectorEntry(tags, timer.tags || []);
const range: TimeSpanProps['range'] = {from: moment.parseZone(timer.start)};
if (timer.end) {
range.to = moment.parseZone(timer.end);
}
return {
id: timer.id,
range: {
...range,
oldFrom: timer.oldStart ? moment(timer.oldStart) : undefined,
},
initialTags: tagEntries,
note: timer.note,
};
});
};
type GroupedByIndex = Record<string, TimeSpans_timeSpans_timeSpans[]>;
const group = (startOfTomorrow: moment.Moment, startOfToday: moment.Moment, startOfYesterday: moment.Moment) => (
a: GroupedByIndex,
current: TimeSpans_timeSpans_timeSpans
): GroupedByIndex => {
const startTime = moment(current.oldStart || current.start);
let date = `${startTime.format('dddd')}, ${startTime.format('LL')}`;
if (startTime.isBetween(startOfToday, startOfTomorrow)) {
date = `${date} (today)`;
} else if (startTime.isBetween(startOfYesterday, startOfToday)) {
date = `${date} (yesterday)`;
}
a[date] = [...(a[date] || []), current];
return a;
};
export type GroupedTimeSpanProps = Array<{key: string; timeSpans: TimeSpanProps[]}>;
export const toGroupedTimeSpanProps = (
timeSpans: TimeSpans_timeSpans_timeSpans[],
tags: Tags_tags[],
now: moment.Moment
): GroupedTimeSpanProps => {
const datesWithTimeSpans: GroupedByIndex = timeSpans.reduce(
group(
moment(now)
.add(1, 'day')
.startOf('day'),
moment(now).startOf('day'),
moment(now)
.subtract(1, 'day')
.startOf('day')
),
{}
);
return Object.keys(datesWithTimeSpans).map((key) => {
const groupedTimeSpans = datesWithTimeSpans[key];
return {
key,
timeSpans: toTimeSpanProps(groupedTimeSpans, tags),
};
});
};

View File

@@ -0,0 +1,24 @@
import moment from 'moment-timezone';
import prettyMs from 'pretty-ms';
export const timeRunning = (date: moment.Moment, now: moment.Moment) => {
const d = inUserTz(now).unix() - inUserTz(date).unix();
return prettyMs(d * 1000, {unitCount: 2}).substring(1);
};
export const timeRunningCalendar = (date: moment.Moment, now: moment.Moment) => {
const d = inUserTz(now).unix() - inUserTz(date).unix();
if (d < 60) {
return '<1m';
}
return prettyMs(d * 1000, {unitCount: d < 60 * 24 ? 1 : 2}).substring(1);
};
export const uglyConvertToLocalTime = (m: moment.Moment): moment.Moment => {
const withoutTimeZone: string = m.format('YYYY-MM-DDTHH:mm:ss');
return moment(withoutTimeZone);
};
export const inUserTz = (m: moment.Moment): moment.Moment => m.tz(moment.tz.guess());

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import * as gqlUser from '../gql/user';
import {useMutation} from '@apollo/react-hooks';
import {useSnackbar} from 'notistack';
import {handleError} from '../utils/errors';
import {Checkbox} from '@material-ui/core';
import {CreateUser, CreateUserVariables} from '../gql/__generated__/CreateUser';
import FormControlLabel from '@material-ui/core/FormControlLabel';
interface AddTagDialogProps {
open: boolean;
close: () => void;
}
export const AddUserDialog: React.FC<AddTagDialogProps> = ({close, open}) => {
const [name, setName] = React.useState('');
const [pass, setPass] = React.useState('');
const [admin, setAdmin] = React.useState(false);
const {enqueueSnackbar} = useSnackbar();
const [addUser] = useMutation<CreateUser, CreateUserVariables>(gqlUser.CreateUser, {
refetchQueries: [{query: gqlUser.Users}],
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
addUser({variables: {name, pass, admin}})
.then(() => {
enqueueSnackbar('User created', {variant: 'success'});
close();
})
.catch(handleError('Create User', enqueueSnackbar));
};
return (
<Dialog open={open} onClose={close} aria-labelledby="form-dialog-title" fullWidth>
<form onSubmit={submit} noValidate autoComplete="off">
<DialogTitle id="form-dialog-title">Create User</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="name"
label="Name"
type="text"
fullWidth
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextField
margin="dense"
label="Password"
type="password"
fullWidth
value={pass}
onChange={(e) => setPass(e.target.value)}
/>
<FormControlLabel
control={
<Checkbox checked={admin} onChange={(e) => setAdmin(e.target.checked)}>
Admin
</Checkbox>
}
label="Admin"
/>
</DialogContent>
<DialogActions>
<Button onClick={close} color="primary">
Cancel
</Button>
<Button type="submit" onClick={submit} color="primary">
Create User
</Button>
</DialogActions>
</form>
</Dialog>
);
};

177
ui/src/user/UsersPage.tsx Normal file
View File

@@ -0,0 +1,177 @@
import * as React from 'react';
import Paper from '@material-ui/core/Paper';
import {useMutation, useQuery} from '@apollo/react-hooks';
import * as gqlUser from '../gql/user';
import {CenteredSpinner} from '../common/CenteredSpinner';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import DoneIcon from '@material-ui/icons/Done';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import {useSnackbar} from 'notistack';
import {TextField} from '@material-ui/core';
import {Users} from '../gql/__generated__/Users';
import {RemoveUser, RemoveUserVariables} from '../gql/__generated__/RemoveUser';
import {UpdateUser, UpdateUserVariables} from '../gql/__generated__/UpdateUser';
import Checkbox from '@material-ui/core/Checkbox';
import Button from '@material-ui/core/Button';
import {AddUserDialog} from './AddUserDialog';
import makeStyles from '@material-ui/core/styles/makeStyles';
import {ConfirmDialog} from '../common/ConfirmDialog';
const useStyles = makeStyles((theme) => ({
root: {
...theme.mixins.gutters(),
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
textAlign: 'center',
maxWidth: 800,
margin: '0 auto',
},
}));
const NoEdit = [-1, '', '', false] as const;
export const UsersPage = () => {
const classes = useStyles();
const {data, loading} = useQuery<Users>(gqlUser.Users);
const [addUser, setAddUser] = React.useState(false);
const refetch = {refetchQueries: [{query: gqlUser.Users}, {query: gqlUser.CurrentUser}]};
const {enqueueSnackbar} = useSnackbar();
const [removeUserConfirmation, setRemoveUserConfirmation] = React.useState<false | [number, string]>(false);
const [removeUser] = useMutation<RemoveUser, RemoveUserVariables>(gqlUser.RemoveUser, refetch);
const [[editId, editName, editPass, editAdmin], setEditing] = React.useState<Readonly<[number, string, string, boolean]>>(
NoEdit
);
const [updateUser] = useMutation<UpdateUser, UpdateUserVariables>(gqlUser.UpdateUser, refetch);
if (loading || !data || !data.currentUser || !data.users) {
return <CenteredSpinner />;
}
const onClickDelete = () => {
if (removeUserConfirmation) {
removeUser({variables: {id: removeUserConfirmation[0]}}).then(() =>
enqueueSnackbar('user deleted', {variant: 'success'})
);
}
};
const users = data.users.map((user) => {
const onClickSubmit = () => {
setEditing(NoEdit);
updateUser({
variables: {
id: editId,
name: editName,
admin: editAdmin,
pass: editPass || undefined,
},
}).then(() => enqueueSnackbar('user edited', {variant: 'success'}));
};
const isCurrent = user.id === data.currentUser!.id;
const isEdited = editId === user.id;
return (
<TableRow selected={isCurrent} key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>
{isEdited ? (
<TextField
value={editName}
onChange={(e) => setEditing([editId, e.target.value, '', editAdmin])}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickSubmit();
}
}}
onSubmit={onClickSubmit}
/>
) : (
user.name + (isCurrent ? ' (current)' : '')
)}
</TableCell>
<TableCell>
{isEdited ? (
<TextField
value={editPass}
placeholder={'use old password'}
type="password"
onChange={(e) => setEditing([editId, editName, e.target.value, editAdmin])}
/>
) : (
'********'
)}
</TableCell>
<TableCell>
{isEdited ? (
<Checkbox checked={editAdmin} onChange={(e) => setEditing([editId, editName, '', e.target.checked])} />
) : user.admin ? (
'Yes'
) : (
'No'
)}
</TableCell>
<TableCell align="right">
{isEdited ? (
<>
<IconButton onClick={onClickSubmit} title="Save">
<DoneIcon />
</IconButton>
<IconButton onClick={() => setEditing(NoEdit)} title="Cancel">
<CloseIcon />
</IconButton>
</>
) : (
<>
<IconButton onClick={() => setEditing([user.id, user.name, '', user.admin])} title="Edit">
<EditIcon />
</IconButton>
<IconButton onClick={() => setRemoveUserConfirmation([user.id, user.name])} title="Delete">
<DeleteIcon />
</IconButton>
</>
)}
</TableCell>
</TableRow>
);
});
return (
<Paper elevation={1} square={true} className={classes.root}>
<Button
color={'primary'}
variant={'outlined'}
size="small"
onClick={() => setAddUser(true)}
fullWidth
style={{marginBottom: 10}}>
Create User
</Button>
{addUser && <AddUserDialog open={true} close={() => setAddUser(false)} />}
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Password</TableCell>
<TableCell>Admin</TableCell>
<TableCell style={{width: 150}} />
</TableRow>
</TableHead>
<TableBody>{users}</TableBody>
</Table>
{removeUserConfirmation ? (
<ConfirmDialog
title={`Are you sure you want to delete the user '${removeUserConfirmation[1]}'?`}
fClose={() => setRemoveUserConfirmation(false)}
fOnSubmit={onClickDelete}>
<b>This operation cannot be undone.</b> Deleting the user includes deleting dashboards, time spans, tags and
devices of that user.
</ConfirmDialog>
) : null}
</Paper>
);
};

35
ui/src/utils/errors.tsx Normal file
View File

@@ -0,0 +1,35 @@
import * as React from 'react';
import {withSnackbarProps} from 'notistack';
import {ApolloError} from 'apollo-boost';
export const handleError = (prefix: string, enqueue: withSnackbarProps['enqueueSnackbar']): ((error: ApolloError) => void) => {
return (error) => {
error.graphQLErrors.forEach((gqlError) => {
enqueue(`${prefix}: ${gqlError.message}`, {variant: 'warning'});
});
};
};
export const useError = (timeout = 1000): [boolean, string, (s: string) => void] => {
const [error, setError] = React.useState('');
const [active, setActive] = React.useState(false);
React.useLayoutEffect(() => {
if (!active) {
return;
}
const handle = setTimeout(() => {
setActive(false);
}, timeout);
return () => clearTimeout(handle);
}, [active, timeout]);
return [
active,
error,
(message: string) => {
setError(message);
setActive(true);
},
];
};

20
ui/src/utils/hooks.ts Normal file
View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import useTimeout from '@rooks/use-timeout';
export const useStateAndDelegateWithDelayOnChange: <T>(
initialState: T,
delegate: React.Dispatch<React.SetStateAction<T>>,
delay?: number
) => [T, React.Dispatch<React.SetStateAction<T>>] = (initialState, delegate, delay = 50) => {
const [value, setValue] = React.useState(initialState);
const {start} = useTimeout(() => delegate(value), delay);
return [
value,
(newValue) => {
if (value !== newValue) {
start();
}
setValue(newValue);
},
];
};

3
ui/src/utils/never.ts Normal file
View File

@@ -0,0 +1,3 @@
export const expectNever = (value: never): never => {
throw new Error('expected never but was ' + value);
};

16
ui/src/utils/range.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface Range {
from: string;
to: string;
}
export const findRange = (selection: {range: Range | null; rangeId: number | null}, ranges: Record<number, Range>): Range => {
if (selection.rangeId !== null) {
return exclusiveRange(ranges[selection.rangeId]);
}
if (selection.range === null) {
throw new Error('expected rangeId or range to be non null');
}
return exclusiveRange(selection.range);
};
export const exclusiveRange = (range: Range) => ({from: range.from, to: range.to});

22
ui/src/utils/strip.ts Normal file
View File

@@ -0,0 +1,22 @@
export const stripTypename = <T>(value: T): T => {
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
// tslint:disable-next-line:no-any
return (value as any).map((x: any) => stripTypename(x));
}
if (typeof value !== 'object') {
return value;
}
Object.values(value).forEach(stripTypename);
if ('__typename' in value) {
// @ts-ignore
delete value.__typename;
}
return value;
};

42
ui/src/utils/time.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import {isValidDate, parseRelativeTime} from './time';
import moment from 'moment';
moment.updateLocale('en', {
week: {
dow: 1, // monday
doy: moment.localeData('en').firstDayOfYear(),
},
});
it('should test for valid date', () => {
expect(isValidDate('2017-05-05')).toBe(false);
expect(isValidDate('2017-05-05T15:23')).toBe(false);
expect(isValidDate('2017-05-05 15:23')).toBe(true);
});
// 2018-10-15 Monday
// 2018-10-22 Monday
// 2019-10-07 Monday
// 2019-10-14 Monday
// 2019-10-21 Monday
it('should parse', () => {
expectSuccess(parseRelativeTime('now-1d', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 15:55:00');
expectSuccess(parseRelativeTime('now-120s', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 15:53:15');
expectSuccess(parseRelativeTime('now-1d-1h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 14:55:00');
expectSuccess(parseRelativeTime('now/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-14 00:00:00');
expectSuccess(parseRelativeTime('now/w', 'endOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 23:59:59');
expectSuccess(parseRelativeTime('now-1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-07 00:00:00');
expectSuccess(parseRelativeTime('now-1y+1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2018-10-22 00:00:00');
expectSuccess(parseRelativeTime('now/d+5h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-20 05:00:00');
expectSuccess(parseRelativeTime('now/y', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-01-01 00:00:00');
});
const expectSuccess = (value: ReturnType<typeof parseRelativeTime>) => {
if (value.success) {
return expect(value.value.format('YYYY-MM-DD HH:mm:ss'));
}
expect(value.error).toEqual('no error');
return expect('');
};

142
ui/src/utils/time.ts Normal file
View File

@@ -0,0 +1,142 @@
import moment from 'moment';
interface Success {
success: true;
value: moment.Moment;
}
interface Failure {
success: false;
error: string;
}
enum Type {
Operation = 'operation',
Unit = 'unit',
Value = 'value',
}
enum Operation {
Divide = '/',
Add = '+',
Substract = '-',
}
enum Unit {
Year = 'y',
Month = 'M',
Week = 'w',
Day = 'd',
Hour = 'h',
Minute = 'm',
Second = 's',
}
export const parseRelativeTime = (value: string, divide: 'endOf' | 'startOf', nowDate = moment()): Success | Failure => {
if (isValidDate(value)) {
return success(asDate(value));
}
if (value.substr(0, 3) === 'now') {
let time = nowDate;
let currentIndex = 'now'.length;
let expectNext = Type.Operation;
let lastOperation = Operation.Add;
let lastNumber = -1;
while (currentIndex < value.length) {
const currentChar = value.charAt(currentIndex);
switch (expectNext) {
case Type.Operation:
if (!isOperation(currentChar)) {
return failure('Expected one of / + - at index ' + currentIndex + ' but was ' + currentChar);
}
lastOperation = currentChar;
expectNext = currentChar === Operation.Divide ? Type.Unit : Type.Value;
currentIndex++;
break;
case Type.Value:
if (isNaN(parseInt(currentChar, 10))) {
return failure('Expected number at index ' + currentIndex + ' but was ' + currentChar);
}
let valueIndex = currentIndex;
while (!isNaN(parseInt(value.charAt(valueIndex + 1), 10)) && valueIndex + 1 < value.length) {
valueIndex++;
}
lastNumber = parseInt(value.substr(currentIndex, valueIndex), 10);
expectNext = Type.Unit;
currentIndex = valueIndex + 1;
break;
case Type.Unit:
if (!isUnit(currentChar)) {
return failure(
'Expected unit (' + Object.values(Unit) + ') at index ' + currentIndex + ' but was ' + currentChar
);
}
// tslint:disable-next-line:no-nested-switch
switch (lastOperation) {
case Operation.Divide:
time = time[divide](currentChar);
expectNext = Type.Operation;
break;
case Operation.Add:
time = time.add(lastNumber, currentChar);
expectNext = Type.Operation;
break;
case Operation.Substract:
time = time.subtract(lastNumber, currentChar);
expectNext = Type.Operation;
break;
default:
throw new Error('oops');
}
currentIndex++;
break;
default:
throw new Error('oopsie');
}
}
if (expectNext === Type.Unit) {
return failure('Expected unit at the end but got nothing');
}
if (expectNext === Type.Value) {
return failure('Expected number at the end but got nothing');
}
return success(time);
}
if (value.indexOf('now') !== -1) {
return failure("'now' must be at the start");
}
return failure("Expected valid date or 'now' at index 0");
};
export const success = (value: moment.Moment): Success => {
return {success: true, value};
};
export const failure = (error: string): Failure => {
return {success: false, error};
};
const isOperation = (char: string): char is Operation => {
return Object.values(Operation).indexOf(char as Operation) !== -1;
};
const isUnit = (char: string): char is Unit => {
return Object.values(Unit).indexOf(char as Unit) !== -1;
};
export const isValidDate = (value: string, format?: string) => {
return asDate(value, format).isValid();
};
export const asDate = (value: string, format = 'YYYY-MM-DD HH:mm') => {
return moment(value, format, true);
};
export const isSameDate = (from: moment.Moment, to?: moment.Moment): boolean => {
const fromString = from.format('YYYYMMDD');
return to === undefined || fromString === to.format('YYYYMMDD');
};

37
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"alwaysStrict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"suppressImplicitAnyIndexErrors": false,
"strictPropertyInitialization": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictFunctionTypes": true,
"jsx": "preserve"
},
"include": [
"src"
]
}

120
ui/tslint.json Normal file
View File

@@ -0,0 +1,120 @@
{
"extends": "tslint-sonarts",
"rules": {
"array-type": [true, "array-simple"],
"arrow-return-shorthand": true,
"ban": [true,
{"name": "Array", "message": "tsstyle#array-constructor"}
],
"ban-types": [true,
["Object", "Use {} instead."],
["String", "Use 'string' instead."],
["Number", "Use 'number' instead."],
["Boolean", "Use 'boolean' instead."],
["Function", "Use (..) => .. instead."]
],
"class-name": true,
"curly": [true],
"forin": true,
"object-literal-shorthand": true,
"label-position": true,
"member-access": [true],
"new-parens": true,
"no-angle-bracket-type-assertion": true,
"no-any": true,
"no-arg": true,
"no-conditional-assignment": true,
"no-construct": true,
"no-shadowed-variable": true,
"prefer-object-spread": true,
"no-debugger": true,
"no-default-export": true,
"no-duplicate-variable": true,
"no-inferrable-types": true,
"no-namespace": [true, "allow-declarations"],
"cyclomatic-complexity": [true, 20],
"no-duplicate-imports": true,
"no-reference": true,
"no-string-throw": true,
"no-unused-expression": true,
"no-var-keyword": true,
"object-literal-shorthand": true,
"only-arrow-functions": [true, "allow-declarations", "allow-named-functions"],
"prefer-const": true,
"radix": true,
"semicolon": [true, "always", "ignore-bound-class-methods"],
"switch-default": true,
"triple-equals": [true, "allow-null-check"],
"use-isnan": true,
"variable-name": [
true,
"check-format",
"ban-keywords",
"allow-pascal-case",
"allow-leading-underscore",
"allow-trailing-underscore"
],
"cognitive-complexity": [true, 20],
"mccabe-complexity": [true, 20],
"no-duplicate-string": false,
"no-nested-incdec": false,
"no-all-duplicated-branches": true,
"consecutive-overloads": true,
"max-union-size": false,
"no-accessor-field-mismatch": true,
"no-array-delete": true,
"no-big-function": false,
"no-case-with-or": true,
"no-collection-size-mischeck": true,
"no-commented-code": true,
"no-dead-store": true,
"no-duplicate-in-composite": true,
"no-duplicated-branches": true,
"no-element-overwrite": true,
"no-empty-destructuring": true,
"no-empty-nested-blocks": true,
"no-extra-semicolon": true,
"no-gratuitous-expressions": true,
"no-hardcoded-credentials": true,
"no-identical-conditions": true,
"no-identical-expressions": true,
"no-identical-functions": true,
"no-ignored-initial-value": true,
"no-ignored-return": true,
"no-in-misuse": true,
"no-inconsistent-return": true,
"no-misleading-array-reverse": true,
"no-misspelled-operator": true,
"no-multiline-string-literals": true,
"no-nested-template-literals": true,
"no-redundant-boolean": true,
"no-redundant-jump": true,
"no-redundant-parentheses": true,
"no-return-type-any": true,
"no-same-line-conditional": true,
"no-self-assignment": true,
"no-small-switch": true,
"no-statements-same-line": true,
"no-unconditional-jump": true,
"no-undefined-argument": true,
"no-unenclosed-multiline-block": true,
"no-unthrown-error": true,
"no-unused-array": true,
"no-use-of-empty-return-value": true,
"no-useless-cast": true,
"no-useless-increment": true,
"no-useless-intersection": true,
"no-variable-usage-before-declaration": true,
"parameters-max-number": true,
"prefer-default-last": true,
"prefer-immediate-return": true,
"prefer-promise-shorthand": true,
"use-primitive-type": true,
"use-type-alias": true
},
"linterOptions": {
"exclude": [
"node_modules/**/*.ts"
]
}
}

13130
ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff