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}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default OverTimeDotGraphHeader
diff --git a/new-lamassu-admin/src/pages/Analytics/components/wrappers/TopMachinesWrapper.js b/new-lamassu-admin/src/pages/Analytics/components/wrappers/TopMachinesWrapper.js
new file mode 100644
index 00000000..024b5712
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Analytics/components/wrappers/TopMachinesWrapper.js
@@ -0,0 +1,70 @@
+import { Box } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
+import * as R from 'ramda'
+import React, { useState } from 'react'
+
+import { RadioGroup } from 'src/components/inputs'
+import { H2 } from 'src/components/typography'
+
+import styles from '../../Analytics.styles'
+import Graph from '../../graphs/Graph'
+import LegendEntry from '../LegendEntry'
+
+const useStyles = makeStyles(styles)
+
+const options = [
+ { code: 'topMachinesTransactions', display: 'Transactions' },
+ { code: 'topMachinesVolume', display: 'Volume' }
+]
+
+const TopMachinesBarGraphHeader = ({
+ title,
+ period,
+ data,
+ machines,
+ selectedMachine,
+ timezone,
+ currency
+}) => {
+ const classes = useStyles()
+
+ const [graphType, setGraphType] = useState(options[0].code)
+
+ const legend = {
+ cashIn: ,
+ cashOut:
+ }
+
+ return (
+ <>
+
+
+
{title}
+
+
+
+
+
+
+ setGraphType(e.target.value)}
+ />
+
+
+
+ >
+ )
+}
+
+export default TopMachinesBarGraphHeader
diff --git a/new-lamassu-admin/src/pages/Analytics/graphs/Graph.js b/new-lamassu-admin/src/pages/Analytics/graphs/Graph.js
new file mode 100644
index 00000000..81870a0c
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Analytics/graphs/Graph.js
@@ -0,0 +1,119 @@
+import * as R from 'ramda'
+import React, { memo, useState } from 'react'
+
+import GraphTooltip from '../components/tooltips/GraphTooltip'
+
+import HourOfDayBarGraph from './HourOfDayBarGraph'
+import OverTimeDotGraph from './OverTimeDotGraph'
+import TopMachinesBarGraph from './TopMachinesBarGraph'
+
+const GraphWrapper = ({
+ data,
+ representing,
+ period,
+ timezone,
+ currency,
+ selectedMachine,
+ machines,
+ selectedDay
+}) => {
+ const [selectionCoords, setSelectionCoords] = useState(null)
+ const [selectionDateInterval, setSelectionDateInterval] = useState(null)
+ const [selectionData, setSelectionData] = useState(null)
+
+ const getGraph = representing => {
+ switch (representing.code) {
+ case 'overTime':
+ return (
+
+ )
+ case 'topMachinesVolume':
+ return (
+ it.code !== 'all', machines)}
+ currency={currency}
+ />
+ )
+ case 'topMachinesTransactions':
+ return (
+ it.code !== 'all', machines)}
+ currency={currency}
+ />
+ )
+ case 'hourOfDayVolume':
+ return (
+ it.code !== 'all', machines)}
+ currency={currency}
+ selectedDay={selectedDay}
+ />
+ )
+ case 'hourOfDayTransactions':
+ return (
+ it.code !== 'all', machines)}
+ currency={currency}
+ selectedDay={selectedDay}
+ />
+ )
+ default:
+ throw new Error(`There's no graph to represent ${representing}`)
+ }
+ }
+
+ return (
+
+ {!R.isNil(selectionCoords) && (
+
+ )}
+ {getGraph(representing)}
+
+ )
+}
+
+export default memo(GraphWrapper)
diff --git a/new-lamassu-admin/src/pages/Analytics/graphs/HourOfDayBarGraph.js b/new-lamassu-admin/src/pages/Analytics/graphs/HourOfDayBarGraph.js
new file mode 100644
index 00000000..b50ccdb7
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Analytics/graphs/HourOfDayBarGraph.js
@@ -0,0 +1,435 @@
+import BigNumber from 'bignumber.js'
+import * as d3 from 'd3'
+import moment from 'moment'
+import * as R from 'ramda'
+import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
+
+import {
+ java,
+ neon,
+ subheaderDarkColor,
+ fontColor,
+ fontSecondary,
+ subheaderColor
+} from 'src/styling/variables'
+import { MINUTE } from 'src/utils/time'
+
+const Graph = ({
+ data,
+ timezone,
+ setSelectionCoords,
+ setSelectionData,
+ setSelectionDateInterval
+}) => {
+ const ref = useRef(null)
+
+ const GRAPH_POPOVER_WIDTH = 150
+ const GRAPH_POPOVER_MARGIN = 25
+ const BAR_MARGIN = 10
+ const GRAPH_HEIGHT = 401
+ const GRAPH_WIDTH = 1163
+ const GRAPH_MARGIN = useMemo(
+ () => ({
+ top: 25,
+ right: 0.5,
+ bottom: 27,
+ left: 36.5
+ }),
+ []
+ )
+
+ const offset = parseInt(timezone.split(':')[1]) * MINUTE
+
+ const getTickIntervals = (domain, interval) => {
+ const ticks = []
+ const start = new Date(domain[0])
+ const end = new Date(domain[1])
+
+ const step = R.clone(start)
+
+ // eslint-disable-next-line no-unmodified-loop-condition
+ while (step <= end) {
+ ticks.push(R.clone(step))
+ step.setUTCHours(step.getUTCHours() + interval)
+ }
+
+ return ticks
+ }
+
+ const filterByHourInterval = useCallback(
+ (lowerBound, upperBound) =>
+ R.filter(it => {
+ const tzCreated = new Date(it.created).setTime(
+ new Date(it.created).getTime() +
+ new Date(it.created).getTimezoneOffset() * MINUTE +
+ offset
+ )
+ const created = new Date(tzCreated)
+
+ return (
+ (lowerBound.getUTCHours() < upperBound.getUTCHours() &&
+ created.getUTCHours() >= new Date(lowerBound).getUTCHours() &&
+ created.getUTCHours() < new Date(upperBound).getUTCHours()) ||
+ (lowerBound.getUTCHours() > upperBound.getUTCHours() &&
+ created.getUTCHours() <= new Date(lowerBound).getUTCHours() &&
+ created.getUTCHours() < new Date(upperBound).getUTCHours())
+ )
+ }, data),
+ [data, offset]
+ )
+
+ const txClassByHourInterval = useCallback(
+ (lowerBound, upperBound) =>
+ R.reduce(
+ (acc, value) => {
+ if (value.txClass === 'cashIn')
+ acc.cashIn += BigNumber(value.fiat).toNumber()
+ if (value.txClass === 'cashOut')
+ acc.cashOut += BigNumber(value.fiat).toNumber()
+ return acc
+ },
+ { cashIn: 0, cashOut: 0 },
+ filterByHourInterval(lowerBound, upperBound)
+ ),
+ [filterByHourInterval]
+ )
+
+ const x = d3
+ .scaleUtc()
+ .domain([
+ moment()
+ .startOf('day')
+ .utc(),
+ moment()
+ .startOf('day')
+ .add(1, 'day')
+ .utc()
+ ])
+ .rangeRound([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
+
+ const groupedByDateInterval = R.map(it => {
+ const lowerBound = R.clone(it)
+ it.setUTCHours(it.getUTCHours() + 2)
+ const upperBound = R.clone(it)
+ return [lowerBound, filterByHourInterval(lowerBound, upperBound)]
+ }, R.init(getTickIntervals(x.domain(), 2)))
+
+ const groupedByTxClass = R.map(it => {
+ const lowerBound = R.clone(it)
+ it.setUTCHours(it.getUTCHours() + 2)
+ const upperBound = R.clone(it)
+ return [lowerBound, txClassByHourInterval(lowerBound, upperBound)]
+ }, R.init(getTickIntervals(x.domain(), 2)))
+
+ const y = d3
+ .scaleLinear()
+ .domain([
+ 0,
+ d3.max(
+ groupedByTxClass.map(it => it[1]),
+ d => d.cashIn + d.cashOut
+ ) !== 0
+ ? d3.max(
+ groupedByTxClass.map(it => it[1]),
+ d => d.cashIn + d.cashOut
+ )
+ : 50
+ ])
+ .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
+
+ const buildXAxis = useCallback(
+ g =>
+ g
+ .attr(
+ 'transform',
+ `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
+ )
+ .call(
+ d3
+ .axisBottom(x)
+ .ticks(d3.timeHour.every(2))
+ .tickFormat(d3.timeFormat('%H:%M'))
+ ),
+ [GRAPH_MARGIN, x]
+ )
+
+ const buildYAxis = useCallback(
+ g =>
+ g
+ .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
+ .call(
+ d3
+ .axisLeft(y)
+ .ticks(GRAPH_HEIGHT / 100)
+ .tickSize(0)
+ .tickFormat(``)
+ )
+ .call(g => g.select('.domain').remove()),
+ [GRAPH_MARGIN, y]
+ )
+
+ const buildVerticalLines = useCallback(
+ g =>
+ g
+ .attr('stroke', subheaderDarkColor)
+ .append('g')
+ .selectAll('line')
+ .data(getTickIntervals(x.domain(), 2))
+ .join('line')
+ .attr('x1', d => {
+ const xValue = x(d)
+ const intervals = getTickIntervals(x.domain(), 2)
+ return xValue === x(intervals[R.length(intervals) - 1])
+ ? xValue - 1
+ : 0.5 + xValue
+ })
+ .attr('x2', d => {
+ const xValue = x(d)
+ const intervals = getTickIntervals(x.domain(), 2)
+ return xValue === x(intervals[R.length(intervals) - 1])
+ ? xValue - 1
+ : 0.5 + xValue
+ })
+ .attr('y1', GRAPH_MARGIN.top)
+ .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom),
+ [GRAPH_MARGIN, x]
+ )
+
+ const buildHoverableEventRects = useCallback(
+ g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(getTickIntervals(x.domain(), 2))
+ .join('rect')
+ .attr('x', d => x(d))
+ .attr('y', GRAPH_MARGIN.top)
+ .attr('width', d => {
+ const xValue = Math.round(x(d) * 100) / 100
+ const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
+
+ const index = R.findIndex(it => it === xValue, ticks)
+ const width =
+ index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
+
+ return Math.round(width * 100) / 100
+ })
+ .attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
+ .attr('stroke', 'transparent')
+ .attr('fill', 'transparent')
+ .on('mouseover', d => {
+ const date = R.clone(new Date(d.target.__data__))
+ const startDate = R.clone(date)
+ date.setUTCHours(date.getUTCHours() + 2)
+ const endDate = R.clone(date)
+
+ const filteredData = groupedByDateInterval.find(it =>
+ R.equals(startDate, it[0])
+ )[1]
+
+ const rectXCoords = {
+ left: R.clone(d.target.getBoundingClientRect().x),
+ right: R.clone(
+ d.target.getBoundingClientRect().x +
+ d.target.getBoundingClientRect().width
+ )
+ }
+
+ const xCoord =
+ d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
+ ? rectXCoords.right + GRAPH_POPOVER_MARGIN
+ : rectXCoords.left - GRAPH_POPOVER_WIDTH - GRAPH_POPOVER_MARGIN
+ const yCoord = R.clone(d.target.getBoundingClientRect().y)
+
+ setSelectionDateInterval([endDate, startDate])
+ setSelectionData(filteredData)
+ setSelectionCoords({
+ x: Math.round(xCoord),
+ y: Math.round(yCoord)
+ })
+
+ d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
+ 'fill',
+ subheaderColor
+ )
+ })
+ .on('mouseleave', d => {
+ d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
+ 'fill',
+ 'transparent'
+ )
+ setSelectionDateInterval(null)
+ setSelectionData(null)
+ setSelectionCoords(null)
+ }),
+ [
+ GRAPH_MARGIN,
+ groupedByDateInterval,
+ setSelectionCoords,
+ setSelectionData,
+ setSelectionDateInterval,
+ x
+ ]
+ )
+
+ const buildEventRects = useCallback(
+ g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(getTickIntervals(x.domain(), 2))
+ .join('rect')
+ .attr('id', d => `event-rect-${x(d)}`)
+ .attr('x', d => x(d))
+ .attr('y', GRAPH_MARGIN.top)
+ .attr('width', d => {
+ const xValue = Math.round(x(d) * 100) / 100
+ const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
+
+ const index = R.findIndex(it => it === xValue, ticks)
+ const width =
+ index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
+
+ return Math.round(width * 100) / 100
+ })
+ .attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
+ .attr('stroke', 'transparent')
+ .attr('fill', 'transparent'),
+ [GRAPH_MARGIN, x]
+ )
+
+ const formatTicksText = useCallback(
+ () =>
+ d3
+ .selectAll('.tick text')
+ .style('stroke', fontColor)
+ .style('fill', fontColor)
+ .style('stroke-width', 0.5)
+ .style('font-family', fontSecondary),
+ []
+ )
+
+ const drawCashIn = useCallback(
+ g => {
+ g.selectAll('rect')
+ .data(R.init(getTickIntervals(x.domain(), 2)))
+ .join('rect')
+ .attr('stroke', java)
+ .attr('fill', java)
+ .attr('x', d => {
+ return x(d) + BAR_MARGIN / 2
+ })
+ .attr('y', d => {
+ const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
+ return y(interval[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom
+ })
+ .attr('height', d => {
+ const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
+ return R.clamp(
+ 0,
+ GRAPH_HEIGHT,
+ GRAPH_HEIGHT -
+ y(interval[1].cashIn) -
+ GRAPH_MARGIN.bottom -
+ BAR_MARGIN / 2
+ )
+ })
+ .attr('width', d => {
+ const xValue = Math.round(x(d) * 100) / 100
+ const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
+
+ const index = R.findIndex(it => it === xValue, ticks)
+ const width =
+ index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
+ return Math.round((width - BAR_MARGIN) * 100) / 100
+ })
+ .attr('rx', 2.5)
+ },
+ [x, y, GRAPH_MARGIN, groupedByTxClass]
+ )
+
+ const drawCashOut = useCallback(
+ g => {
+ g.selectAll('rect')
+ .data(R.init(getTickIntervals(x.domain(), 2)))
+ .join('rect')
+ .attr('stroke', neon)
+ .attr('fill', neon)
+ .attr('x', d => {
+ return x(d) + BAR_MARGIN / 2
+ })
+ .attr('y', d => {
+ const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
+ return (
+ y(interval[1].cashIn + interval[1].cashOut) -
+ GRAPH_MARGIN.top +
+ GRAPH_MARGIN.bottom
+ )
+ })
+ .attr('height', d => {
+ const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
+ return R.clamp(
+ 0,
+ GRAPH_HEIGHT,
+ GRAPH_HEIGHT -
+ y(interval[1].cashOut) -
+ GRAPH_MARGIN.bottom -
+ BAR_MARGIN / 2
+ )
+ })
+ .attr('width', d => {
+ const xValue = Math.round(x(d) * 100) / 100
+ const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
+
+ const index = R.findIndex(it => it === xValue, ticks)
+ const width =
+ index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
+ return Math.round((width - BAR_MARGIN) * 100) / 100
+ })
+ .attr('rx', 2.5)
+ },
+ [x, y, GRAPH_MARGIN, groupedByTxClass]
+ )
+
+ const drawChart = useCallback(() => {
+ const svg = d3
+ .select(ref.current)
+ .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
+
+ svg.append('g').call(buildXAxis)
+ svg.append('g').call(buildYAxis)
+ svg.append('g').call(buildVerticalLines)
+ svg.append('g').call(buildEventRects)
+ svg.append('g').call(formatTicksText)
+ svg.append('g').call(drawCashIn)
+ svg.append('g').call(drawCashOut)
+ svg.append('g').call(buildHoverableEventRects)
+
+ return svg.node()
+ }, [
+ buildXAxis,
+ buildYAxis,
+ buildEventRects,
+ buildHoverableEventRects,
+ buildVerticalLines,
+ drawCashIn,
+ formatTicksText,
+ drawCashOut
+ ])
+
+ useEffect(() => {
+ d3.select(ref.current)
+ .selectAll('*')
+ .remove()
+ drawChart()
+ }, [drawChart])
+
+ return
+}
+
+export default memo(
+ Graph,
+ (prev, next) =>
+ R.equals(prev.period, next.period) &&
+ R.equals(prev.selectedDay, next.selectedDay)
+)
diff --git a/new-lamassu-admin/src/pages/Analytics/graphs/OverTimeDotGraph.js b/new-lamassu-admin/src/pages/Analytics/graphs/OverTimeDotGraph.js
new file mode 100644
index 00000000..cb64506d
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Analytics/graphs/OverTimeDotGraph.js
@@ -0,0 +1,542 @@
+import BigNumber from 'bignumber.js'
+import * as d3 from 'd3'
+import moment from 'moment'
+import * as R from 'ramda'
+import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
+
+import {
+ java,
+ neon,
+ subheaderDarkColor,
+ offColor,
+ fontColor,
+ primaryColor,
+ fontSecondary,
+ subheaderColor
+} from 'src/styling/variables'
+import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
+
+const Graph = ({
+ data,
+ period,
+ timezone,
+ setSelectionCoords,
+ setSelectionData,
+ setSelectionDateInterval
+}) => {
+ const ref = useRef(null)
+
+ const GRAPH_POPOVER_WIDTH = 150
+ const GRAPH_POPOVER_MARGIN = 25
+ const GRAPH_HEIGHT = 401
+ const GRAPH_WIDTH = 1163
+ const GRAPH_MARGIN = useMemo(
+ () => ({
+ top: 25,
+ right: 0.5,
+ bottom: 27,
+ left: 36.5
+ }),
+ []
+ )
+
+ const offset = parseInt(timezone.split(':')[1]) * MINUTE
+ const NOW = Date.now() + offset
+
+ const periodDomains = {
+ day: [NOW - DAY, NOW],
+ week: [NOW - WEEK, NOW],
+ month: [NOW - MONTH, NOW]
+ }
+
+ const dataPoints = useMemo(
+ () => ({
+ day: {
+ freq: 24,
+ step: 60 * 60 * 1000,
+ tick: d3.utcHour.every(1),
+ labelFormat: '%H:%M'
+ },
+ week: {
+ freq: 7,
+ step: 24 * 60 * 60 * 1000,
+ tick: d3.utcDay.every(1),
+ labelFormat: '%a %d'
+ },
+ month: {
+ freq: 30,
+ step: 24 * 60 * 60 * 1000,
+ tick: d3.utcDay.every(1),
+ labelFormat: '%d'
+ }
+ }),
+ []
+ )
+
+ const getPastAndCurrentDayLabels = useCallback(d => {
+ const currentDate = new Date(d)
+ const currentDateDay = currentDate.getUTCDate()
+ const currentDateWeekday = currentDate.getUTCDay()
+ const currentDateMonth = currentDate.getUTCMonth()
+
+ const previousDate = new Date(currentDate.getTime())
+ previousDate.setUTCDate(currentDateDay - 1)
+
+ const previousDateDay = previousDate.getUTCDate()
+ const previousDateWeekday = previousDate.getUTCDay()
+ const previousDateMonth = previousDate.getUTCMonth()
+
+ const daysOfWeek = moment.weekdaysShort()
+ const months = moment.monthsShort()
+
+ return {
+ previous:
+ currentDateMonth !== previousDateMonth
+ ? months[previousDateMonth]
+ : `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
+ current:
+ currentDateMonth !== previousDateMonth
+ ? months[currentDateMonth]
+ : `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`
+ }
+ }, [])
+
+ const buildTicks = useCallback(
+ domain => {
+ const points = []
+
+ const roundDate = d => {
+ const step = dataPoints[period.code].step
+ return new Date(Math.ceil(d.valueOf() / step) * step)
+ }
+
+ for (let i = 0; i <= dataPoints[period.code].freq; i++) {
+ const stepDate = new Date(NOW - i * dataPoints[period.code].step)
+ if (roundDate(stepDate) > domain[1]) continue
+ if (stepDate < domain[0]) continue
+ points.push(roundDate(stepDate))
+ }
+
+ return points
+ },
+ [NOW, dataPoints, period.code]
+ )
+
+ const buildAreas = useCallback(
+ domain => {
+ const points = []
+
+ points.push(domain[1])
+
+ const roundDate = d => {
+ const step = dataPoints[period.code].step
+ return new Date(Math.ceil(d.valueOf() / step) * step)
+ }
+
+ for (let i = 0; i <= dataPoints[period.code].freq; i++) {
+ const stepDate = new Date(NOW - i * dataPoints[period.code].step)
+ if (roundDate(stepDate) > new Date(domain[1])) continue
+ if (stepDate < new Date(domain[0])) continue
+ points.push(roundDate(stepDate))
+ }
+
+ points.push(domain[0])
+
+ return points
+ },
+ [NOW, dataPoints, period.code]
+ )
+
+ const x = d3
+ .scaleUtc()
+ .domain(periodDomains[period.code])
+ .range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
+
+ const y = d3
+ .scaleLinear()
+ .domain([
+ 0,
+ (d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.03
+ ])
+ .nice()
+ .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
+
+ const getAreaInterval = (breakpoints, limits) => {
+ const fullBreakpoints = [
+ limits[1],
+ ...R.filter(it => it > limits[0] && it < limits[1], breakpoints),
+ limits[0]
+ ]
+
+ const intervals = []
+ for (let i = 0; i < fullBreakpoints.length - 1; i++) {
+ intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]])
+ }
+
+ return intervals
+ }
+
+ const getAreaIntervalByX = (intervals, xValue) => {
+ return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0]
+ }
+
+ const getDateIntervalByX = (areas, intervals, xValue) => {
+ const flattenIntervals = R.uniq(R.flatten(intervals))
+
+ // flattenIntervals and areas should have the same number of elements
+ for (let i = intervals.length - 1; i >= 0; i--) {
+ if (xValue < flattenIntervals[i]) {
+ return [areas[i], areas[i + 1]]
+ }
+ }
+ }
+
+ const buildXAxis = useCallback(
+ g =>
+ g
+ .attr(
+ 'transform',
+ `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
+ )
+ .call(
+ d3
+ .axisBottom(x)
+ .ticks(dataPoints[period.code].tick)
+ .tickFormat(d => {
+ return d3.timeFormat(dataPoints[period.code].labelFormat)(
+ d.getTime() + d.getTimezoneOffset() * MINUTE
+ )
+ })
+ )
+ .call(g => g.select('.domain').remove())
+ .call(g =>
+ g
+ .append('line')
+ .attr('x1', GRAPH_MARGIN.left)
+ .attr('y1', -GRAPH_HEIGHT + GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
+ .attr('x2', GRAPH_MARGIN.left)
+ .attr('stroke', primaryColor)
+ .attr('stroke-width', 1)
+ ),
+ [GRAPH_MARGIN, dataPoints, period.code, x]
+ )
+
+ const buildYAxis = useCallback(
+ g =>
+ g
+ .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
+ .call(d3.axisLeft(y).ticks(GRAPH_HEIGHT / 100))
+ .call(g => g.select('.domain').remove())
+ .call(g =>
+ g
+ .selectAll('.tick line')
+ .filter(d => d === 0)
+ .clone()
+ .attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right - GRAPH_MARGIN.left)
+ .attr('stroke-width', 1)
+ .attr('stroke', primaryColor)
+ ),
+ [GRAPH_MARGIN, y]
+ )
+
+ const buildGrid = useCallback(
+ g => {
+ g.attr('stroke', subheaderDarkColor)
+ .attr('fill', subheaderDarkColor)
+ // Vertical lines
+ .call(g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(buildTicks(x.domain()))
+ .join('line')
+ .attr('x1', d => 0.5 + x(d))
+ .attr('x2', d => 0.5 + x(d))
+ .attr('y1', GRAPH_MARGIN.top)
+ .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
+ )
+ // Horizontal lines
+ .call(g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(
+ d3
+ .axisLeft(y)
+ .scale()
+ .ticks(GRAPH_HEIGHT / 100)
+ )
+ .join('line')
+ .attr('y1', d => 0.5 + y(d))
+ .attr('y2', d => 0.5 + y(d))
+ .attr('x1', GRAPH_MARGIN.left)
+ .attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
+ )
+ // Vertical transparent rectangles for events
+ .call(g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(buildAreas(x.domain()))
+ .join('rect')
+ .attr('x', d => x(d))
+ .attr('y', GRAPH_MARGIN.top)
+ .attr('width', d => {
+ const xValue = Math.round(x(d) * 100) / 100
+ const intervals = getAreaInterval(
+ buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
+ x.range()
+ )
+ const interval = getAreaIntervalByX(intervals, xValue)
+ return Math.round((interval[0] - interval[1]) * 100) / 100
+ })
+ .attr(
+ 'height',
+ GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top
+ )
+ .attr('stroke', 'transparent')
+ .attr('fill', 'transparent')
+ .on('mouseover', d => {
+ const xValue = Math.round(d.target.x.baseVal.value * 100) / 100
+ const areas = buildAreas(x.domain())
+ const intervals = getAreaInterval(
+ buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
+ x.range()
+ )
+
+ const dateInterval = getDateIntervalByX(areas, intervals, xValue)
+ const filteredData = data.filter(it => {
+ const created = new Date(it.created)
+ const tzCreated = created.setTime(created.getTime() + offset)
+ return (
+ tzCreated > new Date(dateInterval[1]) &&
+ tzCreated <= new Date(dateInterval[0])
+ )
+ })
+
+ const rectXCoords = {
+ left: R.clone(d.target.getBoundingClientRect().x),
+ right: R.clone(
+ d.target.getBoundingClientRect().x +
+ d.target.getBoundingClientRect().width
+ )
+ }
+
+ const xCoord =
+ d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
+ ? rectXCoords.right + GRAPH_POPOVER_MARGIN
+ : rectXCoords.left -
+ GRAPH_POPOVER_WIDTH -
+ GRAPH_POPOVER_MARGIN
+ const yCoord = R.clone(d.target.getBoundingClientRect().y)
+
+ setSelectionDateInterval(dateInterval)
+ setSelectionData(filteredData)
+ setSelectionCoords({
+ x: Math.round(xCoord),
+ y: Math.round(yCoord)
+ })
+
+ d3.select(d.target).attr('fill', subheaderColor)
+ })
+ .on('mouseleave', d => {
+ d3.select(d.target).attr('fill', 'transparent')
+ setSelectionDateInterval(null)
+ setSelectionData(null)
+ setSelectionCoords(null)
+ })
+ )
+ // Thick vertical lines
+ .call(g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(
+ buildTicks(x.domain()).filter(x => {
+ if (period.code === 'day') return x.getUTCHours() === 0
+ return x.getUTCDate() === 1
+ })
+ )
+ .join('line')
+ .attr('class', 'dateSeparator')
+ .attr('x1', d => 0.5 + x(d))
+ .attr('x2', d => 0.5 + x(d))
+ .attr('y1', GRAPH_MARGIN.top - 50)
+ .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
+ .attr('stroke-width', 5)
+ .join('text')
+ )
+ // Left side breakpoint label
+ .call(g => {
+ const separator = d3
+ ?.select('.dateSeparator')
+ ?.node()
+ ?.getBBox()
+
+ if (!separator) return
+
+ const breakpoint = buildTicks(x.domain()).filter(x => {
+ if (period.code === 'day') return x.getUTCHours() === 0
+ return x.getUTCDate() === 1
+ })
+
+ const labels = getPastAndCurrentDayLabels(breakpoint)
+
+ return g
+ .append('text')
+ .attr('x', separator.x - 10)
+ .attr('y', separator.y + 33)
+ .attr('text-anchor', 'end')
+ .attr('dy', '.25em')
+ .text(labels.previous)
+ })
+ // Right side breakpoint label
+ .call(g => {
+ const separator = d3
+ ?.select('.dateSeparator')
+ ?.node()
+ ?.getBBox()
+
+ if (!separator) return
+
+ const breakpoint = buildTicks(x.domain()).filter(x => {
+ if (period.code === 'day') return x.getUTCHours() === 0
+ return x.getUTCDate() === 1
+ })
+
+ const labels = getPastAndCurrentDayLabels(breakpoint)
+
+ return g
+ .append('text')
+ .attr('x', separator.x + 10)
+ .attr('y', separator.y + 33)
+ .attr('text-anchor', 'start')
+ .attr('dy', '.25em')
+ .text(labels.current)
+ })
+ },
+ [
+ GRAPH_MARGIN,
+ buildTicks,
+ getPastAndCurrentDayLabels,
+ x,
+ y,
+ period,
+ buildAreas,
+ data,
+ offset,
+ setSelectionCoords,
+ setSelectionData,
+ setSelectionDateInterval
+ ]
+ )
+
+ const formatTicksText = useCallback(
+ () =>
+ d3
+ .selectAll('.tick text')
+ .style('stroke', fontColor)
+ .style('fill', fontColor)
+ .style('stroke-width', 0.5)
+ .style('font-family', fontSecondary),
+ []
+ )
+
+ const formatText = useCallback(
+ () =>
+ d3
+ .selectAll('text')
+ .style('stroke', offColor)
+ .style('fill', offColor)
+ .style('stroke-width', 0.5)
+ .style('font-family', fontSecondary),
+ []
+ )
+
+ const formatTicks = useCallback(() => {
+ d3.selectAll('.tick line')
+ .style('stroke', primaryColor)
+ .style('fill', primaryColor)
+ }, [])
+
+ const buildAvg = useCallback(
+ g => {
+ g.attr('stroke', primaryColor)
+ .attr('stroke-width', 3)
+ .attr('stroke-dasharray', '10, 5')
+ .call(g =>
+ g
+ .append('line')
+ .attr(
+ 'y1',
+ 0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
+ )
+ .attr(
+ 'y2',
+ 0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
+ )
+ .attr('x1', GRAPH_MARGIN.left)
+ .attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
+ )
+ },
+ [GRAPH_MARGIN, y, data]
+ )
+
+ const drawData = useCallback(
+ g => {
+ g.selectAll('circle')
+ .data(data)
+ .join('circle')
+ .attr('cx', d => {
+ const created = new Date(d.created)
+ return x(created.setTime(created.getTime() + offset))
+ })
+ .attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
+ .attr('fill', d => (d.txClass === 'cashIn' ? java : neon))
+ .attr('r', 3.5)
+ },
+ [data, offset, x, y]
+ )
+
+ const drawChart = useCallback(() => {
+ const svg = d3
+ .select(ref.current)
+ .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
+
+ svg.append('g').call(buildGrid)
+ svg.append('g').call(buildAvg)
+ svg.append('g').call(buildXAxis)
+ svg.append('g').call(buildYAxis)
+ svg.append('g').call(formatTicksText)
+ svg.append('g').call(formatText)
+ svg.append('g').call(formatTicks)
+ svg.append('g').call(drawData)
+
+ return svg.node()
+ }, [
+ buildAvg,
+ buildGrid,
+ buildXAxis,
+ buildYAxis,
+ drawData,
+ formatText,
+ formatTicks,
+ formatTicksText
+ ])
+
+ useEffect(() => {
+ d3.select(ref.current)
+ .selectAll('*')
+ .remove()
+ drawChart()
+ }, [drawChart])
+
+ return
+}
+
+export default memo(
+ Graph,
+ (prev, next) =>
+ R.equals(prev.period, next.period) &&
+ R.equals(prev.selectedMachine, next.selectedMachine)
+)
diff --git a/new-lamassu-admin/src/pages/Analytics/graphs/TopMachinesBarGraph.js b/new-lamassu-admin/src/pages/Analytics/graphs/TopMachinesBarGraph.js
new file mode 100644
index 00000000..47d794af
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Analytics/graphs/TopMachinesBarGraph.js
@@ -0,0 +1,307 @@
+import BigNumber from 'bignumber.js'
+import * as d3 from 'd3'
+import * as R from 'ramda'
+import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
+
+import {
+ java,
+ neon,
+ subheaderDarkColor,
+ fontColor,
+ fontSecondary
+} from 'src/styling/variables'
+
+const Graph = ({ data, machines, currency }) => {
+ const ref = useRef(null)
+
+ const AMOUNT_OF_MACHINES = 5
+ const BAR_PADDING = 0.15
+ const BAR_MARGIN = 10
+ const GRAPH_HEIGHT = 401
+ const GRAPH_WIDTH = 1163
+ const GRAPH_MARGIN = useMemo(
+ () => ({
+ top: 25,
+ right: 0.5,
+ bottom: 27,
+ left: 36.5
+ }),
+ []
+ )
+
+ const machinesClone = R.clone(machines)
+
+ // This ensures that the graph renders a minimum amount of machines
+ // and avoids having a single bar for cases with one machine
+ const filledMachines =
+ R.length(machines) >= AMOUNT_OF_MACHINES
+ ? machinesClone
+ : R.map(it => {
+ if (!R.isNil(machinesClone[it])) return machinesClone[it]
+ return { code: `ghostMachine${it}`, display: `` }
+ }, R.times(R.identity, AMOUNT_OF_MACHINES))
+
+ const txByDevice = R.reduce(
+ (acc, value) => {
+ acc[value.code] = R.filter(it => it.deviceId === value.code, data)
+ return acc
+ },
+ {},
+ filledMachines
+ )
+
+ const getDeviceVolume = deviceId =>
+ R.reduce(
+ (acc, value) => acc + BigNumber(value.fiat).toNumber(),
+ 0,
+ txByDevice[deviceId]
+ )
+
+ const getDeviceVolumeByTxClass = deviceId =>
+ R.reduce(
+ (acc, value) => {
+ if (value.txClass === 'cashIn')
+ acc.cashIn += BigNumber(value.fiat).toNumber()
+ if (value.txClass === 'cashOut')
+ acc.cashOut += BigNumber(value.fiat).toNumber()
+ return acc
+ },
+ { cashIn: 0, cashOut: 0 },
+ txByDevice[deviceId]
+ )
+
+ const devicesByVolume = R.sort(
+ (a, b) => b[1] - a[1],
+ R.map(m => [m.code, getDeviceVolume(m.code)], filledMachines)
+ )
+
+ const topMachines = R.take(AMOUNT_OF_MACHINES, devicesByVolume)
+
+ const txClassVolumeByDevice = R.fromPairs(
+ R.map(v => [v[0], getDeviceVolumeByTxClass(v[0])], topMachines)
+ )
+
+ const x = d3
+ .scaleBand()
+ .domain(topMachines)
+ .range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
+ .paddingInner(BAR_PADDING)
+
+ const y = d3
+ .scaleLinear()
+ .domain([
+ 0,
+ d3.max(topMachines, d => d[1]) !== 0 ? d3.max(topMachines, d => d[1]) : 50
+ ])
+ .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
+
+ const buildXAxis = useCallback(
+ g =>
+ g
+ .attr('class', 'x-axis-1')
+ .attr(
+ 'transform',
+ `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
+ )
+ .call(
+ d3
+ .axisBottom(x)
+ .tickFormat(
+ d =>
+ `${R.find(it => it.code === d[0], filledMachines).display ??
+ ''}`
+ )
+ .tickSize(0)
+ .tickPadding(10)
+ ),
+ [GRAPH_MARGIN, x, filledMachines]
+ )
+
+ const buildXAxis2 = useCallback(
+ g => {
+ g.attr('class', 'x-axis-2')
+ .attr(
+ 'transform',
+ `translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
+ )
+ .call(
+ d3
+ .axisBottom(x)
+ .tickFormat(d =>
+ R.includes(`ghostMachine`, d[0])
+ ? ``
+ : `${d[1].toFixed(2)} ${currency}`
+ )
+ .tickSize(0)
+ .tickPadding(10)
+ )
+ },
+ [GRAPH_MARGIN, x, currency]
+ )
+
+ const positionXAxisLabels = useCallback(() => {
+ d3.selectAll('.x-axis-1 .tick text').attr('transform', function(d) {
+ const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
+ return `translate(${-widthPerEntry / 2.25 + this.getBBox().width / 2}, 0)`
+ })
+ }, [x])
+
+ const positionXAxis2Labels = useCallback(() => {
+ d3.selectAll('.x-axis-2 .tick text').attr('transform', function(d) {
+ const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
+ return `translate(${widthPerEntry / 2.25 - this.getBBox().width / 2}, 0)`
+ })
+ }, [x])
+
+ const buildYAxis = useCallback(
+ g =>
+ g
+ .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
+ .call(
+ d3
+ .axisLeft(y)
+ .ticks(GRAPH_HEIGHT / 100)
+ .tickSize(0)
+ .tickFormat(``)
+ )
+ .call(g => g.select('.domain').remove()),
+ [GRAPH_MARGIN, y]
+ )
+
+ const formatTicksText = useCallback(
+ () =>
+ d3
+ .selectAll('.tick text')
+ .style('stroke', fontColor)
+ .style('fill', fontColor)
+ .style('stroke-width', 0.5)
+ .style('font-family', fontSecondary),
+ []
+ )
+
+ const buildGrid = useCallback(
+ g => {
+ g.attr('stroke', subheaderDarkColor)
+ .attr('fill', subheaderDarkColor)
+ // Vertical lines
+ .call(g =>
+ g
+ .append('g')
+ .selectAll('line')
+ .data(R.tail(x.domain()))
+ .join('line')
+ .attr('x1', d => {
+ const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
+
+ const xValue =
+ x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
+
+ const paddedXValue = xValue * (BAR_PADDING / 2)
+ return 0.5 + x(d) - paddedXValue
+ })
+ .attr('x2', d => {
+ const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
+
+ const xValue =
+ x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
+
+ const paddedXValue = xValue * (BAR_PADDING / 2)
+ return 0.5 + x(d) - paddedXValue
+ })
+ .attr('y1', GRAPH_MARGIN.top)
+ .attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
+ )
+ },
+ [GRAPH_MARGIN, x]
+ )
+
+ const drawCashIn = useCallback(
+ g => {
+ g.selectAll('rect')
+ .data(R.toPairs(txClassVolumeByDevice))
+ .join('rect')
+ .attr('fill', java)
+ .attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
+ .attr('y', d => y(d[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
+ .attr('height', d =>
+ R.clamp(
+ 0,
+ GRAPH_HEIGHT,
+ GRAPH_HEIGHT - y(d[1].cashIn) - GRAPH_MARGIN.bottom - BAR_MARGIN
+ )
+ )
+ .attr('width', x.bandwidth())
+ .attr('rx', 2.5)
+ },
+ [txClassVolumeByDevice, x, y, GRAPH_MARGIN]
+ )
+
+ const drawCashOut = useCallback(
+ g => {
+ g.selectAll('rect')
+ .data(R.toPairs(txClassVolumeByDevice))
+ .join('rect')
+ .attr('fill', neon)
+ .attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
+ .attr(
+ 'y',
+ d =>
+ y(d[1].cashIn + d[1].cashOut) -
+ GRAPH_MARGIN.top +
+ GRAPH_MARGIN.bottom
+ )
+ .attr('height', d => {
+ return R.clamp(
+ 0,
+ GRAPH_HEIGHT,
+ GRAPH_HEIGHT -
+ y(d[1].cashOut) -
+ GRAPH_MARGIN.bottom -
+ BAR_MARGIN / 2
+ )
+ })
+ .attr('width', x.bandwidth())
+ .attr('rx', 2.5)
+ },
+ [txClassVolumeByDevice, x, y, GRAPH_MARGIN]
+ )
+
+ const drawChart = useCallback(() => {
+ const svg = d3
+ .select(ref.current)
+ .attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
+
+ svg.append('g').call(buildXAxis)
+ svg.append('g').call(buildXAxis2)
+ svg.append('g').call(buildYAxis)
+ svg.append('g').call(formatTicksText)
+ svg.append('g').call(buildGrid)
+ svg.append('g').call(drawCashIn)
+ svg.append('g').call(drawCashOut)
+ svg.append('g').call(positionXAxisLabels)
+ svg.append('g').call(positionXAxis2Labels)
+
+ return svg.node()
+ }, [
+ buildXAxis,
+ buildXAxis2,
+ positionXAxisLabels,
+ positionXAxis2Labels,
+ buildYAxis,
+ formatTicksText,
+ buildGrid,
+ drawCashIn,
+ drawCashOut
+ ])
+
+ useEffect(() => {
+ d3.select(ref.current)
+ .selectAll('*')
+ .remove()
+ drawChart()
+ }, [drawChart])
+
+ return
+}
+
+export default memo(Graph, (prev, next) => R.equals(prev.period, next.period))
diff --git a/new-lamassu-admin/src/routing/lamassu.routes.js b/new-lamassu-admin/src/routing/lamassu.routes.js
index 1e8e1ff3..7394adf8 100644
--- a/new-lamassu-admin/src/routing/lamassu.routes.js
+++ b/new-lamassu-admin/src/routing/lamassu.routes.js
@@ -1,6 +1,7 @@
import React from 'react'
import { Redirect } from 'react-router-dom'
+import Analytics from 'src/pages/Analytics/Analytics'
import Blacklist from 'src/pages/Blacklist'
import Cashout from 'src/pages/Cashout'
import Commissions from 'src/pages/Commissions'
@@ -82,6 +83,13 @@ const getLamassuRoutes = () => [
}
]
},
+ {
+ key: 'analytics',
+ label: 'Analytics',
+ route: '/analytics',
+ allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
+ component: Analytics
+ },
{
key: 'settings',
label: 'Settings',
diff --git a/new-lamassu-admin/src/routing/pazuz.routes.js b/new-lamassu-admin/src/routing/pazuz.routes.js
index 81cf8c36..a0d45648 100644
--- a/new-lamassu-admin/src/routing/pazuz.routes.js
+++ b/new-lamassu-admin/src/routing/pazuz.routes.js
@@ -3,6 +3,7 @@ import { Redirect } from 'react-router-dom'
import ATMWallet from 'src/pages/ATMWallet/ATMWallet'
import Accounting from 'src/pages/Accounting/Accounting'
+import Analytics from 'src/pages/Analytics/Analytics'
import Assets from 'src/pages/Assets/Assets'
import Blacklist from 'src/pages/Blacklist'
import Cashout from 'src/pages/Cashout'
@@ -84,6 +85,13 @@ const getPazuzRoutes = () => [
}
]
},
+ {
+ key: 'analytics',
+ label: 'Analytics',
+ route: '/analytics',
+ allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
+ component: Analytics
+ },
{
key: 'settings',
label: 'Settings',
diff --git a/new-lamassu-admin/src/utils/time.js b/new-lamassu-admin/src/utils/time.js
new file mode 100644
index 00000000..e3ab99d1
--- /dev/null
+++ b/new-lamassu-admin/src/utils/time.js
@@ -0,0 +1,7 @@
+const MINUTE = 60 * 1000
+const HOUR = 60 * 60 * 1000
+const DAY = 24 * 60 * 60 * 1000
+const WEEK = 7 * 24 * 60 * 60 * 1000
+const MONTH = 30 * 24 * 60 * 60 * 1000
+
+export { MINUTE, HOUR, DAY, WEEK, MONTH }
diff --git a/new-lamassu-admin/src/utils/timezones.js b/new-lamassu-admin/src/utils/timezones.js
index f8a23555..a4f36ab1 100644
--- a/new-lamassu-admin/src/utils/timezones.js
+++ b/new-lamassu-admin/src/utils/timezones.js
@@ -75,11 +75,15 @@ const getTzLabels = timezones =>
)
const formatDate = (date, timezoneCode, format) => {
- const dstOffset = timezoneCode.split(':')[1]
+ const dstOffset = timezoneCode?.split(':')[1] ?? 0
return moment
.utc(date)
.utcOffset(parseInt(dstOffset))
.format(format)
}
-export { getTzLabels, formatDate }
+const formatDateNonUtc = (date, format) => {
+ return moment(date).format(format)
+}
+
+export { getTzLabels, formatDate, formatDateNonUtc }