= ({
11 | list,
12 | index,
13 | onChange = () => {},
14 | }) => {
15 | const [currentIndex, setCurrentIndex] = React.useState(index);
16 | const onPreviousClick = React.useCallback(() => {
17 | if (currentIndex > 0) {
18 | const newIndex = currentIndex - 1;
19 |
20 | setCurrentIndex(newIndex);
21 | onChange(list[newIndex], newIndex);
22 | }
23 | }, [currentIndex, onChange, list]);
24 | const onNextClick = React.useCallback(() => {
25 | if (currentIndex < list.length - 1) {
26 | const newIndex = currentIndex + 1;
27 |
28 | setCurrentIndex(newIndex);
29 | onChange(list[newIndex], newIndex);
30 | }
31 | }, [currentIndex, onChange, list]);
32 |
33 | React.useEffect(() => {
34 | setCurrentIndex(index);
35 | }, [index]);
36 |
37 | return (
38 |
39 |
43 |
44 |
45 |
46 | {list[currentIndex]}
47 |
48 |
52 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/webapp/src/components/ListItemPicker/interface.ts:
--------------------------------------------------------------------------------
1 | export interface IListItemPickerProps {
2 | list: string[];
3 | index: number;
4 | onChange?: (currentValue: string, currentIndex: number) => void;
5 | }
6 |
--------------------------------------------------------------------------------
/webapp/src/components/ListItemPicker/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export interface INavigationButtonProps {
4 | isUnavailable: boolean;
5 | }
6 |
7 | export const NavigationButton = styled.button`
8 | font-size: 16px;
9 | transition: opacity 300ms ease;
10 | cursor: ${({ isUnavailable }) => (isUnavailable ? 'default' : 'pointer')};
11 | opacity: ${({ isUnavailable }) => (isUnavailable ? 0.4 : 1)};
12 |
13 | &:focus {
14 | outline: 0;
15 | }
16 |
17 | &:focus,
18 | &:hover {
19 | ${({ isUnavailable }) => !isUnavailable && { opacity: 0.8 }}
20 | }
21 | `;
22 |
23 | export interface INavigationButtonProps {
24 | isUnavailable: boolean;
25 | }
26 |
27 | export const CurrentItemDisplay = styled.span`
28 | margin: 0 10px;
29 | `;
30 |
--------------------------------------------------------------------------------
/webapp/src/components/LoadingSpinner/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { faCog } from '@fortawesome/pro-solid-svg-icons';
3 | import { Spinner } from './styled';
4 | import { ILoadingSpinnerProps } from './interface';
5 |
6 | export const LoadingSpinner: React.FC = ({ className }) => {
7 | return ;
8 | };
9 |
--------------------------------------------------------------------------------
/webapp/src/components/LoadingSpinner/interface.ts:
--------------------------------------------------------------------------------
1 | export interface ILoadingSpinnerProps {
2 | className?: string
3 | }
--------------------------------------------------------------------------------
/webapp/src/components/LoadingSpinner/styled.tsx:
--------------------------------------------------------------------------------
1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
2 | import { keyframes } from '@emotion/core';
3 | import styled from '@emotion/styled';
4 |
5 | const rotate = keyframes`
6 | 50% {
7 | transform: rotate(180deg) scale(1.25);
8 | opacity: 1;
9 | }
10 |
11 | 100% {
12 | transform: rotate(360deg) scale(1);
13 | }
14 | `;
15 |
16 | export const Spinner = styled(FontAwesomeIcon)`
17 | opacity: 0.75;
18 | animation: ${rotate} 1500ms cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
19 | `;
20 |
--------------------------------------------------------------------------------
/webapp/src/components/MonthPicker/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment from 'moment';
3 | import { PoseGroup } from 'react-pose';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 | import { faTimes } from '@fortawesome/pro-solid-svg-icons';
6 | import { faCalendarAlt } from '@fortawesome/pro-regular-svg-icons';
7 | import { useQuery } from '@apollo/react-hooks';
8 | import Skeleton from 'react-loading-skeleton';
9 | import {
10 | IMonthPickerProps,
11 | ICalendarMonth,
12 | IMonthPickerValue,
13 | IMonthPickerComponentProps,
14 | IMonthPickerQuery,
15 | } from './interface';
16 | import { ListItemPicker } from '../ListItemPicker';
17 | import query from './query';
18 | import {
19 | Root,
20 | ScaledBg,
21 | RetractedTriggerBtn,
22 | RetractedTriggerBtnText,
23 | ErrorDisplay,
24 | Picker,
25 | ExpandedTriggerBtn,
26 | PickerYearContainer,
27 | PickerMonthContainer,
28 | PickerMonth,
29 | POSE_NAMES,
30 | } from './styled';
31 | import { MONTH_DATE_FORMAT } from '../../constants';
32 | import { useCalendarData } from './use-calendar-data';
33 |
34 | const today = moment();
35 |
36 | export const MonthPicker: React.FC = (props) => {
37 | const { loading, data, error } = useQuery(query);
38 |
39 | if (data && data.FirstRecord) {
40 | const {
41 | FirstRecord: { day },
42 | } = data;
43 |
44 | const firstRecordDayDate = moment(day);
45 |
46 | return (
47 |
52 | );
53 | }
54 |
55 | return (
56 |
57 | );
58 | };
59 |
60 | export const MonthPickerComponent: React.FC = ({
61 | minYear = today.format('YYYY'),
62 | maxYear = today.format('MM'),
63 | isLoading = false,
64 | hasError = false,
65 | minMonth,
66 | maxMonth,
67 | currentYear,
68 | currentMonth,
69 | onSwitch = () => { },
70 | }) => {
71 | const [isOpen, setIsOpen] = React.useState(false);
72 | const [isExpanded, setIsExpanded] = React.useState(false);
73 | const [browsingYear, setBrowsingYear] = React.useState(currentYear);
74 | const [currentValue, setCurrentValue] = React.useState({
75 | year: currentYear,
76 | month: currentMonth,
77 | });
78 | const { availableYearList, calendarMonthLabels } = useCalendarData({
79 | isLoading,
80 | minYear,
81 | maxYear,
82 | minMonth,
83 | maxMonth,
84 | });
85 |
86 | const onYearChange = React.useCallback(
87 | (year: string) => setBrowsingYear(year),
88 | [],
89 | );
90 |
91 | const onComponentLeave = React.useCallback(() => {
92 | if (isOpen) {
93 | setIsOpen(false);
94 | }
95 | }, [isOpen]);
96 |
97 | React.useEffect(
98 | () => setCurrentValue({ month: currentMonth, year: currentYear }),
99 | [currentMonth, currentYear],
100 | );
101 |
102 | React.useEffect(() => {
103 | if (availableYearList.length) {
104 | const availableYearIndex = availableYearList.indexOf(currentYear);
105 | const isYearUnavailable = availableYearIndex < 0;
106 |
107 | if (isYearUnavailable) {
108 | const latestAvailableYearIndex = availableYearList.length - 1;
109 |
110 | setBrowsingYear(availableYearList[latestAvailableYearIndex]);
111 | }
112 | }
113 | }, [availableYearList, currentYear]);
114 |
115 | return (
116 |
117 |
124 |
125 |
126 | {!isOpen && (
127 | !isLoading && setIsOpen(true)}
130 | onPoseComplete={(pose: string) => setIsExpanded(pose === 'exit')}
131 | >
132 |
133 | {isLoading ? (
134 |
135 | ) : (
136 | moment(
137 | `${currentValue.year}-${currentValue.month}`,
138 | MONTH_DATE_FORMAT,
139 | ).format('MMMM YYYY')
140 | )}
141 |
142 |
143 | {hasError ? (
144 |
145 | ) : (
146 |
147 | )}
148 |
149 | )}
150 |
151 | {isOpen && (
152 | setIsExpanded(pose !== 'exit')}
155 | >
156 | setIsOpen(false)}>
157 |
158 |
159 |
160 |
161 |
166 |
167 |
168 |
169 | {calendarMonthLabels[browsingYear].map(
170 | ({ text, month, year, isAvailable }: ICalendarMonth) => {
171 | const isCurrent =
172 | currentValue.month === month && currentValue.year === year;
173 |
174 | const onClick = () => {
175 | if (isAvailable) {
176 | const newValue = { year: browsingYear, month };
177 |
178 | setIsOpen(false);
179 | setCurrentValue(newValue);
180 | onSwitch(newValue);
181 | }
182 | };
183 |
184 | return (
185 |
191 | {text}
192 |
193 | );
194 | },
195 | )}
196 |
197 |
198 | )}
199 |
200 |
201 | );
202 | };
203 |
--------------------------------------------------------------------------------
/webapp/src/components/MonthPicker/interface.ts:
--------------------------------------------------------------------------------
1 | import { IFirstRecordResult } from '../../interfaces';
2 |
3 | export interface IMonthPickerValue {
4 | year: string;
5 | month: string;
6 | }
7 |
8 | export interface IMonthPickerProps {
9 | maxYear: string;
10 | maxMonth: string;
11 | currentYear: string;
12 | currentMonth: string;
13 | onSwitch?: (value: IMonthPickerValue) => void;
14 | }
15 |
16 | export interface IMonthPickerComponentProps extends IMonthPickerProps {
17 | minYear?: string;
18 | minMonth?: string;
19 | isLoading?: boolean;
20 | hasError?: boolean;
21 | }
22 |
23 | type FirstRecordData = Pick;
24 |
25 | export interface IMonthPickerQuery {
26 | FirstRecord: FirstRecordData;
27 | }
28 |
29 | export interface ICalendarMonth {
30 | text: string;
31 | month: string;
32 | year: string;
33 | isAvailable: boolean;
34 | }
35 |
36 | export interface ICalendarMonthPerYear {
37 | [key: string]: ICalendarMonth[];
38 | }
39 |
40 | export interface IMonthsWithDataPerYear {
41 | [key: string]: string[];
42 | }
43 |
--------------------------------------------------------------------------------
/webapp/src/components/MonthPicker/query.ts:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-boost';
2 |
3 | export default gql`
4 | query getFirstRecord {
5 | FirstRecord {
6 | day
7 | }
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/webapp/src/components/MonthPicker/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import posed from 'react-pose';
3 | import { QueryErrorIcon } from '../QueryErrorIcon';
4 | import { COLORS } from '../../global-styles';
5 |
6 | export const DIMENSIONS = {
7 | RETRACTED_HEIGHT: 45,
8 | RETRACTED_WIDTH: 175,
9 | EXPANDED_HEIGHT: 270,
10 | EXPANDED_WIDTH: 270,
11 | };
12 |
13 | export const POSE_NAMES = {
14 | BG_EXPAND: 'bg-expand',
15 | BG_RETRACT: 'bg-retract',
16 | };
17 |
18 | export const Root = styled.div`
19 | position: relative;
20 | width: ${DIMENSIONS.RETRACTED_WIDTH}px;
21 | height: ${DIMENSIONS.RETRACTED_HEIGHT}px;
22 | color: #e0e0e0;
23 |
24 | &:hover .scaled-bg {
25 | background-color: ${COLORS.DARK_GRAY};
26 | }
27 | `;
28 |
29 | export const animatedScaleBgTransition = {
30 | default: { duration: 550, ease: [0.68, -0.25, 0.265, 1.15] },
31 | };
32 | export const enterExitTransition = { default: { duration: 300 } };
33 |
34 | export const AnimatedRetractedTriggerBtn = posed.button({
35 | enter: {
36 | opacity: 1,
37 | transition: enterExitTransition,
38 | y: 0,
39 | delay: animatedScaleBgTransition.default.duration + 100,
40 | },
41 | exit: {
42 | opacity: 0,
43 | transition: enterExitTransition,
44 | y: -3,
45 | },
46 | });
47 | export const RetractedTriggerBtn = styled(AnimatedRetractedTriggerBtn)`
48 | position: absolute;
49 | top: 0;
50 | right: 0;
51 | display: flex;
52 | align-items: center;
53 | justify-content: center;
54 | flex-wrap: nowrap;
55 | cursor: pointer;
56 | z-index: 2;
57 | border: 0;
58 | background-color: transparent;
59 | font-size: 1em;
60 | width: ${DIMENSIONS.RETRACTED_WIDTH}px;
61 | height: ${DIMENSIONS.RETRACTED_HEIGHT}px;
62 | border-radius: 4px;
63 | color: currentColor;
64 |
65 | &:focus {
66 | outline: 0;
67 | }
68 | `;
69 |
70 | export const RetractedTriggerBtnText = styled.span`
71 | font-weight: 300;
72 | margin-right: 8px;
73 | white-space: nowrap;
74 | `;
75 |
76 | export const AnimatedPicker = posed.div({
77 | enter: {
78 | opacity: 1,
79 | transition: enterExitTransition,
80 | y: 0,
81 | delay: animatedScaleBgTransition.default.duration + 100,
82 | },
83 | exit: { opacity: 0, transition: enterExitTransition, y: -3 },
84 | });
85 | export const Picker = styled(AnimatedPicker)`
86 | position: absolute;
87 | top: 0;
88 | right: 0;
89 | z-index: 2;
90 | border: 0;
91 | background-color: transparent;
92 | font-size: 1em;
93 | width: ${DIMENSIONS.EXPANDED_WIDTH}px;
94 | height: ${DIMENSIONS.EXPANDED_HEIGHT}px;
95 | padding: 30px 15px 35px 15px;
96 |
97 | &:focus {
98 | outline: 0;
99 | }
100 | `;
101 |
102 | export const ExpandedTriggerBtn = styled.button`
103 | position: absolute;
104 | top: 5px;
105 | right: 5px;
106 | transition: opacity 300ms ease;
107 | width: 30px;
108 | height: 30px;
109 | padding: 0;
110 |
111 | &:focus {
112 | outline: 0;
113 | }
114 |
115 | &:focus,
116 | &:hover {
117 | opacity: 0.7;
118 | }
119 | `;
120 |
121 | export const PickerMonthContainer = styled.ul`
122 | width: 100%;
123 | height: 100%;
124 | display: grid;
125 | grid-template-columns: repeat(3, 1fr);
126 | column-gap: 10px;
127 | `;
128 |
129 | interface IPickerMonth {
130 | isAvailable: boolean;
131 | isCurrent: boolean;
132 | }
133 | export const PickerMonth = styled.li`
134 | display: flex;
135 | align-items: center;
136 | justify-content: center;
137 | font-size: 14px;
138 | transition: opacity 300ms ease, color 300ms ease;
139 | opacity: ${({ isAvailable }) => (isAvailable ? 1 : 0.4)};
140 | cursor: ${({ isAvailable, isCurrent }) => {
141 | if (!isAvailable) {
142 | return 'not-allowed';
143 | }
144 |
145 | if (isCurrent) {
146 | return 'default';
147 | }
148 |
149 | return 'pointer';
150 | }};
151 | color: ${({ isCurrent }) => (isCurrent ? COLORS.CHART_BAR : 'currentColor')};
152 |
153 | &:hover {
154 | ${({ isAvailable, isCurrent }) =>
155 | isAvailable &&
156 | !isCurrent && {
157 | opacity: 0.7,
158 | }}
159 | }
160 | `;
161 |
162 | export const AnimatedScaledBg = posed.div({
163 | [POSE_NAMES.BG_EXPAND]: {
164 | width: DIMENSIONS.EXPANDED_WIDTH,
165 | height: DIMENSIONS.EXPANDED_HEIGHT,
166 | transition: animatedScaleBgTransition,
167 | },
168 | [POSE_NAMES.BG_RETRACT]: {
169 | width: DIMENSIONS.RETRACTED_WIDTH,
170 | height: DIMENSIONS.RETRACTED_HEIGHT,
171 | transition: animatedScaleBgTransition,
172 | },
173 | });
174 |
175 | export const ScaledBg = styled(AnimatedScaledBg)`
176 | position: absolute;
177 | z-index: 1;
178 | top: 0;
179 | right: 0;
180 | width: ${DIMENSIONS.RETRACTED_WIDTH}px;
181 | height: ${DIMENSIONS.RETRACTED_HEIGHT}px;
182 | border-radius: 4px;
183 | background-color: ${COLORS.LIGHT_BLACK};
184 | transform-origin: top right;
185 | border: 1px solid ${COLORS.GRAY};
186 | transition: background-color 300ms ease;
187 | `;
188 |
189 | export const PickerYearContainer = styled.div`
190 | display: flex;
191 | align-items: center;
192 | justify-content: center;
193 | `;
194 |
195 | export const ErrorDisplay = styled(QueryErrorIcon)``;
196 |
--------------------------------------------------------------------------------
/webapp/src/components/MonthPicker/use-calendar-data.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import moment from 'moment';
3 | import { ICalendarMonthPerYear } from './interface';
4 | import { MONTH_DATE_FORMAT } from '../../constants';
5 |
6 | interface IUseCalendarDataParameters {
7 | isLoading: boolean;
8 | minYear: string;
9 | maxYear: string;
10 | maxMonth: string;
11 | minMonth?: string;
12 | }
13 |
14 | export function useCalendarData({
15 | isLoading,
16 | minYear,
17 | maxYear,
18 | minMonth,
19 | maxMonth,
20 | }: IUseCalendarDataParameters) {
21 | const [availableYearList, setAvailableYearList] = useState([]);
22 | const [calendarMonthLabels, setCalendarMonthLabels] = useState<
23 | ICalendarMonthPerYear
24 | >({});
25 |
26 | useEffect(() => {
27 | if (minMonth) {
28 | const startDate = moment(`${minYear}-${minMonth}`, MONTH_DATE_FORMAT);
29 | const endDate = moment(`${maxYear}-${maxMonth}`, MONTH_DATE_FORMAT).endOf(
30 | 'month',
31 | );
32 | const minYearNumber = parseInt(minYear, 10);
33 | const maxYearNumber = parseInt(maxYear, 10);
34 | const yearCount = maxYearNumber - minYearNumber + 1;
35 | const yearsStartDate = moment(startDate).subtract(1, 'year');
36 |
37 | const years = Array(yearCount)
38 | .fill(yearCount)
39 | .reduce(
40 | (accum: {}) => ({
41 | ...accum,
42 | [yearsStartDate.add(1, 'year').format('YYYY')]: [],
43 | }),
44 | {},
45 | );
46 |
47 | const calendarMonthsStartDate = moment('12-01', 'MM-DD'); // starts at December because first iteration already adds 1 month
48 | const yearList = Object.keys(years);
49 | const calendarMonthsPerYear: ICalendarMonthPerYear = yearList.reduce(
50 | (accum: { [key: string]: any }, year: string) => {
51 | return {
52 | ...accum,
53 | [year]: Array(12)
54 | .fill(12)
55 | .map(() => {
56 | const referenceDate = calendarMonthsStartDate.add(1, 'month');
57 | const month = referenceDate.format('MM');
58 | const currentDate = moment(
59 | `${year}-${month}`,
60 | MONTH_DATE_FORMAT,
61 | );
62 | const isAvailable =
63 | currentDate.isSameOrAfter(startDate) &&
64 | currentDate.isSameOrBefore(endDate);
65 |
66 | return {
67 | year,
68 | month,
69 | text: referenceDate.format('MMM').toUpperCase(),
70 | isAvailable,
71 | };
72 | }),
73 | };
74 | },
75 | {},
76 | );
77 |
78 | setAvailableYearList(yearList);
79 | setCalendarMonthLabels(calendarMonthsPerYear);
80 | }
81 | }, [minYear, maxYear, minMonth, maxMonth, isLoading]);
82 |
83 | return {
84 | availableYearList,
85 | calendarMonthLabels,
86 | };
87 | }
88 |
--------------------------------------------------------------------------------
/webapp/src/components/Navigation/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Root, Link } from './styled';
3 |
4 | export const Navigation: React.FC = () => {
5 | return (
6 |
7 |
8 | Today
9 |
10 |
11 | Period
12 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/webapp/src/components/Navigation/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { NavLink } from 'react-router-dom';
3 | import { COLORS } from '../../global-styles';
4 |
5 | export const Root = styled.nav`
6 | display: flex;
7 | align-items: center;
8 | justify-content: center;
9 | width: 100%;
10 | height: 50px;
11 | background-color: ${COLORS.LIGHT_BLACK};
12 | border-bottom: 1px solid #aaa;
13 | `;
14 |
15 | export const Link = styled(NavLink)`
16 | &:visited {
17 | color: currentColor;
18 | }
19 |
20 | position: relative;
21 | top: -2px;
22 | opacity: 0.6;
23 | color: ${COLORS.WHITE};
24 | text-decoration: none;
25 | font-size: 20px;
26 | transition: opacity 300ms ease-out;
27 |
28 | &::before,
29 | &::after {
30 | content: '';
31 | position: absolute;
32 | width: 55%;
33 | height: 1px;
34 | bottom: -2px;
35 | background-color: currentColor;
36 | opacity: 0.85;
37 | transform: scaleX(0);
38 | transition: transform 300ms ease-out;
39 | }
40 |
41 | &::before {
42 | right: -5%;
43 | transform-origin: left;
44 | }
45 |
46 | &::after {
47 | left: -5%;
48 | transform-origin: right;
49 | }
50 |
51 | &:not(:last-child) {
52 | margin-right: 30px;
53 | }
54 |
55 | &.active {
56 | opacity: 1;
57 |
58 | &::before,
59 | &::after {
60 | transform: scaleX(1);
61 | }
62 | }
63 |
64 | &:hover:not(.active) {
65 | opacity: 0.8;
66 | }
67 | `;
68 |
--------------------------------------------------------------------------------
/webapp/src/components/Period/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment, { Moment } from 'moment';
3 | import { Card } from '../Card';
4 | import { PeriodBarChart } from '../PeriodBarChart';
5 | import { MonthPicker } from '../MonthPicker';
6 | import { IMonthPickerValue } from '../MonthPicker/interface';
7 | import { Averages } from '../Averages';
8 | import { ChartContainer, Root, MonthPickerContainer } from './styled';
9 | import { DATE_FORMAT, MONTH_DATE_FORMAT } from '../../constants';
10 |
11 | const PERIOD_QUERY_STRING = 'p';
12 |
13 | interface IPeriodProps { }
14 |
15 | const getPeriodEnd = (date: Moment): Moment =>
16 | moment(date)
17 | .endOf('month')
18 | .add(1, 'day');
19 |
20 | const getPeriodStart = (date: Moment): Moment => moment(date).startOf('month');
21 |
22 | const today = moment();
23 | let defaultPeriodStartDate = getPeriodStart(today);
24 | let defaultPeriodEndDate = getPeriodEnd(today);
25 |
26 | const urlParams = new URLSearchParams(window.location.search);
27 |
28 | for (const queryStringEntry of urlParams.entries()) {
29 | if (queryStringEntry[0] === PERIOD_QUERY_STRING) {
30 | const date = moment(queryStringEntry[1], MONTH_DATE_FORMAT);
31 |
32 | if (date.isValid()) {
33 | defaultPeriodStartDate = getPeriodStart(date);
34 | defaultPeriodEndDate = getPeriodEnd(date);
35 | }
36 | }
37 | }
38 |
39 | export const Period: React.FC = () => {
40 | const [periodStart, setPeriodStart] = React.useState(
41 | defaultPeriodStartDate.format(DATE_FORMAT),
42 | );
43 | const [periodEnd, setPeriodEnd] = React.useState(
44 | defaultPeriodEndDate.format(DATE_FORMAT),
45 | );
46 | const [currentSelectedMonth, setCurrentSelectedMonth] = React.useState(
47 | defaultPeriodStartDate.format('MM'),
48 | );
49 | const [currentSelectedYear, setCurrentSelectedYear] = React.useState(
50 | defaultPeriodStartDate.format('YYYY'),
51 | );
52 | const onPeriodSwitch = React.useCallback(
53 | ({ year, month }: IMonthPickerValue) => {
54 | const newPeriodStartDate = moment(`${year}-${month}-01`, DATE_FORMAT);
55 | const newPeriodEndDate = moment(newPeriodStartDate).endOf('month');
56 |
57 | setPeriodStart(newPeriodStartDate.format(DATE_FORMAT));
58 | setPeriodEnd(newPeriodEndDate.add(1, 'day').format(DATE_FORMAT));
59 | setCurrentSelectedMonth(newPeriodStartDate.format('MM'));
60 | setCurrentSelectedYear(newPeriodStartDate.format('YYYY'));
61 | },
62 | [],
63 | );
64 |
65 | return (
66 |
67 |
68 |
69 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | );
86 | };
87 |
--------------------------------------------------------------------------------
/webapp/src/components/Period/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Root = styled.div`
4 | position: relative;
5 | width: 100%;
6 | display: flex;
7 | align-items: center;
8 | justify-content: center;
9 | `;
10 |
11 | export const MonthPickerContainer = styled.div`
12 | width: 100%;
13 | display: flex;
14 | justify-content: flex-end;
15 | margin-bottom: 40px;
16 | `;
17 |
18 | export const ChartContainer = styled.div`
19 | width: 100%;
20 | margin-bottom: 60px;
21 | `;
22 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/__mock__/data.ts:
--------------------------------------------------------------------------------
1 | export const data = [
2 | { "day": "2019-12-02", "totalTimeAtOffice": { "hours": 8, "minutes": 12, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 47, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 1, "minutes": 4, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-03", "totalTimeAtOffice": { "hours": 8, "minutes": 22, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 59, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 52, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-04", "totalTimeAtOffice": { "hours": 9, "minutes": 17, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 54, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 52, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-05", "totalTimeAtOffice": { "hours": 8, "minutes": 31, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 1, "minutes": 8, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 1, "minutes": 1, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-06", "totalTimeAtOffice": { "hours": 7, "minutes": 55, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-09", "totalTimeAtOffice": { "hours": 8, "minutes": 1, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 58, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 58, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-10", "totalTimeAtOffice": { "hours": 8, "minutes": 19, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 48, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 52, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-11", "totalTimeAtOffice": { "hours": 8, "minutes": 47, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 1, "minutes": 8, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 51, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-12", "totalTimeAtOffice": { "hours": 7, "minutes": 47, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 54, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-13", "totalTimeAtOffice": { "hours": 8, "minutes": 28, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-16", "totalTimeAtOffice": { "hours": 8, "minutes": 20, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 53, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 1, "minutes": 1, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-17", "totalTimeAtOffice": { "hours": 8, "minutes": 40, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 47, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 1, "minutes": 3, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-18", "totalTimeAtOffice": { "hours": 8, "minutes": 11, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 52, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 56, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-19", "totalTimeAtOffice": { "hours": 7, "minutes": 25, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 55, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-20", "totalTimeAtOffice": { "hours": 8, "minutes": 2, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-23", "totalTimeAtOffice": { "hours": 8, "minutes": 49, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 50, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 1, "minutes": 1, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-24", "totalTimeAtOffice": { "hours": 7, "minutes": 31, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-27", "totalTimeAtOffice": { "hours": 8, "minutes": 53, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-30", "totalTimeAtOffice": { "hours": 7, "minutes": 24, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }, { "day": "2019-12-31", "totalTimeAtOffice": { "hours": 6, "minutes": 54, "__typename": "TotalTimeAtOfficeResult" }, "totalMorningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalMorningCommuteTime" }, "totalEveningCommuteTime": { "hours": 0, "minutes": 0, "__typename": "TotalEveningCommuteTime" }, "__typename": "TimetableChartResult" }
3 | ];
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/animations.ts:
--------------------------------------------------------------------------------
1 | import anime from 'animejs';
2 |
3 | const TIMELINE_EASING = 'easeOutCubic';
4 | const BARS_EASING = 'easeInOutCubic';
5 | export const ANIMATION_IDS = {
6 | BAR_ANIMATED_RECTANGLE: 'BAR_RECT',
7 | BAR_Y_VALUE_LABEL: 'Y_VALUE',
8 | BAR_X_VALUE_LABEL: 'X_VALUE',
9 | };
10 |
11 | const getAnimationTarget = (id: string) => `[data-animation-id="${id}"]`;
12 |
13 | export function createBarsInAnimationTimeline() {
14 | const animationTimeline = anime.timeline({
15 | autoplay: false,
16 | easing: TIMELINE_EASING,
17 | });
18 |
19 | // X value labels animation
20 | animationTimeline.add({
21 | targets: getAnimationTarget(ANIMATION_IDS.BAR_X_VALUE_LABEL),
22 | duration: 200,
23 | opacity: [0, 1],
24 | scaleX: [0.875, 1],
25 | scaleY: [0.875, 1],
26 | translateY: [2, 0],
27 | });
28 |
29 | // Chart bars animation
30 | animationTimeline.add({
31 | targets: getAnimationTarget(ANIMATION_IDS.BAR_ANIMATED_RECTANGLE),
32 | duration: 800,
33 | translateY: ['100%', 0],
34 | delay: (el: any, i: number) => i * 30,
35 | easing: BARS_EASING,
36 | });
37 |
38 | // Y value labels animation
39 | animationTimeline.add({
40 | targets: getAnimationTarget(ANIMATION_IDS.BAR_Y_VALUE_LABEL),
41 | duration: 300,
42 | opacity: [0, 1],
43 | scaleX: [0.875, 1],
44 | scaleY: [0.875, 1],
45 | translateY: [2, 0],
46 | });
47 |
48 | return animationTimeline;
49 | }
50 |
51 | export function createBarsOutAnimationTimeline() {
52 | const animationTimeline = anime.timeline({
53 | autoplay: false,
54 | easing: TIMELINE_EASING,
55 | });
56 |
57 | // Y value labels animation
58 | animationTimeline.add({
59 | targets: getAnimationTarget(ANIMATION_IDS.BAR_Y_VALUE_LABEL),
60 | duration: 200,
61 | opacity: [1, 0],
62 | scaleX: [1, 0.875],
63 | scaleY: [1, 0.875],
64 | translateY: [0, 2],
65 | });
66 |
67 | // Chart bars animation
68 | animationTimeline.add({
69 | targets: getAnimationTarget(ANIMATION_IDS.BAR_ANIMATED_RECTANGLE),
70 | duration: 600,
71 | translateY: [0, '100%'],
72 | easing: BARS_EASING,
73 | });
74 |
75 | // X value labels animation
76 | animationTimeline.add({
77 | targets: getAnimationTarget(ANIMATION_IDS.BAR_X_VALUE_LABEL),
78 | duration: 200,
79 | opacity: [1, 0],
80 | scaleX: [1, 0.875],
81 | scaleY: [1, 0.875],
82 | translateY: [0, 2],
83 | });
84 |
85 | return animationTimeline;
86 | }
87 |
88 | export function createReverseBarsOutAnimationTimeline() {
89 | const animationTimeline = anime.timeline({
90 | autoplay: false,
91 | easing: TIMELINE_EASING,
92 | });
93 |
94 | // Y value labels animation
95 | animationTimeline.add({
96 | targets: getAnimationTarget(ANIMATION_IDS.BAR_Y_VALUE_LABEL),
97 | duration: 50,
98 | opacity: 0,
99 | scaleX: 0.875,
100 | scaleY: 0.875,
101 | translateY: 2,
102 | });
103 |
104 | // Chart bars animation
105 | animationTimeline.add({
106 | targets: getAnimationTarget(ANIMATION_IDS.BAR_ANIMATED_RECTANGLE),
107 | duration: 450,
108 | translateY: '100%',
109 | easing: 'easeInOutCubic',
110 | });
111 |
112 | // X value labels animation
113 | animationTimeline.add({
114 | targets: getAnimationTarget(ANIMATION_IDS.BAR_X_VALUE_LABEL),
115 | duration: 100,
116 | opacity: [1, 0],
117 | scaleX: [1, 0.875],
118 | scaleY: [1, 0.875],
119 | translateY: [0, 2],
120 | });
121 |
122 | return animationTimeline;
123 | }
124 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/carousel-chart.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Slider } from '../Slider';
3 | import { BarsContainer } from './styled';
4 | import { ICarouselChartProps } from './interface';
5 |
6 | export const BARS_PER_PAGE = 5;
7 | export const SLIDER_SPEED = 800;
8 | const SLIDER_EASING = 'cubic-bezier(0.645, 0.045, 0.355, 1)'; // easeInOutCubic
9 |
10 | const CarouselChartComponent = React.forwardRef(
11 | (props, ref) => {
12 | const { numberOfSlides, chartData, renderChartBars } = props;
13 | const slides = Array(numberOfSlides)
14 | .fill(numberOfSlides)
15 | .map((current, i) => {
16 | const currentPage = i + 1;
17 |
18 | return (
19 |
24 | {chartData
25 | .slice(
26 | currentPage * BARS_PER_PAGE - BARS_PER_PAGE,
27 | currentPage * BARS_PER_PAGE,
28 | )
29 | .map(renderChartBars)}
30 |
31 | );
32 | });
33 |
34 | return (
35 |
43 | {slides}
44 |
45 | );
46 | },
47 | );
48 |
49 | export const CarouselChart = React.memo(CarouselChartComponent);
50 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/chart-bar.tsx:
--------------------------------------------------------------------------------
1 | import { formatMinutes, getBarHeight, getTotalMinutesFromTime } from './utils';
2 | import {
3 | BarChartXValue,
4 | BarChartYValueLabel,
5 | BarContainer,
6 | BarRectangle,
7 | BarRectangleContainer,
8 | DIMENSIONS,
9 | } from './styled';
10 | import { ANIMATION_IDS } from './animations';
11 | import moment from 'moment';
12 | import React from 'react';
13 | import { IChartBarProps } from './interface';
14 |
15 | export const ChartBar = React.forwardRef(
16 | (
17 | { day, hours, minutes, chartDataMaxYValue, barWidth, isMobileView },
18 | ref,
19 | ) => {
20 | const totalMinutes = getTotalMinutesFromTime({ hours, minutes });
21 | const shouldDisplayYValue = !(hours === 0 && minutes < 30);
22 | const height = getBarHeight(
23 | DIMENSIONS.CHART_HEIGHT,
24 | chartDataMaxYValue,
25 | totalMinutes,
26 | );
27 |
28 | return (
29 |
30 |
31 |
35 |
36 |
37 | {shouldDisplayYValue && (
38 |
41 | {hours}h{formatMinutes(minutes)}
42 |
43 | )}
44 |
45 |
49 | {moment(day).format('DD/MM')}
50 |
51 |
52 | );
53 | },
54 | );
55 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import debounce from 'lodash.debounce';
3 | import { useQuery } from '@apollo/react-hooks';
4 | import usePrevious from 'react-hooks-use-previous';
5 | import {
6 | IPeriodChartProps,
7 | IPeriodChartComponentProps,
8 | IPeriodQueryData,
9 | IChartData,
10 | } from './interface';
11 | import {
12 | getTotalMinutesFromTime,
13 | getArrayMaxValue,
14 | } from './utils';
15 | import query from './query';
16 | import { StatusInformation } from './status-information';
17 | import { ITimetableChartResult } from '../../interfaces';
18 | import {
19 | createBarsInAnimationTimeline,
20 | createBarsOutAnimationTimeline,
21 | createReverseBarsOutAnimationTimeline,
22 | } from './animations';
23 | import { BARS_PER_PAGE, SLIDER_SPEED, CarouselChart } from './carousel-chart';
24 | import { ChartBar } from "./chart-bar";
25 | import {
26 | DIMENSIONS,
27 | Root,
28 | BarsContainer,
29 | StatusInformationContainer,
30 | BarChartAxis,
31 | ChartBarsSlider,
32 | } from './styled';
33 |
34 | const chartContainerRef = React.createRef();
35 | const sliderRef = React.createRef();
36 | const BAR_WIDTH_INITIAL_VALUE = -1;
37 |
38 | export const PeriodBarChart: React.FC = ({
39 | periodStart,
40 | periodEnd,
41 | }) => {
42 | const { loading, data, error } = useQuery(query, {
43 | variables: {
44 | periodStart,
45 | periodEnd,
46 | },
47 | });
48 | const periodId = `${periodStart}_${periodEnd}`;
49 |
50 | if (!loading && data && data.Period) {
51 | const {
52 | Period: { timetableChart },
53 | } = data;
54 |
55 | return (
56 |
57 | );
58 | }
59 |
60 | return (
61 |
66 | );
67 | };
68 |
69 | const DATA_INITIAL_PROP: any[] = [];
70 |
71 | export const PeriodBarChartComponent: React.FC = ({
72 | data = DATA_INITIAL_PROP,
73 | periodId,
74 | isLoading,
75 | hasError,
76 | }) => {
77 | const animateBarsInTimeline = React.useRef();
78 | const [isAnimating, setIsAnimating] = React.useState(false);
79 | const [chartData, setChartData] = React.useState(data);
80 | const [areBarsRendered, setAreBarsRendered] = React.useState(false);
81 | const [chartDoneAnimating, setChartDoneAnimating] = React.useState(
82 | false,
83 | );
84 | const [isMobileView, setIsMobileView] = React.useState(false);
85 | const [barWidth, setBarWidth] = React.useState(
86 | BAR_WIDTH_INITIAL_VALUE,
87 | );
88 | const [windowWidth, setWindowWidth] = React.useState(
89 | window.innerWidth,
90 | );
91 | const numberOfSlides = Math.ceil(chartData.length / BARS_PER_PAGE);
92 | const previousPeriodId = usePrevious(periodId, periodId);
93 | const hasPeriodChanged = periodId !== previousPeriodId;
94 | const isDataLoaded = data === chartData;
95 | const noData = data.length === 0;
96 | const chartDataMaxYValue = React.useMemo(() => {
97 | return getArrayMaxValue(chartData, (day: any) =>
98 | getTotalMinutesFromTime(day.totalTimeAtOffice),
99 | );
100 | }, [chartData]);
101 |
102 | React.useEffect(function windowResizeHook() {
103 | const onResize = debounce(() => setWindowWidth(window.innerWidth), 100);
104 |
105 | window.addEventListener('resize', onResize);
106 |
107 | return () => window.removeEventListener('resize', onResize);
108 | }, []);
109 |
110 | const renderChartBars = React.useCallback(
111 | ({
112 | totalTimeAtOffice,
113 | day,
114 | }: ITimetableChartResult) => {
115 | const handleBarRef = (node: HTMLDivElement) => {
116 | if (node === null) {
117 | return setAreBarsRendered(false);
118 | }
119 |
120 | setAreBarsRendered(true);
121 | };
122 |
123 | return ;
124 | },
125 | [chartDataMaxYValue, barWidth, isMobileView],
126 | );
127 |
128 | React.useEffect(
129 | function barWidthCalculationEffect() {
130 | if (chartContainerRef.current && chartData.length) {
131 | const { offsetWidth } = chartContainerRef.current;
132 | const predictedBarWidth = Math.round(
133 | offsetWidth / chartData.length - DIMENSIONS.BAR_GUTTER,
134 | );
135 |
136 | if (predictedBarWidth <= DIMENSIONS.MIN_BAR_WIDTH) {
137 | const mobilePredictedBarWidth = Math.round(
138 | offsetWidth / BARS_PER_PAGE - DIMENSIONS.BAR_GUTTER,
139 | );
140 |
141 | setBarWidth(mobilePredictedBarWidth);
142 | setIsMobileView(true);
143 |
144 | return;
145 | }
146 |
147 | setBarWidth(predictedBarWidth);
148 | setIsMobileView(false);
149 | }
150 | },
151 | [windowWidth, periodId, chartData],
152 | );
153 |
154 | React.useEffect(
155 | function mobileViewSwitchEffect() {
156 | setChartDoneAnimating(false);
157 | },
158 | [isMobileView],
159 | );
160 |
161 | React.useEffect(
162 | function reverseAnimationEffect() {
163 | if (hasPeriodChanged && isAnimating && !chartDoneAnimating) {
164 | animateBarsInTimeline.current.pause();
165 |
166 | const animationTimeline = createReverseBarsOutAnimationTimeline();
167 |
168 | animationTimeline.complete = () => {
169 | sliderRef.current && sliderRef.current.slickGoTo(0, true);
170 |
171 | window.requestIdleCallback(() => {
172 | setIsAnimating(false);
173 | setChartDoneAnimating(false);
174 | });
175 | };
176 |
177 | setIsAnimating(true);
178 | window.requestIdleCallback(animationTimeline.play);
179 | }
180 | },
181 | [hasPeriodChanged, data, chartDoneAnimating, isAnimating],
182 | );
183 |
184 | React.useEffect(
185 | function animateBarsOut() {
186 | if (hasPeriodChanged && chartDoneAnimating && data !== chartData) {
187 | const animationTimeline = createBarsOutAnimationTimeline();
188 |
189 | animationTimeline.complete = () => {
190 | sliderRef.current && sliderRef.current.slickGoTo(0, true);
191 |
192 | window.requestIdleCallback(() => {
193 | setIsAnimating(false);
194 | setChartDoneAnimating(false);
195 | });
196 | };
197 |
198 | setIsAnimating(true);
199 | window.requestIdleCallback(animationTimeline.play);
200 | }
201 | },
202 | [chartDoneAnimating, data, chartData, hasPeriodChanged],
203 | );
204 |
205 | React.useEffect(
206 | function animateBarsInEffect() {
207 | if (
208 | areBarsRendered &&
209 | isDataLoaded &&
210 | chartData.length &&
211 | !chartDoneAnimating
212 | ) {
213 | const animationTimeline = createBarsInAnimationTimeline();
214 |
215 | animateBarsInTimeline.current = animationTimeline;
216 | animationTimeline.complete = () => {
217 | if (isMobileView) {
218 | window.requestIdleCallback(
219 | () =>
220 | sliderRef.current &&
221 | sliderRef.current.slickGoTo(numberOfSlides),
222 | );
223 |
224 | setTimeout(
225 | () =>
226 | window.requestIdleCallback(() => {
227 | setIsAnimating(false);
228 | setChartDoneAnimating(true);
229 | }),
230 | SLIDER_SPEED,
231 | );
232 | } else {
233 | window.requestIdleCallback(() => {
234 | setIsAnimating(false);
235 | setChartDoneAnimating(true);
236 | });
237 | }
238 | };
239 |
240 | setIsAnimating(true);
241 | window.requestIdleCallback(animationTimeline.play);
242 | }
243 | },
244 | [
245 | areBarsRendered,
246 | chartDoneAnimating,
247 | data,
248 | chartData,
249 | isMobileView,
250 | numberOfSlides,
251 | isDataLoaded,
252 | ],
253 | );
254 |
255 | React.useEffect(
256 | function freshDataEffect() {
257 | if (!chartDoneAnimating && !isAnimating) {
258 | setChartData(data);
259 | }
260 | },
261 | [chartDoneAnimating, isAnimating, data],
262 | );
263 |
264 | if (isMobileView) {
265 | return (
266 |
267 |
268 | {!chartDoneAnimating && (
269 |
270 |
275 |
276 | )}
277 |
278 |
284 |
285 |
286 |
287 |
288 | );
289 | }
290 |
291 | return (
292 |
293 | {!chartDoneAnimating && (
294 |
295 |
300 |
301 | )}
302 |
303 | {chartData.map(renderChartBars)}
304 |
305 |
306 |
307 | );
308 | };
309 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/interface.ts:
--------------------------------------------------------------------------------
1 | import {IPeriodResult, ITime, ITimetableChartResult} from '../../interfaces';
2 |
3 | type RequestIdleCallbackHandle = any;
4 | type RequestIdleCallbackOptions = {
5 | timeout: number;
6 | };
7 | type RequestIdleCallbackDeadline = {
8 | readonly didTimeout: boolean;
9 | timeRemaining: () => number;
10 | };
11 |
12 | declare global {
13 | interface Window {
14 | requestIdleCallback: (
15 | callback: (deadline: RequestIdleCallbackDeadline) => void,
16 | opts?: RequestIdleCallbackOptions,
17 | ) => RequestIdleCallbackHandle;
18 | cancelIdleCallback: (handle: RequestIdleCallbackHandle) => void;
19 | }
20 | }
21 |
22 | export interface IPeriodChartProps {
23 | periodStart: string;
24 | periodEnd: string;
25 | }
26 |
27 | type PeriodQueryData = Pick;
28 |
29 | export interface IPeriodQueryData {
30 | Period: PeriodQueryData;
31 | }
32 |
33 | export type IChartData = ITimetableChartResult[];
34 |
35 | export interface IPeriodChartComponentProps {
36 | data?: IChartData;
37 | periodId: string;
38 | isLoading?: boolean;
39 | hasError?: boolean;
40 | }
41 |
42 | export interface IStatusInfoProps
43 | extends Pick {
44 | noData?: boolean;
45 | }
46 |
47 | export interface ICarouselChartProps {
48 | numberOfSlides: number;
49 | chartData: IChartData;
50 | renderChartBars(chartResult: ITimetableChartResult): JSX.Element;
51 | }
52 |
53 | export interface IChartBarProps {
54 | barWidth: number;
55 | hours: number;
56 | minutes: number;
57 | chartDataMaxYValue: number;
58 | isMobileView: boolean;
59 | day: string;
60 | }
61 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/query.ts:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-boost';
2 |
3 | export default gql`
4 | query getPeriod($periodStart: String!, $periodEnd: String!) {
5 | Period(periodStart: $periodStart, periodEnd: $periodEnd) {
6 | timetableChart {
7 | day
8 |
9 | totalTimeAtOffice {
10 | hours
11 | minutes
12 | }
13 | totalMorningCommuteTime {
14 | hours
15 | minutes
16 | }
17 | totalEveningCommuteTime {
18 | hours
19 | minutes
20 | }
21 | }
22 | }
23 | }
24 | `;
25 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/status-information.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import posed, {PoseGroup} from "react-pose";
3 | import styled from '@emotion/styled';
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5 | import { faEmptySet } from '@fortawesome/pro-regular-svg-icons';
6 | import { faIcon as errorIcon } from '../QueryErrorIcon';
7 | import { LoadingSpinner } from '../LoadingSpinner';
8 | import { IStatusInfoProps } from './interface';
9 |
10 | const AnimatedRoot = posed.div({
11 | enter: { opacity: 1, delay: 300 },
12 | exit: { opacity: 0 },
13 | });
14 |
15 | interface IRootProps {
16 | width?: number;
17 | }
18 |
19 | const Root = styled(AnimatedRoot)`
20 | position: absolute;
21 | top: 50%;
22 | left: 50%;
23 | transform: translate(-50%, -50%);
24 | display: flex;
25 | flex-direction: column;
26 | align-items: center;
27 | justify-content: center;
28 | text-align: center;
29 | width: ${({ width }) => `${width}px` || 'auto'}
30 | `;
31 |
32 | const Icon = styled(FontAwesomeIcon)`
33 | font-size: 60px;
34 | margin-bottom: 16px;
35 | `;
36 |
37 | const Text = styled.div`
38 | font-size: 18px;
39 | `;
40 |
41 | export const StatusInformation: React.FC = ({
42 | noData,
43 | hasError,
44 | isLoading,
45 | }) => {
46 | const renderContent = React.useCallback(() => {
47 | if (isLoading) {
48 | return (
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | if (hasError) {
56 | return (
57 |
58 |
59 |
60 | There was a problem while fetching data for the selected period
61 |
62 |
63 | );
64 | }
65 |
66 | if (noData) {
67 | return (
68 |
69 |
70 | No recorded data for the selected period
71 |
72 | );
73 | }
74 |
75 | return undefined;
76 | }, [isLoading, hasError, noData]);
77 |
78 | return (
79 |
80 | {renderContent()}
81 |
82 | );
83 | };
84 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import posed from 'react-pose';
3 | import { COLORS } from '../../global-styles';
4 |
5 | export const DIMENSIONS = {
6 | CHART_HEIGHT: 375,
7 | BAR_GUTTER: 8,
8 | MIN_BAR_WIDTH: 25,
9 | };
10 |
11 | export const Root = styled.div`
12 | position: relative;
13 | `;
14 |
15 | export const StatusInformationContainer = styled.div`
16 | position: absolute;
17 | height: 100%;
18 | width: 100%;
19 | font-size: 40px;
20 | `;
21 |
22 | export const ChartBarsSlider = styled.div`
23 | height: ${DIMENSIONS.CHART_HEIGHT}px;
24 | `;
25 |
26 | export const AnimatedBarLabel = posed.div({
27 | visible: {
28 | opacity: 1,
29 | scaleX: 1,
30 | scaleY: 1,
31 | y: 0,
32 | transition: {
33 | default: { duration: 350, ease: [0.215, 0.61, 0.355, 1] },
34 | },
35 | },
36 | invisible: {
37 | opacity: 0,
38 | scaleX: 0.875,
39 | scaleY: 0.875,
40 | y: 2,
41 | },
42 | });
43 |
44 | export const BarChartYValueLabel = styled.div`
45 | position: absolute;
46 | width: 100%;
47 | top: 7px;
48 | left: 0;
49 | font-size: 1em;
50 | text-align: center;
51 | transform-origin: center center;
52 | will-change: transform;
53 | opacity: 0;
54 | `;
55 |
56 | interface IBarChartXValueProps {
57 | isMobile?: boolean;
58 | }
59 |
60 | export const BarChartXValue = styled.div`
61 | position: absolute;
62 | width: 100%;
63 | bottom: ${({ isMobile }) => (isMobile ? '5px' : '-26px')};
64 | left: 0;
65 | font-size: 1.2em;
66 | text-align: center;
67 | transform-origin: center center;
68 | will-change: transform;
69 | opacity: 0;
70 | `;
71 |
72 | interface IBarsContainerProps {
73 | isCarouselItem?: boolean;
74 | isCentered?: boolean;
75 | }
76 |
77 | export const BarsContainer = styled.div`
78 | display: ${({ isCarouselItem }) =>
79 | isCarouselItem ? 'inline-flex !important' : 'flex'};
80 | align-items: flex-end;
81 | justify-content: ${({ isCentered }) =>
82 | isCentered ? 'flex-start' : 'center'};
83 | padding: 0 ${DIMENSIONS.BAR_GUTTER / 2}px;
84 | height: ${DIMENSIONS.CHART_HEIGHT}px;
85 | outline: 0;
86 | `;
87 |
88 | function getBarContainerFontSize(barWidth: number) {
89 | if (barWidth >= 50) {
90 | return '12px';
91 | }
92 |
93 | if (barWidth >= 40) {
94 | return '11px';
95 | }
96 |
97 | if (barWidth >= 30) {
98 | return '10px';
99 | }
100 |
101 | return '9px';
102 | }
103 |
104 | interface IBarContainerProps {
105 | barWidth: number;
106 | }
107 |
108 | export const BarContainer = styled.div`
109 | position: relative;
110 | flex: 1;
111 | font-size: ${({ barWidth }) => getBarContainerFontSize(barWidth)};
112 | width: ${({ barWidth }) => barWidth}px;
113 | max-width: 100px;
114 |
115 | &:not(:first-of-type) {
116 | margin-left: ${DIMENSIONS.BAR_GUTTER}px;
117 | }
118 | `;
119 |
120 | interface IBarRectangleContainerProps {
121 | barHeight: number;
122 | }
123 |
124 | export const BarRectangleContainer = styled.div`
125 | height: ${({ barHeight }) => barHeight}px;
126 | overflow: hidden;
127 | `;
128 |
129 | export const AnimatedBar = posed.div({
130 | visible: {
131 | y: 0,
132 | transition: ({ index }: { index: number }) => ({
133 | y: {
134 | duration: 900,
135 | ease: [0.645, 0.045, 0.355, 1],
136 | delay: index * 30,
137 | },
138 | }),
139 | },
140 | invisible: { y: '100%' },
141 | });
142 |
143 | export const BarRectangle = styled.div`
144 | height: 100%;
145 | border-top-left-radius: 5px;
146 | border-top-right-radius: 5px;
147 | background-color: ${COLORS.CHART_BAR};
148 | transform-origin: bottom left;
149 | will-change: transform;
150 | transform: translateY(100%);
151 | `;
152 |
153 | export const BarChartAxis = styled.div`
154 | height: 4px;
155 | width: 100%;
156 | background-color: ${COLORS.GRAY};
157 | border-radius: 8px;
158 | `;
159 |
--------------------------------------------------------------------------------
/webapp/src/components/PeriodBarChart/utils.ts:
--------------------------------------------------------------------------------
1 | import { ITime } from '../../interfaces';
2 |
3 | export function getArrayMaxValue(array: any[], acessor: Function): number {
4 | return array.reduce((accum: number, currentItem: any) => {
5 | const value = acessor(currentItem);
6 |
7 | if (value > accum) {
8 | return value;
9 | }
10 |
11 | return accum;
12 | }, 0);
13 | }
14 |
15 | export function getTotalMinutesFromTime(time: ITime): number {
16 | return time.hours * 60 + time.minutes;
17 | }
18 |
19 | export function getBarHeight(
20 | maxHeight: number,
21 | maxValue: number,
22 | value: number,
23 | ): number {
24 | return (value * maxHeight) / maxValue;
25 | }
26 |
27 | export function formatMinutes(num: number): string {
28 | if (num < 10) {
29 | return `0${num}`;
30 | }
31 |
32 | return num.toString();
33 | }
34 |
--------------------------------------------------------------------------------
/webapp/src/components/QueryErrorIcon/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import { faExclamationCircle } from '@fortawesome/pro-regular-svg-icons';
4 | import { Root } from './styled';
5 |
6 | export const faIcon = faExclamationCircle;
7 | export const QueryErrorIcon: React.FC<{ className?: string }> = ({
8 | className,
9 | }) => (
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/webapp/src/components/QueryErrorIcon/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { COLORS } from '../../global-styles';
3 |
4 | export const Root = styled.span`
5 | color: ${COLORS.DANGER};
6 | font-size: 18px;
7 | `;
8 |
--------------------------------------------------------------------------------
/webapp/src/components/Section/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ISectionProps } from './interface';
3 | import { Content } from './styled';
4 |
5 | export const Section: React.FC = ({ children }) => {
6 | return (
7 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/webapp/src/components/Section/interface.ts:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | export interface ISectionProps {
4 | children: ReactNode;
5 | }
6 |
--------------------------------------------------------------------------------
/webapp/src/components/Section/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Content = styled.div`
4 | display: flex;
5 | align-items: center;
6 |
7 | @media (max-width: 960px) {
8 | justify-content: center;
9 | }
10 | `;
11 |
--------------------------------------------------------------------------------
/webapp/src/components/Slider/index.ts:
--------------------------------------------------------------------------------
1 | import SlickSlider from 'react-slick';
2 | import './slick.css';
3 |
4 | export const Slider = SlickSlider;
5 |
--------------------------------------------------------------------------------
/webapp/src/components/Slider/slick.css:
--------------------------------------------------------------------------------
1 | .slick-list,
2 | .slick-slider,
3 | .slick-track {
4 | position: relative;
5 | display: block;
6 | }
7 | .slick-loading .slick-slide,
8 | .slick-loading .slick-track {
9 | visibility: hidden;
10 | }
11 | .slick-slider {
12 | box-sizing: border-box;
13 | -webkit-user-select: none;
14 | -moz-user-select: none;
15 | -ms-user-select: none;
16 | user-select: none;
17 | -webkit-touch-callout: none;
18 | -khtml-user-select: none;
19 | -ms-touch-action: pan-y;
20 | touch-action: pan-y;
21 | -webkit-tap-highlight-color: transparent;
22 | }
23 | .slick-list {
24 | overflow: hidden;
25 | margin: 0;
26 | padding: 0;
27 | }
28 | .slick-list:focus {
29 | outline: 0;
30 | }
31 | .slick-list.dragging {
32 | cursor: pointer;
33 | cursor: hand;
34 | }
35 | .slick-slider .slick-list,
36 | .slick-slider .slick-track {
37 | -webkit-transform: translate3d(0, 0, 0);
38 | -moz-transform: translate3d(0, 0, 0);
39 | -ms-transform: translate3d(0, 0, 0);
40 | -o-transform: translate3d(0, 0, 0);
41 | transform: translate3d(0, 0, 0);
42 | }
43 | .slick-track {
44 | top: 0;
45 | left: 0;
46 | }
47 | .slick-track:after,
48 | .slick-track:before {
49 | display: table;
50 | content: '';
51 | }
52 | .slick-track:after {
53 | clear: both;
54 | }
55 | .slick-slide {
56 | display: none;
57 | float: left;
58 | height: 100%;
59 | min-height: 1px;
60 | }
61 | [dir='rtl'] .slick-slide {
62 | float: right;
63 | }
64 | .slick-slide img {
65 | display: block;
66 | }
67 | .slick-slide.slick-loading img {
68 | display: none;
69 | }
70 | .slick-slide.dragging img {
71 | pointer-events: none;
72 | }
73 | .slick-initialized .slick-slide {
74 | display: block;
75 | }
76 | .slick-vertical .slick-slide {
77 | display: block;
78 | height: auto;
79 | border: 1px solid transparent;
80 | }
81 | .slick-arrow.slick-hidden {
82 | display: none;
83 | }
84 |
85 | /* overrides */
86 |
87 | .slick-slide,
88 | .slick-slide > div {
89 | outline: 0 !important;
90 | }
91 |
92 | .slick-dots {
93 | display: flex !important;
94 | margin: 15px 0 0 0;
95 | padding: 0;
96 | align-items: center;
97 | justify-content: center;
98 | list-style: none;
99 | }
100 |
101 | .slick-dots button {
102 | display: none;
103 | }
104 |
105 | .slick-dots li {
106 | width: 6px;
107 | height: 6px;
108 | border-radius: 50%;
109 | transition: background-color 170ms ease, transform 170ms ease;
110 | background-color: #aaa;
111 | }
112 |
113 | .slick-dots li:not(:first-of-type) {
114 | margin-left: 5px;
115 | }
116 |
117 | .slick-dots li.slick-active {
118 | transform: scale(1.25);
119 | background-color: #eee;
120 | }
121 |
--------------------------------------------------------------------------------
/webapp/src/components/TimeDisplay/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Skeleton from 'react-loading-skeleton';
3 | import { ITimeDisplayProps } from './interface';
4 | import { UnitDisplay, Root, Unit } from './styled';
5 |
6 | export const TimeDisplay: React.FC = ({
7 | hours = 0,
8 | minutes = 0,
9 | isLoading,
10 | }) => {
11 | return (
12 |
13 |
14 | {isLoading ? (
15 |
16 | ) : (
17 | <>
18 | {hours > 0 && (
19 |
20 | {hours}
21 | hrs.
22 |
23 | )}{' '}
24 |
25 | {minutes}
26 | min.
27 |
28 | >
29 | )}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/webapp/src/components/TimeDisplay/interface.ts:
--------------------------------------------------------------------------------
1 | export interface ITimeDisplayProps {
2 | hours?: number;
3 | minutes?: number;
4 | isLoading?: boolean;
5 | }
6 |
--------------------------------------------------------------------------------
/webapp/src/components/TimeDisplay/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Root = styled.div`
4 | display: flex;
5 | `;
6 |
7 | export const Unit = styled.span`
8 | font-size: 0.6em;
9 | `;
10 |
11 | export const UnitDisplay = styled.div`
12 | font-size: 28px;
13 | font-weight: bold;
14 | `;
15 |
--------------------------------------------------------------------------------
/webapp/src/components/TimetableDisplay/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import moment from 'moment';
3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4 | import Skeleton from 'react-loading-skeleton';
5 | import { ITimetableDisplayProps } from './interface';
6 | import {
7 | IconContainer,
8 | Root,
9 | DisplayIconContainer,
10 | ErrorDisplay,
11 | Timetable,
12 | TimetableLabel,
13 | TimetableTimestamp,
14 | } from './styled';
15 |
16 | export const TimetableDisplay: React.FC = ({
17 | icon,
18 | timetables,
19 | isLoading,
20 | hasError,
21 | }) => {
22 | return (
23 |
24 |
25 |
26 | {hasError && }
27 |
28 |
29 |
30 |
31 |
32 | {timetables.map(({ timestamp, label }) => {
33 | const timetableDate = moment(timestamp, 'HH:mm:ssZ');
34 | const timetableLabel = `${label}: `;
35 |
36 | if (timetableDate && !timetableDate.isValid()) {
37 | return (
38 |
39 |
40 | {isLoading ? : timetableLabel}
41 |
42 |
43 | {isLoading ? : 'n/a'}
44 |
45 |
46 | );
47 | }
48 |
49 | return (
50 |
51 | {timetableLabel}
52 |
53 | {timetableDate.format('HH:mm')}
54 |
55 |
56 | );
57 | })}
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/webapp/src/components/TimetableDisplay/interface.ts:
--------------------------------------------------------------------------------
1 | import { IconProp } from '@fortawesome/fontawesome-svg-core';
2 |
3 | export interface ITimetable {
4 | timestamp?: string;
5 | label: string;
6 | }
7 |
8 | export interface ITimetableDisplayProps {
9 | icon: IconProp;
10 | timetables: ITimetable[];
11 | isLoading?: boolean;
12 | hasError?: boolean;
13 | }
14 |
--------------------------------------------------------------------------------
/webapp/src/components/TimetableDisplay/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { QueryErrorIcon } from '../QueryErrorIcon';
3 |
4 | export const CSS_VARIABLES = {
5 | LABEL_MIN_WIDTH: 125,
6 | MQ: { X_SMALL: 375 },
7 | };
8 |
9 | export const Root = styled.div`
10 | display: flex;
11 |
12 | @media (max-width: ${CSS_VARIABLES.MQ.X_SMALL}px) {
13 | flex-direction: column;
14 | }
15 | `;
16 |
17 | export const IconContainer = styled.div`
18 | display: flex;
19 | align-items: center;
20 | padding: 0 30px 0 0;
21 | margin-right: 34px;
22 | border-right: 1px solid #aaa;
23 | font-size: 42px;
24 |
25 | @media (max-width: ${CSS_VARIABLES.MQ.X_SMALL}px) {
26 | border-right: none;
27 | padding: 0 0 20px 0;
28 | margin: 0;
29 | justify-content: center;
30 | }
31 | `;
32 |
33 | interface ITimetableProps {
34 | isLoading?: boolean;
35 | }
36 | export const Timetable = styled.li`
37 | display: ${({ isLoading }) => (isLoading ? 'initial' : 'flex')};
38 | align-items: center;
39 | padding: 7px 0;
40 |
41 | &:not(:last-child) {
42 | border-bottom: 1px solid #aaa;
43 | }
44 |
45 | @media (max-width: ${CSS_VARIABLES.MQ.X_SMALL}px) {
46 | justify-content: center;
47 | }
48 | `;
49 |
50 | export const TimetableLabel = styled.span`
51 | display: inline-block;
52 | min-width: ${CSS_VARIABLES.LABEL_MIN_WIDTH}px;
53 | font-size: 0.75em;
54 | font-size: 14px;
55 | opacity: 0.9;
56 | `;
57 |
58 | export const TimetableTimestamp = styled.span`
59 | font-weight: bold;
60 | `;
61 |
62 | export const DisplayIconContainer = styled.div`
63 | position: relative;
64 | `;
65 |
66 | export const ErrorDisplay = styled(QueryErrorIcon)`
67 | position: absolute;
68 | top: -12px;
69 | right: -12px;
70 | `;
71 |
--------------------------------------------------------------------------------
/webapp/src/components/Today/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Card } from '../Card';
3 | import { DayTimetable } from '../DayTimetable';
4 | import { DayTotal } from '../DayTotal';
5 | import { Root, DayTimetableContainer, DayTotalContainerCard } from './styled';
6 |
7 | export const Today: React.FC = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/webapp/src/components/Today/styled.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Card } from '../Card';
3 |
4 | export const CSS_VARS = {
5 | GRID_GUTTER: 32,
6 | MQ: {
7 | MEDIUM: 960,
8 | SMALL: 620,
9 | X_SMALL: 375,
10 | },
11 | };
12 |
13 | export const Root = styled.div`
14 | display: flex;
15 | align-items: stretch;
16 | justify-content: center;
17 | width: 100%;
18 |
19 | @media (max-width: ${CSS_VARS.MQ.MEDIUM}px) {
20 | flex-direction: column;
21 | align-items: center;
22 | }
23 | `;
24 |
25 | export const DayTimetableContainer = styled.div`
26 | margin-right: 40px;
27 | flex: 0 1 auto;
28 |
29 | @media (max-width: ${CSS_VARS.MQ.MEDIUM}px) {
30 | margin-right: 0;
31 | margin-bottom: 40px;
32 | }
33 |
34 | @media (max-width: ${CSS_VARS.MQ.X_SMALL}px) {
35 | width: 100%;
36 | max-width: 250px;
37 | }
38 | `;
39 |
40 | export const DayTotalContainerCard = styled(Card)`
41 | flex: 1 1 auto;
42 |
43 | @media (max-width: ${CSS_VARS.MQ.SMALL}px) {
44 | max-width: 250px;
45 | }
46 |
47 | @media (max-width: ${CSS_VARS.MQ.MEDIUM}px) {
48 | width: 100%;
49 | flex: 1 0 auto;
50 | }
51 | `;
52 |
--------------------------------------------------------------------------------
/webapp/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const IS_DEV = process.env.NODE_ENV === 'development';
2 | export const DEVELOPMENT_DAY = '2019-12-12';
3 | export const DATE_FORMAT = 'YYYY-MM-DD';
4 | export const MONTH_DATE_FORMAT = 'YYYY-MM';
5 |
--------------------------------------------------------------------------------
/webapp/src/global-styles.tsx:
--------------------------------------------------------------------------------
1 | import { css, Global } from '@emotion/core';
2 | import React from 'react';
3 |
4 | export const COLORS = {
5 | CHART_BAR: '#4edfa5',
6 | DANGER: 'orangered',
7 | GRAY: '#f1f1f1',
8 | DARK_GRAY: '#626262',
9 | LIGHT_BLACK: '#404040',
10 | BLACK: '#323232',
11 | WHITE: '#fff',
12 | };
13 |
14 | const styles = css`
15 | body,
16 | html,
17 | #root {
18 | font-size: 18px;
19 | background-color: ${COLORS.BLACK};
20 | color: ${COLORS.WHITE};
21 | font-family: Roboto, sans-serif, sans-serif, Verdana, Geneva, Tahoma;
22 | width: 100%;
23 | height: 100%;
24 | margin: 0;
25 | }
26 |
27 | h1,
28 | h2,
29 | h3,
30 | h4,
31 | h5,
32 | h6 {
33 | margin: 0;
34 | }
35 |
36 | ul {
37 | margin: 0;
38 | padding: 0;
39 | list-style: none;
40 | }
41 |
42 | button {
43 | border: 0;
44 | background: transparent;
45 | color: currentColor;
46 | font-size: 1em;
47 | cursor: pointer;
48 | }
49 |
50 | *,
51 | *::before,
52 | *::after {
53 | box-sizing: border-box;
54 | }
55 | `;
56 |
57 | export const GlobalStyles: React.SFC = () => ;
58 |
--------------------------------------------------------------------------------
/webapp/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | // fonts
6 | import 'typeface-roboto';
7 |
8 | if (!window.requestIdleCallback) {
9 | window.requestIdleCallback = (cb: Function) => setTimeout(cb, 100);
10 | }
11 |
12 | ReactDOM.render(, document.getElementById('root'));
13 |
--------------------------------------------------------------------------------
/webapp/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface ITime {
2 | hours: number;
3 | minutes: number;
4 | }
5 |
6 | export interface IWorkDayTimetableRecord {
7 | day: string;
8 | date: string;
9 | events: string[];
10 | homeArriveTime: string;
11 | homeLeaveTime: string;
12 | workArriveTime: string;
13 | workLeaveTime: string;
14 | }
15 |
16 | export interface IDayResult extends IWorkDayTimetableRecord {
17 | totalMorningCommuteTime: ITime;
18 | totalEveningCommuteTime: ITime;
19 | totalTimeAtOffice: ITime;
20 | }
21 |
22 | export interface ITimetableChartResult extends IWorkDayTimetableRecord {
23 | totalMorningCommuteTime: ITime;
24 | totalEveningCommuteTime: ITime;
25 | totalTimeAtOffice: ITime;
26 | }
27 |
28 | export interface IPeriodResult {
29 | totalTimeAtOffice: ITime;
30 | averageTimeCommuting: ITime;
31 | averageTimeAtOffice: ITime;
32 | timetableChart: ITimetableChartResult[];
33 | }
34 |
35 | export interface IFirstRecordResult extends IWorkDayTimetableRecord {}
36 |
--------------------------------------------------------------------------------
/webapp/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/webapp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react",
17 | "downlevelIteration": true
18 | },
19 | "include": ["src"]
20 | }
21 |
--------------------------------------------------------------------------------
/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | prettier@^1.19.1:
6 | version "1.19.1"
7 | resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
8 | integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
9 |
--------------------------------------------------------------------------------