shlink-web-client/src/visits/helpers/LineChartCard.tsx

203 lines
6 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo } from 'react';
import {
Card,
CardHeader,
CardBody,
UncontrolledDropdown,
DropdownToggle,
DropdownMenu,
DropdownItem,
} from 'reactstrap';
import { Line } from 'react-chartjs-2';
2020-05-31 21:16:15 +03:00
import { always, cond, reverse } from 'ramda';
import moment from 'moment';
import { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
2020-09-06 11:22:21 +03:00
import { NormalizedVisit, Stats } from '../types';
import { fillTheGaps, renderNonDoughnutChartLabel } from '../../utils/helpers/visits';
2020-05-30 18:39:08 +03:00
import { useToggle } from '../../utils/helpers/hooks';
import { rangeOf } from '../../utils/utils';
2020-07-14 17:05:00 +03:00
import ToggleSwitch from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers';
2020-09-15 23:22:56 +03:00
import './LineChartCard.scss';
interface LineChartCardProps {
title: string;
highlightedLabel?: string;
2020-09-06 11:22:21 +03:00
visits: NormalizedVisit[];
2020-09-05 09:49:18 +03:00
highlightedVisits: NormalizedVisit[];
}
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
const STEPS_MAP: Record<Step, string> = {
2020-05-30 18:39:08 +03:00
monthly: 'Month',
weekly: 'Week',
daily: 'Day',
hourly: 'Hour',
};
const STEP_TO_DATE_UNIT_MAP: Record<Step, moment.unitOfTime.Diff> = {
2020-05-30 18:39:08 +03:00
hourly: 'hour',
daily: 'day',
weekly: 'week',
monthly: 'month',
};
const STEP_TO_DATE_FORMAT: Record<Step, (date: moment.Moment | string) => string> = {
hourly: (date) => moment(date).format('YYYY-MM-DD HH:00'),
daily: (date) => moment(date).format('YYYY-MM-DD'),
weekly(date) {
const firstWeekDay = moment(date).isoWeekday(1).format('YYYY-MM-DD');
const lastWeekDay = moment(date).isoWeekday(7).format('YYYY-MM-DD');
return `${firstWeekDay} - ${lastWeekDay}`;
},
monthly: (date) => moment(date).format('YYYY-MM'),
};
const determineInitialStep = (oldestVisitDate: string): Step => {
const now = moment();
const oldestDate = moment(oldestVisitDate);
const matcher = cond<never, Step | undefined>([
[ () => now.diff(oldestDate, 'day') <= 2, always<Step>('hourly') ], // Less than 2 days
[ () => now.diff(oldestDate, 'month') <= 1, always<Step>('daily') ], // Between 2 days and 1 month
[ () => now.diff(oldestDate, 'month') <= 6, always<Step>('weekly') ], // Between 1 and 6 months
2020-05-31 21:16:15 +03:00
]);
return matcher() ?? 'monthly';
};
2020-09-06 11:22:21 +03:00
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visits.reduce<Stats>(
2020-09-05 09:49:18 +03:00
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](visit.date);
2020-09-05 09:49:18 +03:00
acc[key] = acc[key] ? acc[key] + 1 : 1;
2020-09-05 09:49:18 +03:00
return acc;
},
{},
);
2020-09-06 11:22:21 +03:00
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const unit = STEP_TO_DATE_UNIT_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step];
2020-05-30 18:39:08 +03:00
const newerDate = moment(visits[0].date);
const oldestDate = moment(visits[visits.length - 1].date);
const size = newerDate.diff(oldestDate, unit);
2020-05-30 18:39:08 +03:00
return [
formatter(oldestDate),
...rangeOf(size, () => formatter(oldestDate.add(1, unit))),
2020-05-30 18:39:08 +03:00
];
};
const generateLabelsAndGroupedVisits = (
2020-09-06 11:22:21 +03:00
visits: NormalizedVisit[],
groupedVisitsWithGaps: Stats,
step: Step,
skipNoElements: boolean,
): [string[], number[]] => {
2020-05-30 18:39:08 +03:00
if (skipNoElements) {
return [ Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps) ];
2020-05-30 18:39:08 +03:00
}
const labels = generateLabels(step, visits);
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
2020-05-30 18:39:08 +03:00
};
const generateDataset = (data: number[], label: string, color: string): ChartDataSets => ({
label,
data,
fill: false,
lineTension: 0.2,
borderColor: color,
backgroundColor: color,
});
const LineChartCard = ({ title, visits, highlightedVisits, highlightedLabel = 'Selected' }: LineChartCardProps) => {
const [ step, setStep ] = useState<Step>(
2020-08-22 09:06:41 +03:00
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
);
2020-05-30 18:39:08 +03:00
const [ skipNoVisits, toggleSkipNoVisits ] = useToggle(true);
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [ step, visits ]);
2020-05-30 18:39:08 +03:00
const [ labels, groupedVisits ] = useMemo(
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
2020-08-22 09:06:41 +03:00
[ visits, step, skipNoVisits ],
2020-05-30 18:39:08 +03:00
);
const groupedHighlighted = useMemo(
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
2020-08-22 09:10:31 +03:00
[ highlightedVisits, step, labels ],
);
const data: ChartData = {
labels,
datasets: [
generateDataset(groupedVisits, 'Visits', '#4696e5'),
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, '#F77F28'),
].filter(Boolean) as ChartDataSets[],
};
const options: ChartOptions = {
maintainAspectRatio: false,
legend: { display: false },
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
// @ts-expect-error
precision: 0,
callback: prettify,
},
},
],
2020-05-30 18:39:08 +03:00
xAxes: [
{
scaleLabel: { display: true, labelString: STEPS_MAP[step] },
},
],
},
tooltips: {
intersect: false,
// @ts-expect-error
2020-05-30 18:39:08 +03:00
axis: 'x',
callbacks: {
label: renderNonDoughnutChartLabel('yLabel'),
},
},
};
return (
<Card>
<CardHeader>
{title}
<div className="float-right">
<UncontrolledDropdown>
<DropdownToggle caret color="link" className="btn-sm p-0">
Group by
</DropdownToggle>
<DropdownMenu right>
2020-05-30 18:39:08 +03:00
{Object.entries(STEPS_MAP).map(([ value, menuText ]) => (
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
{menuText}
</DropdownItem>
))}
</DropdownMenu>
</UncontrolledDropdown>
</div>
2020-05-30 18:39:08 +03:00
<div className="float-right mr-2">
2020-07-14 17:05:00 +03:00
<ToggleSwitch checked={skipNoVisits} onChange={toggleSkipNoVisits}>
2020-05-30 18:39:08 +03:00
<small>Skip dates with no visits</small>
2020-07-14 17:05:00 +03:00
</ToggleSwitch>
2020-05-30 18:39:08 +03:00
</div>
</CardHeader>
<CardBody className="line-chart-card__body">
<Line data={data} options={options} />
</CardBody>
</Card>
);
};
export default LineChartCard;