Added graph with orphan visits grouped by visited URL

This commit is contained in:
Alejandro Celaya 2021-03-28 20:56:16 +02:00
parent d6bb718672
commit f0a04ced75
5 changed files with 56 additions and 26 deletions

View File

@ -1,4 +1,4 @@
import { countBy, filter, isEmpty, pipe, prop, propEq, values } from 'ramda'; import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC } from 'react'; import { useState, useEffect, useMemo, FC } from 'react';
import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -19,11 +19,12 @@ import SortableBarGraph from './helpers/SortableBarGraph';
import GraphCard from './helpers/GraphCard'; import GraphCard from './helpers/GraphCard';
import LineChartCard from './helpers/LineChartCard'; import LineChartCard from './helpers/LineChartCard';
import VisitsTable from './VisitsTable'; import VisitsTable from './VisitsTable';
import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, Stats, Visit, VisitsInfo } from './types'; import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types';
import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import OpenMapModalBtn from './helpers/OpenMapModalBtn';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; import { processStatsFromVisits } from './services/VisitsParser';
import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown';
import './VisitsStats.scss'; import './VisitsStats.scss';
import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers';
export interface VisitsStatsProps { export interface VisitsStatsProps {
getVisits: (params: Partial<ShlinkVisitsParams>) => void; getVisits: (params: Partial<ShlinkVisitsParams>) => void;
@ -42,7 +43,6 @@ interface VisitsNavLinkProps {
icon: IconDefinition; icon: IconDefinition;
} }
type HighlightableProps = 'referer' | 'country' | 'city';
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list'; type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
const sections: Record<Section, VisitsNavLinkProps> = { const sections: Record<Section, VisitsNavLinkProps> = {
@ -52,14 +52,6 @@ const sections: Record<Section, VisitsNavLinkProps> = {
list: { title: 'List', subPath: '/list', icon: faList }, list: { title: 'List', subPath: '/list', icon: faList },
}; };
const highlightedVisitsToStats = (highlightedVisits: NormalizedVisit[], property: HighlightableProps): Stats =>
countBy(prop(property), highlightedVisits);
const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe(
normalizeVisits,
filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type),
)(visits);
let selectedBar: string | undefined; let selectedBar: string | undefined;
const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title, icon, to }) => ( const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title, icon, to }) => (
@ -94,7 +86,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
() => normalizeAndFilterVisits(visits, orphanVisitType), () => normalizeAndFilterVisits(visits, orphanVisitType),
[ visits, orphanVisitType ], [ visits, orphanVisitType ],
); );
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits), () => processStatsFromVisits(normalizedVisits),
[ normalizedVisits ], [ normalizedVisits ],
); );
@ -104,7 +96,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
selectedBar = undefined; selectedBar = undefined;
setHighlightedVisits(selectedVisits); setHighlightedVisits(selectedVisits);
}; };
const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => { const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
const newSelectedBar = `${prop}_${value}`; const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) { if (selectedBar === newSelectedBar) {
@ -112,7 +104,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
setHighlightedLabel(undefined); setHighlightedLabel(undefined);
selectedBar = undefined; selectedBar = undefined;
} else { } else {
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
setHighlightedLabel(value); setHighlightedLabel(value);
selectedBar = newSelectedBar; selectedBar = newSelectedBar;
} }
@ -198,11 +190,14 @@ const VisitsStats: FC<VisitsStatsProps> = (
<div className="mt-4 col-lg-6"> <div className="mt-4 col-lg-6">
<SortableBarGraph <SortableBarGraph
title="Visited URLs" title="Visited URLs"
stats={{}} stats={visitedUrls}
highlightedLabel={highlightedLabel}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'visitedUrl')}
sortingItems={{ sortingItems={{
visitedUrl: 'Visited URL', visitedUrl: 'Visited URL',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('visitedUrl')}
/> />
</div> </div>
)} )}

View File

@ -93,11 +93,8 @@ const VisitsTable = ({
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
if (isFirstLoad.current) { !isFirstLoad.current && setSelectedVisits([]);
isFirstLoad.current = false; isFirstLoad.current = false;
} else {
setSelectedVisits([]);
}
}, [ searchTerm ]); }, [ searchTerm ]);
return ( return (
@ -157,7 +154,7 @@ const VisitsTable = ({
</td> </td>
</tr> </tr>
)} )}
{resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => { {resultSet.visitsGroups[page - 1]?.map((visit, index) => {
const isSelected = selectedVisits.includes(visit); const isSelected = selectedVisits.includes(visit);
return ( return (

View File

@ -2,7 +2,7 @@ import { isNil, map } from 'ramda';
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits'; import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
import { hasValue } from '../../utils/utils'; import { hasValue } from '../../utils/utils';
import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types'; import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
import { isOrphanVisit } from '../types/helpers'; import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers';
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) => const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
!isNil(visit) && hasValue(visit[propertyName]); !isNil(visit) && hasValue(visit[propertyName]);
@ -54,6 +54,16 @@ const updateCitiesForMapForVisit = (citiesForMapStats: Record<string, CityStats>
citiesForMapStats[city] = currentCity; citiesForMapStats[city] = currentCity;
}; };
const updateVisitedUrlsForVisit = (visitedUrlsStats: Stats, visit: NormalizedVisit) => {
if (!isNormalizedOrphanVisit(visit)) {
return;
}
const { visitedUrl } = visit;
visitedUrlsStats[visitedUrl] = (visitedUrlsStats[visitedUrl] || 0) + 1;
};
export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.reduce( export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.reduce(
(stats: VisitsStats, visit: NormalizedVisit) => { (stats: VisitsStats, visit: NormalizedVisit) => {
// We mutate the original object because it has a big performance impact when large data sets are processed // We mutate the original object because it has a big performance impact when large data sets are processed
@ -63,10 +73,11 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
updateCountriesStatsForVisit(stats.countries, visit); updateCountriesStatsForVisit(stats.countries, visit);
updateCitiesStatsForVisit(stats.cities, visit); updateCitiesStatsForVisit(stats.cities, visit);
updateCitiesForMapForVisit(stats.citiesForMap, visit); updateCitiesForMapForVisit(stats.citiesForMap, visit);
updateVisitedUrlsForVisit(stats.visitedUrls, visit);
return stats; return stats;
}, },
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }, { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} },
); );
export const normalizeVisits = map((visit: Visit): NormalizedVisit => { export const normalizeVisits = map((visit: Visit): NormalizedVisit => {

View File

@ -1,8 +1,20 @@
import { groupBy, pipe } from 'ramda'; import { countBy, filter, groupBy, pipe, prop } from 'ramda';
import { Visit, OrphanVisit, CreateVisit } from './index'; import { normalizeVisits } from '../services/VisitsParser';
import {
Visit,
OrphanVisit,
CreateVisit,
NormalizedVisit,
NormalizedOrphanVisit,
Stats,
OrphanVisitType,
} from './index';
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit =>
visit.hasOwnProperty('visitedUrl');
export interface GroupedNewVisits { export interface GroupedNewVisits {
orphanVisits: CreateVisit[]; orphanVisits: CreateVisit[];
regularVisits: CreateVisit[]; regularVisits: CreateVisit[];
@ -13,3 +25,17 @@ export const groupNewVisitsByType = pipe(
// @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props // @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }), (result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
); );
export type HighlightableProps<T extends NormalizedVisit> = T extends NormalizedOrphanVisit
? ('referer' | 'country' | 'city' | 'visitedUrl')
: ('referer' | 'country' | 'city');
export const highlightedVisitsToStats = <T extends NormalizedVisit>(
highlightedVisits: T[],
property: HighlightableProps<T>,
): Stats => countBy(prop(property) as any, highlightedVisits);
export const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe(
normalizeVisits,
filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type),
)(visits);

View File

@ -90,4 +90,5 @@ export interface VisitsStats {
countries: Stats; countries: Stats;
cities: Stats; cities: Stats;
citiesForMap: Record<string, CityStats>; citiesForMap: Record<string, CityStats>;
visitedUrls: Stats;
} }