From 307cb0712900fe717fe81705de1bd8c313e52d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Salgado?= Date: Tue, 15 Jun 2021 15:22:21 +0100 Subject: [PATCH] feat: add analytics screen feat: analytics header overview and legend feat: data selectors feat: graph axis and introductory stale data feat: change graph tick labeling feat: color formatting feat: day and month separation in graph grid refactor: move graph logic to its own file chore: create dummy transaction factory feat: make the analytics overview dynamic with data feat: apply timezone to the analytics screen fix: multiple bug fixes fix: graph txs colors fix: graph no data scenario feat: remove placeholder icons fix: used currencies on filtered data fix: remove timezone object formatting fix: forex rates on transaction data fix: styles fix: growth percentage margin refactor: pull up variables fix: clean up code feat: add transparent areas for mouse event purposes fix: replace DateTime with Date fix: small fixes to graph area intervals fix: graph rectangle location fix: d3 onClick event in dot graph chore: move graph popover component to components folder feat: memo dot graph to avoid expensive rerendering refactor: separate Graph component for future refactoring purposes refactor: restructure Graph related components fix: graph memoizing feat: top machines stacked bar graph refactor: further analytics refactor fix: small fixes on top machines graph fix: top machines stacked bar grapy y-axis scaling fix: small fixes on the stacked bar graph feat: add top machines header fix: bar drawing fix: transaction grouping per day of week refactor: general code cleaning up feat: mouseover events fix: transaction filtering on cross-day hour intervals fix: top machines graph edge case fix: NaN instances on graph drawing fix: tooltip date presentation fix: rearrange files for easier understanding fix: tooltip date interval fix: multiple small fixes fix: remove unnecessary arguments fix: add second group of hoverable rectangles --- .../src/pages/Analytics/Analytics.js | 332 +++++++++++ .../src/pages/Analytics/Analytics.styles.js | 141 +++++ .../pages/Analytics/components/LegendEntry.js | 22 + .../components/tooltips/GraphTooltip.js | 86 +++ .../tooltips/GraphTooltip.styles.js | 29 + .../components/wrappers/HourOfDayWrapper.js | 138 +++++ .../components/wrappers/OverTimeWrapper.js | 82 +++ .../components/wrappers/TopMachinesWrapper.js | 70 +++ .../src/pages/Analytics/graphs/Graph.js | 119 ++++ .../Analytics/graphs/HourOfDayBarGraph.js | 435 ++++++++++++++ .../Analytics/graphs/OverTimeDotGraph.js | 542 ++++++++++++++++++ .../Analytics/graphs/TopMachinesBarGraph.js | 307 ++++++++++ .../src/routing/lamassu.routes.js | 8 + new-lamassu-admin/src/routing/pazuz.routes.js | 8 + new-lamassu-admin/src/utils/time.js | 7 + new-lamassu-admin/src/utils/timezones.js | 8 +- 16 files changed, 2332 insertions(+), 2 deletions(-) create mode 100644 new-lamassu-admin/src/pages/Analytics/Analytics.js create mode 100644 new-lamassu-admin/src/pages/Analytics/Analytics.styles.js create mode 100644 new-lamassu-admin/src/pages/Analytics/components/LegendEntry.js create mode 100644 new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.js create mode 100644 new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.styles.js create mode 100644 new-lamassu-admin/src/pages/Analytics/components/wrappers/HourOfDayWrapper.js create mode 100644 new-lamassu-admin/src/pages/Analytics/components/wrappers/OverTimeWrapper.js create mode 100644 new-lamassu-admin/src/pages/Analytics/components/wrappers/TopMachinesWrapper.js create mode 100644 new-lamassu-admin/src/pages/Analytics/graphs/Graph.js create mode 100644 new-lamassu-admin/src/pages/Analytics/graphs/HourOfDayBarGraph.js create mode 100644 new-lamassu-admin/src/pages/Analytics/graphs/OverTimeDotGraph.js create mode 100644 new-lamassu-admin/src/pages/Analytics/graphs/TopMachinesBarGraph.js create mode 100644 new-lamassu-admin/src/utils/time.js diff --git a/new-lamassu-admin/src/pages/Analytics/Analytics.js b/new-lamassu-admin/src/pages/Analytics/Analytics.js new file mode 100644 index 00000000..577d0043 --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/Analytics.js @@ -0,0 +1,332 @@ +import { useQuery } from '@apollo/react-hooks' +import { Box } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import classnames from 'classnames' +import gql from 'graphql-tag' +import * as R from 'ramda' +import React, { useState } from 'react' + +import { Select } from 'src/components/inputs' +import TitleSection from 'src/components/layout/TitleSection' +import { Info2, P } from 'src/components/typography' +import { ReactComponent as DownIcon } from 'src/styling/icons/dashboard/down.svg' +import { ReactComponent as EqualIcon } from 'src/styling/icons/dashboard/equal.svg' +import { ReactComponent as UpIcon } from 'src/styling/icons/dashboard/up.svg' +import { fromNamespace } from 'src/utils/config' +import { DAY, WEEK, MONTH } from 'src/utils/time' + +import styles from './Analytics.styles' +import LegendEntry from './components/LegendEntry' +import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper' +import OverTimeWrapper from './components/wrappers/OverTimeWrapper' +import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper' + +const useStyles = makeStyles(styles) + +const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }] +const REPRESENTING_OPTIONS = [ + { code: 'overTime', display: 'Over time' }, + { code: 'topMachines', display: 'Top Machines' }, + { code: 'hourOfTheDay', display: 'Hour of the day' } +] +const PERIOD_OPTIONS = [ + { code: 'day', display: 'Last 24 hours' }, + { code: 'week', display: 'Last 7 days' }, + { code: 'month', display: 'Last 30 days' } +] +const TIME_OPTIONS = { + day: DAY, + week: WEEK, + month: MONTH +} + +const GET_TRANSACTIONS = gql` + query transactions($limit: Int, $from: Date, $until: Date) { + transactions(limit: $limit, from: $from, until: $until) { + id + txClass + txHash + toAddress + commissionPercentage + expired + machineName + operatorCompleted + sendConfirmed + dispense + hasError: error + deviceId + fiat + cashInFee + fiatCode + cryptoAtoms + cryptoCode + toAddress + created + } + } +` + +const GET_DATA = gql` + query getData { + config + machines { + name + deviceId + } + fiatRates { + code + name + rate + } + } +` + +const OverviewEntry = ({ label, value, oldValue, currency }) => { + const classes = useStyles() + + const _oldValue = !oldValue || R.equals(oldValue, 0) ? 1 : oldValue + const growthRate = ((value - oldValue) * 100) / _oldValue + + const growthClasses = { + [classes.growthPercentage]: true, + [classes.growth]: R.gt(value, oldValue), + [classes.decline]: R.gt(oldValue, value) + } + + return ( +
+

{label}

+ + + {value.toLocaleString('en-US', { maximumFractionDigits: 2 })} + + {!!currency && ` ${currency}`} + + + {R.gt(growthRate, 0) && } + {R.lt(growthRate, 0) && } + {R.equals(growthRate, 0) && } +

+ {growthRate.toLocaleString('en-US', { maximumFractionDigits: 2 })}% +

+
+
+ ) +} + +const Analytics = () => { + const classes = useStyles() + + const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS) + const { data: configResponse, loading: configLoading } = useQuery(GET_DATA) + + const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0]) + const [period, setPeriod] = useState(PERIOD_OPTIONS[0]) + const [machine, setMachine] = useState(MACHINE_OPTIONS[0]) + + const loading = txLoading || configLoading + + const transactions = R.path(['transactions'])(txResponse) ?? [] + const machines = R.path(['machines'])(configResponse) ?? [] + const config = R.path(['config'])(configResponse) ?? [] + const rates = R.path(['fiatRates'])(configResponse) ?? [] + const fiatLocale = fromNamespace('locale')(config).fiatCurrency + + const timezone = config?.locale_timezone + + const convertFiatToLocale = item => { + if (item.fiatCode === fiatLocale) return item + const itemRate = R.find(R.propEq('code', item.fiatCode))(rates) + const localeRate = R.find(R.propEq('code', fiatLocale))(rates) + const multiplier = localeRate?.rate / itemRate?.rate + return { ...item, fiat: parseFloat(item.fiat) * multiplier } + } + + const data = + R.map(convertFiatToLocale)( + transactions?.filter( + tx => + (!tx.dispensed || !tx.expired) && (tx.sendConfirmed || tx.dispense) + ) + ) ?? [] + + const machineOptions = R.clone(MACHINE_OPTIONS) + + R.forEach( + m => machineOptions.push({ code: m.deviceId, display: m.name }), + machines + ) + + const machineTxs = R.filter( + tx => (machine.code === 'all' ? true : tx.deviceId === machine.code), + data + ) + + const filteredData = timeInterval => ({ + current: + machineTxs.filter( + d => new Date(d.created) >= Date.now() - TIME_OPTIONS[timeInterval] + ) ?? [], + previous: + machineTxs.filter( + d => + new Date(d.created) < Date.now() - TIME_OPTIONS[timeInterval] && + new Date(d.created) >= Date.now() - 2 * TIME_OPTIONS[timeInterval] + ) ?? [] + }) + + const txs = { + current: filteredData(period.code).current.length, + previous: filteredData(period.code).previous.length + } + + const avgAmount = { + current: + R.sum(R.map(d => d.fiat, filteredData(period.code).current)) / + (txs.current === 0 ? 1 : txs.current), + previous: + R.sum(R.map(d => d.fiat, filteredData(period.code).previous)) / + (txs.previous === 0 ? 1 : txs.previous) + } + + const txVolume = { + current: R.sum(R.map(d => d.fiat, filteredData(period.code).current)), + previous: R.sum(R.map(d => d.fiat, filteredData(period.code).previous)) + } + + const commissions = { + current: R.sum( + R.map( + d => d.fiat * d.commissionPercentage, + filteredData(period.code).current + ) + ), + previous: R.sum( + R.map( + d => d.fiat * d.commissionPercentage, + filteredData(period.code).previous + ) + ) + } + + const getGraphInfo = representing => { + switch (representing.code) { + case 'overTime': + return ( + + ) + case 'topMachines': + return ( + + ) + case 'hourOfTheDay': + return ( + + ) + default: + throw new Error(`There's no graph info to represent ${representing}`) + } + } + + return ( + !loading && ( + <> + + + + + + + +
+
+ +
+
+ +
+ +
+ +
+ +
+
+ {getGraphInfo(representing)} + + ) + ) +} + +export default Analytics diff --git a/new-lamassu-admin/src/pages/Analytics/Analytics.styles.js b/new-lamassu-admin/src/pages/Analytics/Analytics.styles.js new file mode 100644 index 00000000..089547f8 --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/Analytics.styles.js @@ -0,0 +1,141 @@ +import { offDarkColor, tomato, neon, java } from 'src/styling/variables' + +const styles = { + overviewLegend: { + display: 'flex', + justifyContent: 'flex-end', + '& span': { + marginRight: 24 + }, + '& > :last-child': { + marginRight: 0 + } + }, + legendEntry: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + '& > :first-child': { + marginRight: 8 + } + }, + dropdownsOverviewWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16 + }, + verticalLine: { + height: 64, + width: 1, + border: 'solid', + borderWidth: 0.5, + borderColor: offDarkColor + }, + dropdowns: { + display: 'flex', + flexDirection: 'row', + '& div': { + marginRight: 24 + }, + '& > :last-child': { + marginRight: 0 + } + }, + overview: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + '& div': { + marginRight: 40 + }, + '& > :last-child': { + marginRight: 0 + } + }, + overviewFieldWrapper: { + marginTop: 6, + marginBottom: 6, + '& span': { + fontSize: 24 + } + }, + overviewGrowth: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + '& p': { + marginLeft: 4 + } + }, + growthPercentage: { + fontWeight: 'bold' + }, + growth: { + color: '#00CD5A' + }, + decline: { + color: tomato + }, + // Graph + graphHeaderWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 40 + }, + graphHeaderLeft: { + display: 'flex', + flexDirection: 'column' + }, + graphHeaderRight: { + marginTop: 15, + display: 'flex', + flexDirection: 'row', + '& > *': { + marginRight: 30, + '&:last-child': { + marginRight: 0 + } + } + }, + graphLegend: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + '& span': { + marginRight: 24 + }, + '& > :last-child': { + marginRight: 0 + } + }, + machineSelector: { + width: 248 + }, + cashInIcon: { + width: 12, + height: 12, + borderRadius: 12, + backgroundColor: java + }, + cashOutIcon: { + width: 12, + height: 12, + borderRadius: 12, + backgroundColor: neon + }, + txIcon: { + width: 12, + height: 12, + borderRadius: 12, + backgroundColor: '#000' + }, + topMachinesRadio: { + display: 'flex', + flexDirection: 'row' + } +} + +export default styles diff --git a/new-lamassu-admin/src/pages/Analytics/components/LegendEntry.js b/new-lamassu-admin/src/pages/Analytics/components/LegendEntry.js new file mode 100644 index 00000000..d079e760 --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/components/LegendEntry.js @@ -0,0 +1,22 @@ +import { makeStyles } from '@material-ui/core/styles' +import React from 'react' + +import { P } from 'src/components/typography' + +import styles from '../Analytics.styles' + +const useStyles = makeStyles(styles) + +const LegendEntry = ({ IconElement, IconComponent, label }) => { + const classes = useStyles() + + return ( + + {!!IconComponent && } + {!!IconElement && IconElement} +

{label}

+
+ ) +} + +export default LegendEntry diff --git a/new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.js b/new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.js new file mode 100644 index 00000000..e46bc675 --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.js @@ -0,0 +1,86 @@ +import { Paper } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import * as R from 'ramda' +import React, { memo } from 'react' + +import { Info2, Label3, P } from 'src/components/typography' +import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg' +import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg' +import { singularOrPlural } from 'src/utils/string' +import { formatDate, formatDateNonUtc } from 'src/utils/timezones' + +import styles from './GraphTooltip.styles' + +const useStyles = makeStyles(styles) + +const formatCurrency = amount => + amount.toLocaleString('en-US', { maximumFractionDigits: 2 }) + +const GraphTooltip = ({ + coords, + data, + dateInterval, + period, + currency, + representing +}) => { + const classes = useStyles(coords) + + const formattedDateInterval = !R.includes('hourOfDay', representing.code) + ? [ + formatDate( + dateInterval[1], + null, + period.code === 'day' ? 'MMM D, HH:mm' : 'MMM D' + ), + formatDate( + dateInterval[0], + null, + period.code === 'day' ? 'HH:mm' : 'MMM D' + ) + ] + : [ + formatDateNonUtc(dateInterval[1], 'HH:mm'), + formatDateNonUtc(dateInterval[0], 'HH:mm') + ] + + const transactions = R.reduce( + (acc, value) => { + acc.volume += parseInt(value.fiat) + if (value.txClass === 'cashIn') acc.cashIn++ + if (value.txClass === 'cashOut') acc.cashOut++ + return acc + }, + { volume: 0, cashIn: 0, cashOut: 0 }, + data + ) + + return ( + + + {period.code === 'day' || R.includes('hourOfDay', representing.code) + ? `${formattedDateInterval[0]} - ${formattedDateInterval[1]}` + : `${formattedDateInterval[0]}`} + +

+ {R.length(data)}{' '} + {singularOrPlural(R.length(data), 'transaction', 'transactions')} +

+

+ {formatCurrency(transactions.volume)} {currency} in volume +

+
+ + + {transactions.cashIn} cash-in + + + + {transactions.cashOut} cash-out + +
+
+ ) +} + +export default memo(GraphTooltip, (prev, next) => prev.coords === next.coords) diff --git a/new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.styles.js b/new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.styles.js new file mode 100644 index 00000000..01fa39ea --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/components/tooltips/GraphTooltip.styles.js @@ -0,0 +1,29 @@ +import { comet } from 'src/styling/variables' + +const styles = { + dotOtWrapper: { + position: 'absolute', + top: coords => coords?.y ?? 0, + left: coords => coords?.x ?? 0, + width: 150, + padding: 12, + borderRadius: 8 + }, + dotOtTransactionAmount: { + margin: [[8, 0, 8, 0]] + }, + dotOtTransactionVolume: { + color: comet + }, + dotOtTransactionClasses: { + marginTop: 15, + '& p > span': { + marginLeft: 5 + }, + '& p:last-child': { + marginTop: 5 + } + } +} + +export default styles diff --git a/new-lamassu-admin/src/pages/Analytics/components/wrappers/HourOfDayWrapper.js b/new-lamassu-admin/src/pages/Analytics/components/wrappers/HourOfDayWrapper.js new file mode 100644 index 00000000..aeadafe4 --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/components/wrappers/HourOfDayWrapper.js @@ -0,0 +1,138 @@ +import { Box } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import moment from 'moment' +import * as R from 'ramda' +import React, { useState } from 'react' + +import { RadioGroup, Select } from 'src/components/inputs' +import { H2 } from 'src/components/typography' +import { MINUTE } from 'src/utils/time' + +import styles from '../../Analytics.styles' +import Graph from '../../graphs/Graph' +import LegendEntry from '../LegendEntry' + +const useStyles = makeStyles(styles) + +const options = [ + { code: 'hourOfDayTransactions', display: 'Transactions' }, + { code: 'hourOfDayVolume', display: 'Volume' } +] + +const dayOptions = R.map( + it => ({ + code: R.toLower(it), + display: it + }), + moment.weekdays() +) + +const HourOfDayBarGraphHeader = ({ + title, + period, + data, + machines, + selectedMachine, + handleMachineChange, + timezone, + currency +}) => { + const classes = useStyles() + + const [graphType, setGraphType] = useState(options[0].code) + const [selectedDay, setSelectedDay] = useState(dayOptions[0]) + + const legend = { + cashIn:
, + cashOut:
+ } + + const offset = parseInt(timezone.split(':')[1]) * MINUTE + + const txsPerWeekday = R.reduce( + (acc, value) => { + const created = new Date(value.created) + // console.log('before', R.clone(created)) + created.setTime( + created.getTime() + created.getTimezoneOffset() * MINUTE + offset + ) + // console.log('after', R.clone(created)) + switch (created.getDay()) { + case 0: + acc.sunday.push(value) + break + case 1: + acc.monday.push(value) + break + case 2: + acc.tuesday.push(value) + break + case 3: + acc.wednesday.push(value) + break + case 4: + acc.thursday.push(value) + break + case 5: + acc.friday.push(value) + break + case 6: + acc.saturday.push(value) + break + default: + throw new Error('Day of week not recognized') + } + return acc + }, + R.fromPairs(R.map(it => [it.code, []], dayOptions)), + data + ) + + return ( + <> +
+
+

{title}

+ + + + +
+
+ setGraphType(e.target.value)} + /> + +
+
+ it.code === graphType)(options)} + period={period} + data={txsPerWeekday[selectedDay.code]} + timezone={timezone} + currency={currency} + selectedMachine={selectedMachine} + machines={machines} + selectedDay={selectedDay} + /> + + ) +} + +export default HourOfDayBarGraphHeader diff --git a/new-lamassu-admin/src/pages/Analytics/components/wrappers/OverTimeWrapper.js b/new-lamassu-admin/src/pages/Analytics/components/wrappers/OverTimeWrapper.js new file mode 100644 index 00000000..502d817a --- /dev/null +++ b/new-lamassu-admin/src/pages/Analytics/components/wrappers/OverTimeWrapper.js @@ -0,0 +1,82 @@ +import { Box } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import React from 'react' + +import { Select } from 'src/components/inputs' +import { H2 } from 'src/components/typography' +import { primaryColor } from 'src/styling/variables' + +import styles from '../../Analytics.styles' +import Graph from '../../graphs/Graph' +import LegendEntry from '../LegendEntry' + +const useStyles = makeStyles(styles) + +const OverTimeDotGraphHeader = ({ + title, + representing, + period, + data, + machines, + selectedMachine, + handleMachineChange, + timezone, + currency +}) => { + const classes = useStyles() + + const legend = { + cashIn:
, + cashOut:
, + transaction:
, + average: ( + + + + ) + } + + return ( + <> +
+
+

{title}

+ + + + + + +
+
+