Extract shlink-web-component outside of src folder

This commit is contained in:
Alejandro Celaya
2023-07-24 20:14:59 +02:00
parent 768fb1992f
commit 3a0cea1268
230 changed files with 485 additions and 524 deletions

View File

@@ -0,0 +1,41 @@
import { useParams } from 'react-router-dom';
import type { ReportExporter } from '../../src/common/services/ReportExporter';
import { useGoBack } from '../../src/utils/helpers/hooks';
import type { ShlinkVisitsParams } from '../api-contract';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits';
import type { NormalizedVisit } from './types';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
export interface DomainVisitsProps {
getDomainVisits: (params: LoadDomainVisits) => void;
domainVisits: DomainVisitsState;
cancelGetDomainVisits: () => void;
}
export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getDomainVisits,
domainVisits,
cancelGetDomainVisits,
}: DomainVisitsProps) => {
const goBack = useGoBack();
const { domain = '' } = useParams();
const [authority, domainId = authority] = domain.split('_');
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
getDomainVisits({ domain: domainId, query: toApiParams(params), doIntervalFallback });
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetDomainVisits}
visitsInfo={domainVisits}
exportCsv={exportCsv}
>
<VisitsHeader goBack={goBack} visits={domainVisits.visits} title={`"${authority}" visits`} />
</VisitsStats>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,37 @@
import type { ReportExporter } from '../../src/common/services/ReportExporter';
import { useGoBack } from '../../src/utils/helpers/hooks';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { LoadVisits, VisitsInfo } from './reducers/types';
import type { NormalizedVisit, VisitsParams } from './types';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
export interface NonOrphanVisitsProps {
getNonOrphanVisits: (params: LoadVisits) => void;
nonOrphanVisits: VisitsInfo;
cancelGetNonOrphanVisits: () => void;
}
export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getNonOrphanVisits,
nonOrphanVisits,
cancelGetNonOrphanVisits,
}: NonOrphanVisitsProps) => {
const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getNonOrphanVisits({ query: toApiParams(params), doIntervalFallback });
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetNonOrphanVisits}
visitsInfo={nonOrphanVisits}
exportCsv={exportCsv}
>
<VisitsHeader title="Non-orphan visits" goBack={goBack} visits={nonOrphanVisits.visits} />
</VisitsStats>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,40 @@
import type { ReportExporter } from '../../src/common/services/ReportExporter';
import { useGoBack } from '../../src/utils/helpers/hooks';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { LoadOrphanVisits } from './reducers/orphanVisits';
import type { VisitsInfo } from './reducers/types';
import type { NormalizedVisit, VisitsParams } from './types';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsStats } from './VisitsStats';
export interface OrphanVisitsProps {
getOrphanVisits: (params: LoadOrphanVisits) => void;
orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void;
}
export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
getOrphanVisits,
orphanVisits,
cancelGetOrphanVisits,
}: OrphanVisitsProps) => {
const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getOrphanVisits(
{ query: toApiParams(params), orphanVisitsType: params.filter?.orphanVisitsType, doIntervalFallback },
);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetOrphanVisits}
visitsInfo={orphanVisits}
exportCsv={exportCsv}
isOrphanVisits
>
<VisitsHeader title="Orphan visits" goBack={goBack} visits={orphanVisits.visits} />
</VisitsStats>
);
}, () => [Topics.orphanVisits]);

View File

@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import type { ReportExporter } from '../../src/common/services/ReportExporter';
import { useGoBack } from '../../src/utils/helpers/hooks';
import { parseQuery } from '../../src/utils/helpers/query';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { ShortUrlIdentifier } from '../short-urls/data';
import { urlDecodeShortCode } from '../short-urls/helpers';
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import type { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
import type { NormalizedVisit, VisitsParams } from './types';
import { toApiParams } from './types/helpers';
import { VisitsStats } from './VisitsStats';
export interface ShortUrlVisitsProps {
getShortUrlVisits: (params: LoadShortUrlVisits) => void;
shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
shortUrlDetail: ShortUrlDetail;
cancelGetShortUrlVisits: () => void;
}
export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({
shortUrlVisits,
shortUrlDetail,
getShortUrlVisits,
getShortUrlDetail,
cancelGetShortUrlVisits,
}: ShortUrlVisitsProps) => {
const { shortCode = '' } = useParams<{ shortCode: string }>();
const { search } = useLocation();
const goBack = useGoBack();
const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
shortCode: urlDecodeShortCode(shortCode),
query: { ...toApiParams(params), domain },
doIntervalFallback,
});
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
visits,
);
useEffect(() => {
getShortUrlDetail({ shortCode: urlDecodeShortCode(shortCode), domain });
}, []);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetShortUrlVisits}
visitsInfo={shortUrlVisits}
exportCsv={exportCsv}
>
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
</VisitsStats>
);
}, (_, params) => (params.shortCode ? [Topics.shortUrlVisits(urlDecodeShortCode(params.shortCode))] : []));

View File

@@ -0,0 +1,3 @@
.short-url-visits-header__created-at {
cursor: default;
}

View File

@@ -0,0 +1,45 @@
import { ExternalLink } from 'react-external-link';
import { UncontrolledTooltip } from 'reactstrap';
import { Time } from '../../src/utils/dates/Time';
import type { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import type { ShortUrlVisits } from './reducers/shortUrlVisits';
import { VisitsHeader } from './VisitsHeader';
import './ShortUrlVisitsHeader.scss';
interface ShortUrlVisitsHeaderProps {
shortUrlDetail: ShortUrlDetail;
shortUrlVisits: ShortUrlVisits;
goBack: () => void;
}
export const ShortUrlVisitsHeader = ({ shortUrlDetail, shortUrlVisits, goBack }: ShortUrlVisitsHeaderProps) => {
const { shortUrl, loading } = shortUrlDetail;
const { visits } = shortUrlVisits;
const shortLink = shortUrl?.shortUrl ?? '';
const longLink = shortUrl?.longUrl ?? '';
const title = shortUrl?.title;
const renderDate = () => (!shortUrl ? <small>Loading...</small> : (
<span>
<b id="created" className="short-url-visits-header__created-at">
<Time date={shortUrl.dateCreated} relative />
</b>
<UncontrolledTooltip placement="bottom" target="created">
<Time date={shortUrl.dateCreated} />
</UncontrolledTooltip>
</span>
));
const visitsStatsTitle = <>Visits for <ExternalLink href={shortLink} /></>;
return (
<VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} shortUrl={shortUrl}>
<hr />
<div>Created: {renderDate()}</div>
<div className="long-url-container">
{`${title ? 'Title' : 'Long URL'}: `}
{loading && <small>Loading...</small>}
{!loading && <ExternalLink href={longLink}>{title ?? longLink}</ExternalLink>}
</div>
</VisitsHeader>
);
};

View File

@@ -0,0 +1,41 @@
import { useParams } from 'react-router-dom';
import type { ShlinkVisitsParams } from '../../api/types';
import type { ReportExporter } from '../../src/common/services/ReportExporter';
import { useGoBack } from '../../src/utils/helpers/hooks';
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics';
import type { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits';
import { TagVisitsHeader } from './TagVisitsHeader';
import type { NormalizedVisit } from './types';
import { toApiParams } from './types/helpers';
import { VisitsStats } from './VisitsStats';
export interface TagVisitsProps {
getTagVisits: (params: LoadTagVisits) => void;
tagVisits: TagVisitsState;
cancelGetTagVisits: () => void;
}
export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: ReportExporter) => boundToMercureHub(({
getTagVisits,
tagVisits,
cancelGetTagVisits,
}: TagVisitsProps) => {
const goBack = useGoBack();
const { tag = '' } = useParams();
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
getTagVisits({ tag, query: toApiParams(params), doIntervalFallback });
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
return (
<VisitsStats
getVisits={loadVisits}
cancelGetVisits={cancelGetTagVisits}
visitsInfo={tagVisits}
exportCsv={exportCsv}
>
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
</VisitsStats>
);
}, () => [Topics.visits]);

View File

@@ -0,0 +1,23 @@
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
import { Tag } from '../tags/helpers/Tag';
import type { TagVisits } from './reducers/tagVisits';
import { VisitsHeader } from './VisitsHeader';
import './ShortUrlVisitsHeader.scss';
interface TagVisitsHeaderProps {
tagVisits: TagVisits;
goBack: () => void;
colorGenerator: ColorGenerator;
}
export const TagVisitsHeader = ({ tagVisits, goBack, colorGenerator }: TagVisitsHeaderProps) => {
const { visits, tag } = tagVisits;
const visitsStatsTitle = (
<span className="d-flex align-items-center justify-content-center">
<span className="me-2">Visits for</span>
<Tag text={tag} colorGenerator={colorGenerator} />
</span>
);
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
};

View File

@@ -0,0 +1,38 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Button, Card } from 'reactstrap';
import type { ShortUrl } from '../short-urls/data';
import { ShortUrlVisitsCount } from '../short-urls/helpers/ShortUrlVisitsCount';
import type { Visit } from './types';
type VisitsHeaderProps = PropsWithChildren<{
visits: Visit[];
goBack: () => void;
title: ReactNode;
shortUrl?: ShortUrl;
}>;
export const VisitsHeader: FC<VisitsHeaderProps> = ({ visits, goBack, shortUrl, children, title }) => (
<header>
<Card body>
<h2 className="d-flex justify-content-between align-items-center mb-0">
<Button color="link" size="lg" className="p-0 me-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<span className="text-center d-none d-sm-block">
<small>{title}</small>
</span>
<span className="badge badge-main ms-3">
Visits:{' '}
<ShortUrlVisitsCount visitsCount={visits.length} shortUrl={shortUrl} />
</span>
</h2>
<h3 className="text-center d-block d-sm-none mb-0 mt-3">
<small>{title}</small>
</h3>
{children && <div className="mt-md-2">{children}</div>}
</Card>
</header>
);

View File

@@ -0,0 +1,335 @@
import type { IconDefinition } from '@fortawesome/fontawesome-common-types';
import { faCalendarAlt, faChartPie, faList, faMapMarkedAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { isEmpty, pipe, propEq, values } from 'ramda';
import type { FC, PropsWithChildren } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { Button, Progress, Row } from 'reactstrap';
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
import { ExportBtn } from '../../src/utils/ExportBtn';
import type { DateInterval, DateRange } from '../../src/utils/helpers/dateIntervals';
import { toDateRange } from '../../src/utils/helpers/dateIntervals';
import { prettify } from '../../src/utils/helpers/numbers';
import { Message } from '../../src/utils/Message';
import { NavPillItem, NavPills } from '../../src/utils/NavPills';
import { Result } from '../../src/utils/Result';
import { useSetting } from '../utils/settings';
import { DoughnutChartCard } from './charts/DoughnutChartCard';
import { LineChartCard } from './charts/LineChartCard';
import { SortableBarChartCard } from './charts/SortableBarChartCard';
import { useVisitsQuery } from './helpers/hooks';
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import type { VisitsInfo } from './reducers/types';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import type { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types';
import type { HighlightableProps } from './types/helpers';
import { highlightedVisitsToStats } from './types/helpers';
import { VisitsTable } from './VisitsTable';
export type VisitsStatsProps = PropsWithChildren<{
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
visitsInfo: VisitsInfo;
cancelGetVisits: () => void;
exportCsv: (visits: NormalizedVisit[]) => void;
isOrphanVisits?: boolean;
}>;
interface VisitsNavLinkProps {
title: string;
subPath: string;
icon: IconDefinition;
}
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
const sections: Record<Section, VisitsNavLinkProps> = {
byTime: { title: 'By time', subPath: 'by-time', icon: faCalendarAlt },
byContext: { title: 'By context', subPath: 'by-context', icon: faChartPie },
byLocation: { title: 'By location', subPath: 'by-location', icon: faMapMarkedAlt },
list: { title: 'List', subPath: 'list', icon: faList },
};
let selectedBar: string | undefined;
export const VisitsStats: FC<VisitsStatsProps> = ({
children,
visitsInfo,
getVisits,
cancelGetVisits,
exportCsv,
isOrphanVisits = false,
}) => {
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery();
const visitsSettings = useSetting('visits');
const setDates = pipe(
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
dateRange: {
startDate: theStartDate ?? undefined,
endDate: theEndDate ?? undefined,
},
}),
updateFiltering,
);
const initialInterval = useRef<DateRange | DateInterval>(
dateRange ?? fallbackInterval ?? visitsSettings?.defaultInterval ?? 'last30Days',
);
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
const isFirstLoad = useRef(true);
const { search } = useLocation();
const buildSectionUrl = (subPath?: string) => (!subPath ? search : `${subPath}${search}`);
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits),
[normalizedVisits],
);
const resolvedFilter = useMemo(() => ({
...visitsFilter,
excludeBots: visitsFilter.excludeBots ?? visitsSettings?.excludeBots,
}), [visitsFilter]);
const mapLocations = values(citiesForMap);
const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
};
const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) {
setHighlightedVisits([]);
setHighlightedLabel(undefined);
selectedBar = undefined;
} else {
setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
setHighlightedLabel(value);
selectedBar = newSelectedBar;
}
};
useEffect(() => cancelGetVisits, []);
useEffect(() => {
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
getVisits({ dateRange: resolvedDateRange, filter: resolvedFilter }, isFirstLoad.current);
isFirstLoad.current = false;
}, [dateRange, visitsFilter]);
useEffect(() => {
// As soon as the fallback is loaded, if the initial interval used the settings one, we do fall back
if (fallbackInterval && initialInterval.current === (visitsSettings?.defaultInterval ?? 'last30Days')) {
initialInterval.current = fallbackInterval;
}
}, [fallbackInterval]);
const renderVisitsContent = () => {
if (loadingLarge) {
return (
<Message loading>
This is going to take a while... :S
<Progress value={progress} striped={progress === 100} className="mt-3" />
</Message>
);
}
if (loading) {
return <Message loading />;
}
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading visits :(" />
</Result>
);
}
if (isEmpty(visits)) {
return <Message>There are no visits matching current filter</Message>;
}
return (
<>
<NavPills fill>
{Object.values(sections).map(({ title, icon, subPath }, index) => (
<NavPillItem key={index} to={buildSectionUrl(subPath)} replace>
<FontAwesomeIcon icon={icon} />
<span className="ms-2 d-none d-sm-inline">{title}</span>
</NavPillItem>
))}
</NavPills>
<Row>
<Routes>
<Route
path={sections.byTime.subPath}
element={(
<div className="col-12 mt-3">
<LineChartCard
title="Visits during time"
visits={normalizedVisits}
highlightedVisits={highlightedVisits}
highlightedLabel={highlightedLabel}
setSelectedVisits={setSelectedVisits}
/>
</div>
)}
/>
<Route
path={sections.byContext.subPath}
element={(
<>
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
<DoughnutChartCard title="Operating systems" stats={os} />
</div>
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
<DoughnutChartCard title="Browsers" stats={browsers} />
</div>
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
<SortableBarChartCard
title="Referrers"
stats={referrers}
withPagination={false}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'referer')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Referrer name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('referer')}
/>
</div>
{isOrphanVisits && (
<div className="mt-3 col-lg-6">
<SortableBarChartCard
title="Visited URLs"
stats={visitedUrls}
highlightedLabel={highlightedLabel}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'visitedUrl')}
sortingItems={{
visitedUrl: 'Visited URL',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('visitedUrl')}
/>
</div>
)}
</>
)}
/>
<Route
path={sections.byLocation.subPath}
element={(
<>
<div className="col-lg-6 mt-3">
<SortableBarChartCard
title="Countries"
stats={countries}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
highlightedLabel={highlightedLabel}
sortingItems={{
name: 'Country name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('country')}
/>
</div>
<div className="col-lg-6 mt-3">
<SortableBarChartCard
title="Cities"
stats={cities}
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
highlightedLabel={highlightedLabel}
extraHeaderContent={(activeCities) => mapLocations.length > 0 && (
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
)}
sortingItems={{
name: 'City name',
amount: 'Visits amount',
}}
onClick={highlightVisitsForProp('city')}
/>
</div>
</>
)}
/>
<Route
path={sections.list.subPath}
element={(
<div className="col-12">
<VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setSelectedVisits}
isOrphanVisits={isOrphanVisits}
/>
</div>
)}
/>
<Route path="*" element={<Navigate replace to={buildSectionUrl(sections.byTime.subPath)} />} />
</Routes>
</Row>
</>
);
};
return (
<>
{children}
<section className="mt-3">
<div className="row flex-md-row-reverse">
<div className="col-lg-7 col-xl-6">
<div className="d-md-flex">
<div className="flex-fill">
<DateRangeSelector
updatable
disabled={loading}
initialDateRange={initialInterval.current}
defaultText="All visits"
onDatesChange={setDates}
/>
</div>
<VisitsFilterDropdown
className="ms-0 ms-md-2 mt-3 mt-md-0"
isOrphanVisits={isOrphanVisits}
selected={resolvedFilter}
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
/>
</div>
</div>
{visits.length > 0 && (
<div className="col-lg-5 col-xl-6 mt-3 mt-lg-0">
<div className="d-flex">
<ExportBtn
className="btn-md-block"
amount={normalizedVisits.length}
onClick={() => exportCsv(normalizedVisits)}
/>
<Button
outline
disabled={highlightedVisits.length === 0}
className="btn-md-block ms-2"
onClick={() => setSelectedVisits([])}
>
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
</Button>
</div>
</div>
)}
</div>
</section>
<section className="mt-3">
{renderVisitsContent()}
</section>
</>
);
};

View File

@@ -0,0 +1,39 @@
@import '../../src/utils/base';
@import '../../src/utils/mixins/sticky-cell';
.visits-table {
margin: 1.5rem 0 0;
position: relative;
background-color: var(--primary-color);
overflow-y: hidden;
}
.visits-table__header-cell {
cursor: pointer;
margin-bottom: 55px;
@include sticky-cell();
@media (min-width: $mdMin) {
&.visits-table__sticky {
top: $headerHeight + 40px;
}
}
}
.visits-table__header-icon {
float: right;
margin-top: 3px;
}
.visits-table__footer-cell.visits-table__footer-cell {
bottom: 0;
margin-top: 34px;
padding: .5rem;
@include sticky-cell();
}
.visits-table__sticky.visits-table__sticky {
position: sticky;
}

View File

@@ -0,0 +1,216 @@
import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { min, splitEvery } from 'ramda';
import { useEffect, useMemo, useRef, useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { SimplePaginator } from '../../src/common/SimplePaginator';
import { Time } from '../../src/utils/dates/Time';
import { prettify } from '../../src/utils/helpers/numbers';
import type { Order } from '../../src/utils/helpers/ordering';
import { determineOrderDir, sortList } from '../../src/utils/helpers/ordering';
import { SearchField } from '../../src/utils/SearchField';
import { TableOrderIcon } from '../../src/utils/table/TableOrderIcon';
import type { MediaMatcher } from '../../src/utils/types';
import type { NormalizedOrphanVisit, NormalizedVisit } from './types';
import './VisitsTable.scss';
export interface VisitsTableProps {
visits: NormalizedVisit[];
selectedVisits?: NormalizedVisit[];
setSelectedVisits: (visits: NormalizedVisit[]) => void;
matchMedia?: MediaMatcher;
isOrphanVisits?: boolean;
}
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
type VisitsOrder = Order<OrderableFields>;
const PAGE_SIZE = 20;
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
`${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes(
searchTerm.toLowerCase(),
);
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [...allVisits];
const sortedVisits = sortVisits(order, filteredVisits);
const total = sortedVisits.length;
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
return { visitsGroups, total };
};
export const VisitsTable = ({
visits,
selectedVisits = [],
setSelectedVisits,
matchMedia = window.matchMedia,
isOrphanVisits = false,
}: VisitsTableProps) => {
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
const [isMobileDevice, setIsMobileDevice] = useState(matchMobile());
const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
const [order, setOrder] = useState<VisitsOrder>({});
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [searchTerm, order]);
const isFirstLoad = useRef(true);
const [page, setPage] = useState(1);
const end = page * PAGE_SIZE;
const start = end - PAGE_SIZE;
const fullSizeColSpan = 8 + Number(isOrphanVisits);
const orderByColumn = (field: OrderableFields) =>
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
const renderOrderIcon = (field: OrderableFields) =>
<TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile());
window.addEventListener('resize', listener);
return () => window.removeEventListener('resize', listener);
}, []);
useEffect(() => {
setPage(1);
!isFirstLoad.current && setSelectedVisits([]);
isFirstLoad.current = false;
}, [searchTerm]);
return (
<div className="table-responsive-md">
<table className="table table-bordered table-hover table-sm visits-table">
<thead className="visits-table__header">
<tr>
<th
className={`${headerCellsClass} text-center`}
onClick={() => setSelectedVisits(
selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [],
)}
>
<FontAwesomeIcon icon={checkIcon} className={classNames({ 'text-primary': selectedVisits.length > 0 })} />
</th>
<th className={`${headerCellsClass} text-center`} onClick={orderByColumn('potentialBot')}>
<FontAwesomeIcon icon={botIcon} />
{renderOrderIcon('potentialBot')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('date')}>
Date
{renderOrderIcon('date')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('country')}>
Country
{renderOrderIcon('country')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('city')}>
City
{renderOrderIcon('city')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('browser')}>
Browser
{renderOrderIcon('browser')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('os')}>
OS
{renderOrderIcon('os')}
</th>
<th className={headerCellsClass} onClick={orderByColumn('referer')}>
Referrer
{renderOrderIcon('referer')}
</th>
{isOrphanVisits && (
<th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
Visited URL
{renderOrderIcon('visitedUrl')}
</th>
)}
</tr>
<tr>
<td colSpan={fullSizeColSpan} className="p-0">
<SearchField noBorder large={false} onChange={setSearchTerm} />
</td>
</tr>
</thead>
<tbody>
{!resultSet.visitsGroups[page - 1]?.length && (
<tr>
<td colSpan={fullSizeColSpan} className="text-center">
No visits found with current filtering
</td>
</tr>
)}
{resultSet.visitsGroups[page - 1]?.map((visit, index) => {
const isSelected = selectedVisits.includes(visit);
return (
<tr
key={index}
style={{ cursor: 'pointer' }}
className={classNames({ 'table-active': isSelected })}
onClick={() => setSelectedVisits(
isSelected ? selectedVisits.filter((v) => v !== visit) : [...selectedVisits, visit],
)}
>
<td className="text-center">
{isSelected && <FontAwesomeIcon icon={checkIcon} className="text-primary" />}
</td>
<td className="text-center">
{visit.potentialBot && (
<>
<FontAwesomeIcon icon={botIcon} id={`botIcon${index}`} />
<UncontrolledTooltip placement="right" target={`botIcon${index}`}>
Potentially a visit from a bot or crawler
</UncontrolledTooltip>
</>
)}
</td>
<td><Time date={visit.date} /></td>
<td>{visit.country}</td>
<td>{visit.city}</td>
<td>{visit.browser}</td>
<td>{visit.os}</td>
<td>{visit.referer}</td>
{isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
</tr>
);
})}
</tbody>
{resultSet.total > PAGE_SIZE && (
<tfoot>
<tr>
<td colSpan={fullSizeColSpan} className="visits-table__footer-cell visits-table__sticky">
<div className="row">
<div className="col-md-6">
<SimplePaginator
pagesCount={Math.ceil(resultSet.total / PAGE_SIZE)}
currentPage={page}
setCurrentPage={setPage}
centered={isMobileDevice}
/>
</div>
<div
className={classNames('col-md-6', {
'd-flex align-items-center flex-row-reverse': !isMobileDevice,
'text-center mt-3': isMobileDevice,
})}
>
<div>
Visits <b>{prettify(start + 1)}</b> to{' '}
<b>{prettify(min(end, resultSet.total))}</b> of{' '}
<b>{prettify(resultSet.total)}</b>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
};

View File

@@ -0,0 +1,4 @@
.chart-card__footer--sticky {
position: sticky;
bottom: 0;
}

View File

@@ -0,0 +1,16 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Card, CardBody, CardFooter, CardHeader } from 'reactstrap';
import './ChartCard.scss';
type ChartCardProps = PropsWithChildren<{
title: Function | string;
footer?: ReactNode;
}>;
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
<Card role="document">
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{children}</CardBody>
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
</Card>
);

View File

@@ -0,0 +1,73 @@
import type { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
import { keys, values } from 'ramda';
import type { FC } from 'react';
import { memo, useState } from 'react';
import { Doughnut } from 'react-chartjs-2';
import { renderPieChartLabel } from '../../../src/utils/helpers/charts';
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../../src/utils/theme';
import type { Stats } from '../types';
import { DoughnutChartLegend } from './DoughnutChartLegend';
interface DoughnutChartProps {
stats: Stats;
}
const generateChartDatasets = (data: number[]): ChartDataset[] => [
{
data,
backgroundColor: [
'#97BBCD',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#57A773',
'#414066',
'#08B2E3',
'#B6C454',
'#DCDCDC',
'#463730',
],
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
borderWidth: 2,
},
];
const generateChartData = (labels: string[], data: number[]): ChartData => ({
labels,
datasets: generateChartDatasets(data),
});
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
const [chartRef, setChartRef] = useState<Chart | undefined>(); // Cannot use useRef here
const labels = keys(stats);
const data = values(stats);
const options: ChartOptions = {
plugins: {
legend: { display: false },
tooltip: {
intersect: true,
callbacks: { label: renderPieChartLabel },
},
},
};
const chartData = generateChartData(labels, data);
return (
<div className="row">
<div className="col-sm-12 col-md-7">
<Doughnut
height={300}
data={chartData as any}
options={options as any}
ref={(element) => {
setChartRef(element ?? undefined);
}}
/>
</div>
<div className="col-sm-12 col-md-5">
{chartRef && <DoughnutChartLegend chart={chartRef} />}
</div>
</div>
);
});

View File

@@ -0,0 +1,15 @@
import type { FC } from 'react';
import type { Stats } from '../types';
import { ChartCard } from './ChartCard';
import { DoughnutChart } from './DoughnutChart';
interface DoughnutChartCardProps {
title: string;
stats: Stats;
}
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
<ChartCard title={title}>
<DoughnutChart stats={stats} />
</ChartCard>
);

View File

@@ -0,0 +1,29 @@
@import '../../../src/utils/base';
.doughnut-chart-legend {
list-style-type: none;
padding: 0;
margin: 0;
@media (max-width: $smMax) {
margin-top: 1rem;
}
}
.doughnut-chart-legend__item:not(:first-child) {
margin-top: .3rem;
}
.doughnut-chart-legend__item-color {
width: 20px;
min-width: 20px;
height: 20px;
margin-right: 5px;
border-radius: 10px;
}
.doughnut-chart-legend__item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,28 @@
import type { Chart } from 'chart.js';
import type { FC } from 'react';
import './DoughnutChartLegend.scss';
interface DoughnutChartLegendProps {
chart: Chart;
}
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
const { config } = chart;
const { labels = [], datasets = [] } = config.data ?? {};
const [{ backgroundColor: colors }] = datasets;
const { defaultColor } = config.options ?? {} as any;
return (
<ul className="doughnut-chart-legend">
{(labels as string[]).map((label, index) => (
<li key={label} className="doughnut-chart-legend__item d-flex">
<div
className="doughnut-chart-legend__item-color"
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
/>
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
</li>
))}
</ul>
);
};

View File

@@ -0,0 +1,134 @@
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
import { keys, values } from 'ramda';
import type { FC, MutableRefObject } from 'react';
import { useRef } from 'react';
import { Bar, getElementAtEvent } from 'react-chartjs-2';
import { pointerOnHover, renderChartLabel } from '../../../src/utils/helpers/charts';
import { prettify } from '../../../src/utils/helpers/numbers';
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../../src/utils/theme';
import type { Stats } from '../types';
import { fillTheGaps } from '../utils';
export interface HorizontalBarChartProps {
stats: Stats;
max?: number;
highlightedStats?: Stats;
highlightedLabel?: string;
onClick?: (label: string) => void;
}
const dropLabelIfHidden = (label: string) => (label.startsWith('hidden') ? '' : label);
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
const determineHeight = (labels: string[]): number | undefined => (labels.length > 20 ? labels.length * 10 : undefined);
const generateChartDatasets = (
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartDataset[] => {
const mainDataset: ChartDataset = {
data,
label: highlightedLabel ? 'Non-selected' : 'Visits',
backgroundColor: MAIN_COLOR_ALPHA,
borderColor: MAIN_COLOR,
borderWidth: 2,
};
if (highlightedData.every((value) => value === 0)) {
return [mainDataset];
}
const highlightedDataset: ChartDataset = {
label: highlightedLabel ?? 'Selected',
data: highlightedData,
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
borderColor: HIGHLIGHTED_COLOR,
borderWidth: 2,
};
return [mainDataset, highlightedDataset];
};
const generateChartData = (
labels: string[],
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartData => ({
labels,
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
});
const chartElementAtEvent = (labels: string[], [chart]: InteractionItem[], onClick?: (label: string) => void) => {
if (!onClick || !chart) {
return;
}
onClick(labels[chart.index]);
};
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
{ stats, highlightedStats, highlightedLabel, onClick, max },
) => {
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
if (acc[highlightedKey]) {
acc[highlightedKey] -= highlightedStats[highlightedKey];
}
return acc;
}, { ...stats }),
);
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
const refWithStats = useRef(null);
const refWithoutStats = useRef(null);
const options: ChartOptions = {
plugins: {
legend: { display: false },
tooltip: {
mode: 'y',
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ label }) => label !== '',
callbacks: { label: renderChartLabel },
},
},
scales: {
x: {
beginAtZero: true,
stacked: true,
max,
ticks: {
precision: 0,
callback: prettify,
},
},
y: { stacked: true },
},
onHover: pointerOnHover,
indexAxis: 'y',
};
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
const height = determineHeight(labels);
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
const renderChartComponent = (customKey: string, theRef: MutableRefObject<any>) => (
<Bar
ref={theRef}
key={`${height}_${customKey}`}
data={chartData as any}
options={options as any}
height={height}
onClick={(e) => chartElementAtEvent(labels, getElementAtEvent(theRef.current, e), onClick)}
/>
);
return (
<>
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
{highlightedStats !== undefined && renderChartComponent('with_stats', refWithStats)}
{highlightedStats === undefined && renderChartComponent('without_stats', refWithoutStats)}
</>
);
};

View File

@@ -0,0 +1,9 @@
@import '../../../src/utils/base';
.line-chart-card__body canvas {
height: 300px !important;
@media (min-width: $mdMin) {
height: 400px !important;
}
}

View File

@@ -0,0 +1,270 @@
import type { ChartData, ChartDataset, ChartOptions, InteractionItem } from 'chart.js';
import {
add,
differenceInDays,
differenceInHours,
differenceInMonths,
differenceInWeeks,
endOfISOWeek,
format,
parseISO,
startOfISOWeek,
} from 'date-fns';
import { always, cond, countBy, reverse } from 'ramda';
import type { MutableRefObject } from 'react';
import { useMemo, useRef, useState } from 'react';
import { getElementAtEvent, Line } from 'react-chartjs-2';
import {
Card,
CardBody,
CardHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
UncontrolledDropdown,
} from 'reactstrap';
import { pointerOnHover, renderChartLabel } from '../../../src/utils/helpers/charts';
import { STANDARD_DATE_FORMAT } from '../../../src/utils/helpers/date';
import { useToggle } from '../../../src/utils/helpers/hooks';
import { prettify } from '../../../src/utils/helpers/numbers';
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../../src/utils/theme';
import { ToggleSwitch } from '../../../src/utils/ToggleSwitch';
import { rangeOf } from '../../../src/utils/utils';
import type { NormalizedVisit, Stats } from '../types';
import { fillTheGaps } from '../utils';
import './LineChartCard.scss';
interface LineChartCardProps {
title: string;
highlightedLabel?: string;
visits: NormalizedVisit[];
highlightedVisits: NormalizedVisit[];
setSelectedVisits?: (visits: NormalizedVisit[]) => void;
}
type Step = 'monthly' | 'weekly' | 'daily' | 'hourly';
const STEPS_MAP: Record<Step, string> = {
monthly: 'Month',
weekly: 'Week',
daily: 'Day',
hourly: 'Hour',
};
const STEP_TO_DURATION_MAP: Record<Step, (amount: number) => Duration> = {
hourly: (hours: number) => ({ hours }),
daily: (days: number) => ({ days }),
weekly: (weeks: number) => ({ weeks }),
monthly: (months: number) => ({ months }),
};
const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => number> = {
hourly: differenceInHours,
daily: differenceInDays,
weekly: differenceInWeeks,
monthly: differenceInMonths,
};
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
daily: (date) => format(date, STANDARD_DATE_FORMAT),
weekly(date) {
const firstWeekDay = format(startOfISOWeek(date), STANDARD_DATE_FORMAT);
const lastWeekDay = format(endOfISOWeek(date), STANDARD_DATE_FORMAT);
return `${firstWeekDay} - ${lastWeekDay}`;
},
monthly: (date) => format(date, 'yyyy-MM'),
};
const determineInitialStep = (oldestVisitDate: string): Step => {
const now = new Date();
const oldestDate = parseISO(oldestVisitDate);
const matcher = cond<never, Step | undefined>([
[() => differenceInDays(now, oldestDate) <= 2, always<Step>('hourly')], // Less than 2 days
[() => differenceInMonths(now, oldestDate) <= 1, always<Step>('daily')], // Between 2 days and 1 month
[() => differenceInMonths(now, oldestDate) <= 6, always<Step>('weekly')], // Between 1 and 6 months
]);
return matcher() ?? 'monthly';
};
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
(visit) => STEP_TO_DATE_FORMAT[step](parseISO(visit.date)),
visits,
);
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
visits.reduce<Record<string, NormalizedVisit[]>>(
(acc, visit) => {
const key = STEP_TO_DATE_FORMAT[step](parseISO(visit.date));
acc[key] = acc[key] ?? [];
acc[key].push(visit);
return acc;
},
{},
);
const generateLabels = (step: Step, visits: NormalizedVisit[]): string[] => {
const diffFunc = STEP_TO_DIFF_FUNC_MAP[step];
const formatter = STEP_TO_DATE_FORMAT[step];
const newerDate = parseISO(visits[0].date);
const oldestDate = parseISO(visits[visits.length - 1].date);
const size = diffFunc(newerDate, oldestDate);
const duration = STEP_TO_DURATION_MAP[step];
return [
formatter(oldestDate),
...rangeOf(size, (num) => formatter(add(oldestDate, duration(num)))),
];
};
const generateLabelsAndGroupedVisits = (
visits: NormalizedVisit[],
groupedVisitsWithGaps: Stats,
step: Step,
skipNoElements: boolean,
): [string[], number[]] => {
if (skipNoElements) {
return [Object.keys(groupedVisitsWithGaps), Object.values(groupedVisitsWithGaps)];
}
const labels = generateLabels(step, visits);
return [labels, fillTheGaps(groupedVisitsWithGaps, labels)];
};
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
label,
data,
fill: false,
tension: 0.2,
borderColor: color,
backgroundColor: color,
});
let selectedLabel: string | null = null;
const chartElementAtEvent = (
labels: string[],
datasetsByPoint: Record<string, NormalizedVisit[]>,
[chart]: InteractionItem[],
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
) => {
if (!setSelectedVisits || !chart) {
return;
}
const { index } = chart;
if (selectedLabel === labels[index]) {
setSelectedVisits([]);
selectedLabel = null;
} else {
setSelectedVisits(labels[index] && datasetsByPoint[labels[index]] ? datasetsByPoint[labels[index]] : []);
selectedLabel = labels[index] ?? null;
}
};
export const LineChartCard = (
{ title, visits, highlightedVisits, highlightedLabel = 'Selected', setSelectedVisits }: LineChartCardProps,
) => {
const [step, setStep] = useState<Step>(
visits.length > 0 ? determineInitialStep(visits[visits.length - 1].date) : 'monthly',
);
const [skipNoVisits, toggleSkipNoVisits] = useToggle(true);
const refWithHighlightedVisits = useRef(null);
const refWithoutHighlightedVisits = useRef(null);
const datasetsByPoint = useMemo(() => visitsToDatasetGroups(step, visits), [step, visits]);
const groupedVisitsWithGaps = useMemo(() => groupVisitsByStep(step, reverse(visits)), [step, visits]);
const [labels, groupedVisits] = useMemo(
() => generateLabelsAndGroupedVisits(visits, groupedVisitsWithGaps, step, skipNoVisits),
[visits, step, skipNoVisits],
);
const groupedHighlighted = useMemo(
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
[highlightedVisits, step, labels],
);
const generateChartDatasets = (): ChartDataset[] => {
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
if (highlightedVisits.length === 0) {
return [mainDataset];
}
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
return [mainDataset, highlightedDataset];
};
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
const options: ChartOptions = {
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
intersect: false,
axis: 'x',
callbacks: { label: renderChartLabel },
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
callback: prettify,
},
},
x: {
title: { display: true, text: STEPS_MAP[step] },
},
},
onHover: pointerOnHover,
};
const renderLineChart = (theRef: MutableRefObject<any>) => (
<Line
ref={theRef}
data={generateChartData() as any}
options={options as any}
onClick={(e) =>
chartElementAtEvent(labels, datasetsByPoint, getElementAtEvent(theRef.current, e), setSelectedVisits)}
/>
);
return (
<Card>
<CardHeader role="heading">
{title}
<div className="float-end">
<UncontrolledDropdown>
<DropdownToggle caret color="link" className="btn-sm p-0">
Group by
</DropdownToggle>
<DropdownMenu end>
{Object.entries(STEPS_MAP).map(([value, menuText]) => (
<DropdownItem key={value} active={step === value} onClick={() => setStep(value as Step)}>
{menuText}
</DropdownItem>
))}
</DropdownMenu>
</UncontrolledDropdown>
</div>
<div className="float-end me-2">
<ToggleSwitch checked={skipNoVisits} onChange={toggleSkipNoVisits}>
<small>Skip dates with no visits</small>
</ToggleSwitch>
</div>
</CardHeader>
<CardBody className="line-chart-card__body">
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
{highlightedVisits.length > 0 && renderLineChart(refWithHighlightedVisits)}
{highlightedVisits.length === 0 && renderLineChart(refWithoutHighlightedVisits)}
</CardBody>
</Card>
);
};

View File

@@ -0,0 +1,141 @@
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import type { FC, ReactNode } from 'react';
import { useState } from 'react';
import { SimplePaginator } from '../../../src/common/SimplePaginator';
import { roundTen } from '../../../src/utils/helpers/numbers';
import type { Order } from '../../../src/utils/helpers/ordering';
import { OrderingDropdown } from '../../../src/utils/OrderingDropdown';
import { PaginationDropdown } from '../../../src/utils/PaginationDropdown';
import { rangeOf } from '../../../src/utils/utils';
import type { Stats, StatsRow } from '../types';
import { ChartCard } from './ChartCard';
import type { HorizontalBarChartProps } from './HorizontalBarChart';
import { HorizontalBarChart } from './HorizontalBarChart';
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
title: Function | string;
sortingItems: Record<string, string>;
withPagination?: boolean;
extraHeaderContent?: (activeCities?: string[]) => ReactNode;
}
const toLowerIfString = (value: any) => (type(value) === 'String' ? toLower(value) : value);
const pickKeyFromPair = ([key]: StatsRow) => key;
const pickValueFromPair = ([, value]: StatsRow) => value;
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
stats,
highlightedStats,
title,
sortingItems,
extraHeaderContent,
withPagination = true,
...rest
}) => {
const [order, setOrder] = useState<Order<string>>({});
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(50);
const getSortedPairsForStats = (statsToSort: Stats, sorting: Record<string, string>) => {
const pairs = toPairs(statsToSort);
const sortedPairs = !order.field ? pairs : sortBy(
pipe<StatsRow[], string | number, string | number>(
order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair,
toLowerIfString,
),
pairs,
);
return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
};
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
const page = pages[currentPage - 1];
if (currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [...page, ...rangeOf(firstPageLength - page.length, (i): StatsRow => [`hidden_${i}`, 0])];
};
const renderPagination = (pagesCount: number) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
const determineStats = (statsToSort: Stats, sorting: Record<string, string>, theHighlightedStats?: Stats) => {
const sortedPairs = getSortedPairsForStats(statsToSort, sorting);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = theHighlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...theHighlightedStats },
);
if (sortedPairs.length <= itemsPerPage) {
return {
currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
};
}
const pages = splitEvery(itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
return {
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
pagination: renderPagination(pages.length),
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
};
};
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
stats,
sortingItems,
highlightedStats && Object.keys(highlightedStats).length > 0 ? highlightedStats : undefined,
);
const activeCities = Object.keys(currentPageStats);
const computeTitle = () => (
<>
{title}
<div className="float-end">
<OrderingDropdown
isButton={false}
right
items={sortingItems}
order={order}
onChange={(field, dir) => {
setOrder({ field, dir });
setCurrentPage(1);
}}
/>
</div>
{withPagination && Object.keys(stats).length > 50 && (
<div className="float-end">
<PaginationDropdown
toggleClassName="btn-sm p-0 me-3"
ranges={[50, 100, 200, 500]}
value={itemsPerPage}
setValue={(value) => {
setItemsPerPage(value);
setCurrentPage(1);
}}
/>
</div>
)}
{extraHeaderContent && (
<div className="float-end">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</>
);
return (
<ChartCard
title={computeTitle}
footer={pagination}
>
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
</ChartCard>
);
};

View File

@@ -0,0 +1,45 @@
@import '../../../src/utils/base';
@import '../../../src/utils/mixins/fit-with-margin';
.map-modal__modal.map-modal__modal {
@media (min-width: $mdMin) {
$margin: 20px;
@include fit-with-margin($margin);
}
@media (max-width: $smMax) {
$margin: 10px;
@include fit-with-margin($margin);
}
}
.map-modal__modal-content.map-modal__modal-content {
height: 100%;
}
.map-modal__modal-title.map-modal__modal-title {
position: absolute;
width: 100%;
z-index: 1001;
padding: .5rem 1rem 1rem;
margin: 0;
color: #fff;
background: linear-gradient(rgba(0, 0, 0, .5), rgba(0, 0, 0, 0));
}
.map-modal__modal-body.map-modal__modal-body {
padding: 0;
display: flex;
overflow: hidden;
}
.map-modal__modal.map-modal__modal .leaflet-container.leaflet-container {
flex: 1 1 auto;
border-radius: .3rem;
}
.map-modal__modal.map-modal__modal .leaflet-top.leaflet-top .leaflet-control.leaflet-control {
margin-top: 60px;
}

View File

@@ -0,0 +1,56 @@
import { prop } from 'ramda';
import type { FC } from 'react';
import type { MapContainerProps } from 'react-leaflet';
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import { Modal, ModalBody } from 'reactstrap';
import type { CityStats } from '../types';
import './MapModal.scss';
interface MapModalProps {
toggle: () => void;
isOpen: boolean;
title: string;
locations?: CityStats[];
}
const OpenStreetMapTile: FC = () => (
<TileLayer
attribution='&amp;copy <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
);
const calculateMapProps = (locations: CityStats[]): MapContainerProps => {
if (locations.length === 0) {
return {};
}
if (locations.length > 1) {
return { bounds: locations.map(prop('latLong')) };
}
// When there's only one location, an error is thrown if trying to calculate the bounds.
// When that happens, we use "zoom" and "center" as a workaround
const [{ latLong: center }] = locations;
return { zoom: 10, center };
};
export const MapModal = ({ toggle, isOpen, title, locations = [] }: MapModalProps) => (
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
<ModalBody className="map-modal__modal-body">
<h3 className="map-modal__modal-title">
{title}
<button type="button" className="btn-close float-end" aria-label="Close" onClick={toggle} />
</h3>
<MapContainer {...calculateMapProps(locations)}>
<OpenStreetMapTile />
{locations.map(({ cityName, latLong, count }, index) => (
<Marker key={index} position={latLong}>
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{cityName}</b></Popup>
</Marker>
))}
</MapContainer>
</ModalBody>
</Modal>
);

View File

@@ -0,0 +1,4 @@
.open-map-modal-btn__btn.open-map-modal-btn__btn {
padding: 0;
margin-right: 1rem;
}

View File

@@ -0,0 +1,55 @@
import { faMapMarkedAlt as mapIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useState } from 'react';
import { Button, Dropdown, DropdownItem, DropdownMenu, UncontrolledTooltip } from 'reactstrap';
import { useDomId, useToggle } from '../../../src/utils/helpers/hooks';
import type { CityStats } from '../types';
import { MapModal } from './MapModal';
import './OpenMapModalBtn.scss';
interface OpenMapModalBtnProps {
modalTitle: string;
activeCities?: string[];
locations?: CityStats[];
}
export const OpenMapModalBtn = ({ modalTitle, activeCities, locations = [] }: OpenMapModalBtnProps) => {
const [mapIsOpened, , openMap, closeMap] = useToggle();
const [dropdownIsOpened, toggleDropdown, openDropdown] = useToggle();
const [locationsToShow, setLocationsToShow] = useState<CityStats[]>([]);
const id = useDomId();
const filterLocations = (cities: CityStats[]) => (
!activeCities ? cities : cities.filter(({ cityName }) => activeCities?.includes(cityName))
);
const onClick = () => {
if (!activeCities) {
setLocationsToShow(locations);
openMap();
return;
}
openDropdown();
};
const openMapWithLocations = (filtered: boolean) => () => {
setLocationsToShow(filtered ? filterLocations(locations) : locations);
openMap();
};
return (
<>
<Button color="link" className="open-map-modal-btn__btn" id={id} onClick={onClick}>
<FontAwesomeIcon icon={mapIcon} />
</Button>
<UncontrolledTooltip placement="left" target={id}>Show in map</UncontrolledTooltip>
<Dropdown isOpen={dropdownIsOpened} toggle={toggleDropdown} inNavbar>
<DropdownMenu end>
<DropdownItem onClick={openMapWithLocations(false)}>Show all locations</DropdownItem>
<DropdownItem onClick={openMapWithLocations(true)}>Show locations in current page</DropdownItem>
</DropdownMenu>
</Dropdown>
<MapModal toggle={closeMap} isOpen={mapIsOpened} title={modalTitle} locations={locationsToShow} />
</>
);
};

View File

@@ -0,0 +1,48 @@
import type { DropdownItemProps } from 'reactstrap';
import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../../../src/utils/DropdownBtn';
import { hasValue } from '../../../src/utils/utils';
import type { OrphanVisitType, VisitsFilter } from '../types';
interface VisitsFilterDropdownProps {
onChange: (filters: VisitsFilter) => void;
selected?: VisitsFilter;
className?: string;
isOrphanVisits: boolean;
}
export const VisitsFilterDropdown = (
{ onChange, selected = {}, className, isOrphanVisits }: VisitsFilterDropdownProps,
) => {
const { orphanVisitsType, excludeBots = false } = selected;
const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({
active: orphanVisitsType === type,
onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }),
});
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return (
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>
{isOrphanVisits && (
<>
<DropdownItem divider />
<DropdownItem header>Orphan visits type:</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('base_url')}>Base URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('invalid_short_url')}>Invalid short URL</DropdownItem>
<DropdownItem {...propsForOrphanVisitsTypeItem('regular_404')}>Regular 404</DropdownItem>
</>
)}
<DropdownItem divider />
<DropdownItem
disabled={!hasValue(selected)}
onClick={() => onChange({ excludeBots: false, orphanVisitsType: undefined })}
>
<i>Clear filters</i>
</DropdownItem>
</DropdownBtn>
);
};

View File

@@ -0,0 +1,67 @@
import type { DeepPartial } from '@reduxjs/toolkit';
import { isEmpty, isNil, mergeDeepRight, pipe } from 'ramda';
import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { formatIsoDate } from '../../../src/utils/helpers/date';
import type { DateRange } from '../../../src/utils/helpers/dateIntervals';
import { datesToDateRange } from '../../../src/utils/helpers/dateIntervals';
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
import type { BooleanString } from '../../../src/utils/utils';
import { parseBooleanToString } from '../../../src/utils/utils';
import type { OrphanVisitType, VisitsFilter } from '../types';
interface VisitsQuery {
startDate?: string;
endDate?: string;
orphanVisitsType?: OrphanVisitType;
excludeBots?: BooleanString;
domain?: string;
}
interface VisitsFiltering {
dateRange?: DateRange;
visitsFilter: VisitsFilter;
}
interface VisitsFilteringAndDomain {
filtering: VisitsFiltering;
domain?: string;
}
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
const navigate = useNavigate();
const { search } = useLocation();
const { filtering, domain: theDomain } = useMemo(
pipe(
() => parseQuery<VisitsQuery>(search),
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
domain,
filtering: {
dateRange: startDate != null || endDate != null ? datesToDateRange(startDate, endDate) : undefined,
visitsFilter: { orphanVisitsType, excludeBots: !isNil(excludeBots) ? excludeBots === 'true' : undefined },
},
}),
),
[search],
);
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
const { excludeBots, orphanVisitsType } = visitsFilter;
const query: VisitsQuery = {
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || '',
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || '',
excludeBots: excludeBots === undefined ? undefined : parseBooleanToString(excludeBots),
orphanVisitsType,
domain: theDomain,
};
const stringifiedQuery = stringifyQuery(query);
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
navigate(queryString, { replace: true, relative: 'route' });
};
return [filtering, updateFiltering];
};

View File

@@ -0,0 +1,144 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
import { flatten, prop, range, splitEvery } from 'ramda';
import type { ShlinkState } from '../../../src/container/types';
import type { DateInterval } from '../../../src/utils/helpers/dateIntervals';
import { dateToMatchingInterval } from '../../../src/utils/helpers/dateIntervals';
import { createAsyncThunk } from '../../../src/utils/helpers/redux';
import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api-contract';
import { parseApiError } from '../../api-contract/utils';
import type { CreateVisit, Visit } from '../types';
import type { LoadVisits, VisitsInfo, VisitsLoaded } from './types';
import { createNewVisits } from './visitCreation';
const ITEMS_PER_PAGE = 5000;
const PARALLEL_REQUESTS_COUNT = 4;
const PARALLEL_STARTING_PAGE = 2;
const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => currentPage >= pagesCount;
const calcProgress = (total: number, current: number): number => (current * 100) / total;
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
type LastVisitLoader = (excludeBots?: boolean) => Promise<Visit | undefined>;
interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded> {
typePrefix: string;
createLoaders: (params: T) => [VisitsLoader, LastVisitLoader];
getExtraFulfilledPayload: (params: T) => Partial<R>;
shouldCancel: (getState: () => ShlinkState) => boolean;
}
export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
{ typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
) => {
const progressChanged = createAction<number>(`${typePrefix}/progressChanged`);
const large = createAction<void>(`${typePrefix}/large`);
const fallbackToInterval = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`);
const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise<Partial<R>> => {
const [visitsLoader, lastVisitLoader] = createLoaders(params);
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => {
if (shouldCancel(getState)) {
return [];
}
const data = await loadVisitsInParallel(pagesBlocks[index]);
dispatch(progressChanged(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
if (index < pagesBlocks.length - 1) {
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
}
return data;
};
const loadVisits = async (page = 1) => {
const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
// If pagination was not returned, then this is an old shlink version. Just return data
if (!pagination || isLastPage(pagination)) {
return data;
}
// If there are more pages, make requests in blocks of 4
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1);
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
dispatch(large());
}
return data.concat(await loadPagesBlocks(pagesBlocks));
};
const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader(params.query?.excludeBots)]);
if (!visits.length && lastVisit) {
dispatch(fallbackToInterval(dateToMatchingInterval(lastVisit.date)));
}
return { ...getExtraFulfilledPayload(params), visits };
});
// Enhance the async thunk with extra actions
return Object.assign(asyncThunk, { progressChanged, large, fallbackToInterval });
};
export const lastVisitLoaderForLoader = (
doIntervalFallback: boolean,
loader: (params: ShlinkVisitsParams) => Promise<ShlinkVisits>,
): LastVisitLoader => async (excludeBots?: boolean) => (
!doIntervalFallback
? Promise.resolve(undefined)
: loader({ page: 1, itemsPerPage: 1, excludeBots }).then(({ data }) => data[0])
);
interface VisitsReducerOptions<State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>> {
name: string;
asyncThunkCreator: AT;
initialState: State;
filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[];
}
export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
{ name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions<State, AT>,
) => {
const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator;
const { reducer, actions } = createSlice({
name,
initialState,
reducers: {
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
},
extraReducers: (builder) => {
builder.addCase(pending, () => ({ ...initialState, loading: true }));
builder.addCase(rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(error) }
));
builder.addCase(fulfilled, (state, { payload }) => (
{ ...state, ...payload, loading: false, loadingLarge: false, error: false }
));
builder.addCase(large, (state) => ({ ...state, loadingLarge: true }));
builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress }));
builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => (
{ ...state, fallbackInterval }
));
builder.addCase(createNewVisits, (state, { payload }) => {
const { visits } = state;
// @ts-expect-error TODO Fix type inference
const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit);
return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] };
});
},
});
const { cancelGetVisits } = actions;
return { reducer, cancelGetVisits };
};

View File

@@ -0,0 +1,59 @@
import { isBetween } from '../../../src/utils/helpers/date';
import type { ShlinkApiClient } from '../../api-contract';
import { domainMatches } from '../../short-urls/helpers';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { LoadVisits, VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/domainVisits';
export const DEFAULT_DOMAIN = 'DEFAULT';
interface WithDomain {
domain: string;
}
export interface DomainVisits extends VisitsInfo, WithDomain {}
export interface LoadDomainVisits extends LoadVisits, WithDomain {}
const initialState: DomainVisits = {
visits: [],
domain: '',
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export const getDomainVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getDomainVisits`,
createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits) => {
const { getDomainVisits: getVisits } = apiClient;
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
domain,
{ ...query, page, itemsPerPage },
);
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params));
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }),
shouldCancel: (getState) => getState().domainVisits.cancelLoad,
});
export const domainVisitsReducerCreator = (
asyncThunkCreator: ReturnType<typeof getDomainVisits>,
) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
// @ts-expect-error TODO Fix type inference
asyncThunkCreator,
filterCreatedVisits: ({ domain, query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(
({ shortUrl, visit }) =>
shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate),
);
},
});

View File

@@ -0,0 +1,41 @@
import { isBetween } from '../../../src/utils/helpers/date';
import type { ShlinkApiClient } from '../../api-contract';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/orphanVisits';
const initialState: VisitsInfo = {
visits: [],
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export const getNonOrphanVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`,
createLoaders: ({ query = {}, doIntervalFallback = false }) => {
const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = apiClient;
const visitsLoader = async (page: number, itemsPerPage: number) =>
shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage });
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits);
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ query = {} }) => ({ query }),
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
});
export const nonOrphanVisitsReducerCreator = (
asyncThunkCreator: ReturnType<typeof getNonOrphanVisits>,
) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
asyncThunkCreator,
filterCreatedVisits: ({ query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate));
},
});

View File

@@ -0,0 +1,53 @@
import { isBetween } from '../../../src/utils/helpers/date';
import type { ShlinkApiClient } from '../../api-contract';
import type { OrphanVisit, OrphanVisitType } from '../types';
import { isOrphanVisit } from '../types/helpers';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { LoadVisits, VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/orphanVisits';
export interface LoadOrphanVisits extends LoadVisits {
orphanVisitsType?: OrphanVisitType;
}
const initialState: VisitsInfo = {
visits: [],
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
!orphanVisitsType || orphanVisitsType === visit.type;
export const getOrphanVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getOrphanVisits`,
createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits) => {
const { getOrphanVisits: getVisits } = apiClient;
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage })
.then((result) => {
const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
return { ...result, data: visits };
});
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits);
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }),
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
});
export const orphanVisitsReducerCreator = (
asyncThunkCreator: ReturnType<typeof getOrphanVisits>,
) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
asyncThunkCreator,
filterCreatedVisits: ({ query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate));
},
});

View File

@@ -0,0 +1,62 @@
import { isBetween } from '../../../src/utils/helpers/date';
import type { ShlinkApiClient } from '../../api-contract';
import type { ShortUrlIdentifier } from '../../short-urls/data';
import { shortUrlMatches } from '../../short-urls/helpers';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { LoadVisits, VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/shortUrlVisits';
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
export interface LoadShortUrlVisits extends LoadVisits {
shortCode: string;
}
const initialState: ShortUrlVisits = {
visits: [],
shortCode: '',
domain: undefined, // Deprecated. Value from query params can be used instead
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export const getShortUrlVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getShortUrlVisits`,
createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits) => {
const { getShortUrlVisits: shlinkGetShortUrlVisits } = apiClient;
const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits(
shortCode,
{ ...query, page, itemsPerPage },
);
const lastVisitLoader = lastVisitLoaderForLoader(
doIntervalFallback,
async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }),
);
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ shortCode, query = {} }: LoadShortUrlVisits) => (
{ shortCode, query, domain: query.domain }
),
shouldCancel: (getState) => getState().shortUrlVisits.cancelLoad,
});
export const shortUrlVisitsReducerCreator = (
asyncThunkCreator: ReturnType<typeof getShortUrlVisits>,
) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
// @ts-expect-error TODO Fix type inference
asyncThunkCreator,
filterCreatedVisits: ({ shortCode, domain, query = {} }: ShortUrlVisits, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(
({ shortUrl, visit }) =>
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
);
},
});

View File

@@ -0,0 +1,53 @@
import { isBetween } from '../../../src/utils/helpers/date';
import type { ShlinkApiClient } from '../../api-contract';
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import type { LoadVisits, VisitsInfo } from './types';
const REDUCER_PREFIX = 'shlink/tagVisits';
interface WithTag {
tag: string;
}
export interface TagVisits extends VisitsInfo, WithTag {}
export interface LoadTagVisits extends LoadVisits, WithTag {}
const initialState: TagVisits = {
visits: [],
tag: '',
loading: false,
loadingLarge: false,
error: false,
cancelLoad: false,
progress: 0,
};
export const getTagVisits = (apiClient: ShlinkApiClient) => createVisitsAsyncThunk({
typePrefix: `${REDUCER_PREFIX}/getTagVisits`,
createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits) => {
const { getTagVisits: getVisits } = apiClient;
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
tag,
{ ...query, page, itemsPerPage },
);
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params));
return [visitsLoader, lastVisitLoader];
},
getExtraFulfilledPayload: ({ tag, query = {} }: LoadTagVisits) => ({ tag, query }),
shouldCancel: (getState) => getState().tagVisits.cancelLoad,
});
export const tagVisitsReducerCreator = (asyncThunkCreator: ReturnType<typeof getTagVisits>) => createVisitsReducer({
name: REDUCER_PREFIX,
initialState,
// @ts-expect-error TODO Fix type inference
asyncThunkCreator,
filterCreatedVisits: ({ tag, query = {} }: TagVisits, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(
({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate),
);
},
});

View File

@@ -0,0 +1,25 @@
import type { DateInterval } from '../../../../src/utils/helpers/dateIntervals';
import type { ProblemDetailsError, ShlinkVisitsParams } from '../../../api-contract';
import type { Visit } from '../../types';
export interface VisitsInfo {
visits: Visit[];
loading: boolean;
loadingLarge: boolean;
error: boolean;
errorData?: ProblemDetailsError;
progress: number;
cancelLoad: boolean;
query?: ShlinkVisitsParams;
fallbackInterval?: DateInterval;
}
export interface LoadVisits {
query?: ShlinkVisitsParams;
doIntervalFallback?: boolean;
}
export type VisitsLoaded<T = {}> = T & {
visits: Visit[];
query?: ShlinkVisitsParams;
};

View File

@@ -0,0 +1,12 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createAction } from '@reduxjs/toolkit';
import type { CreateVisit } from '../types';
export type CreateVisitsAction = PayloadAction<{
createdVisits: CreateVisit[];
}>;
export const createNewVisits = createAction(
'shlink/visitCreation/createNewVisits',
(createdVisits: CreateVisit[]) => ({ payload: { createdVisits } }),
);

View File

@@ -0,0 +1,99 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { createAsyncThunk } from '../../../src/utils/helpers/redux';
import type { ShlinkApiClient, ShlinkVisitsOverview } from '../../api-contract';
import type { CreateVisit } from '../types';
import { groupNewVisitsByType } from '../types/helpers';
import { createNewVisits } from './visitCreation';
const REDUCER_PREFIX = 'shlink/visitsOverview';
export type PartialVisitsSummary = {
total: number;
nonBots?: number;
bots?: number;
};
export type ParsedVisitsOverview = {
nonOrphanVisits: PartialVisitsSummary;
orphanVisits: PartialVisitsSummary;
};
export interface VisitsOverview extends ParsedVisitsOverview {
loading: boolean;
error: boolean;
}
export type GetVisitsOverviewAction = PayloadAction<ShlinkVisitsOverview>;
const initialState: VisitsOverview = {
nonOrphanVisits: {
total: 0,
},
orphanVisits: {
total: 0,
},
loading: false,
error: false,
};
const countBots = (visits: CreateVisit[]) => visits.filter(({ visit }) => visit.potentialBot).length;
export const loadVisitsOverview = (apiClient: ShlinkApiClient) => createAsyncThunk(
`${REDUCER_PREFIX}/loadVisitsOverview`,
(): Promise<ParsedVisitsOverview> => apiClient.getVisitsOverview().then(
({ nonOrphanVisits, visitsCount, orphanVisits, orphanVisitsCount }) => ({
nonOrphanVisits: {
total: nonOrphanVisits?.total ?? visitsCount,
nonBots: nonOrphanVisits?.nonBots,
bots: nonOrphanVisits?.bots,
},
orphanVisits: {
total: orphanVisits?.total ?? orphanVisitsCount,
nonBots: orphanVisits?.nonBots,
bots: orphanVisits?.bots,
},
}),
),
);
export const visitsOverviewReducerCreator = (
loadVisitsOverviewThunk: ReturnType<typeof loadVisitsOverview>,
) => createSlice({
name: REDUCER_PREFIX,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(loadVisitsOverviewThunk.pending, () => ({ ...initialState, loading: true }));
builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true }));
builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload }));
builder.addCase(createNewVisits, ({ nonOrphanVisits, orphanVisits, ...rest }, { payload }) => {
const { nonOrphanVisits: newNonOrphanVisits, orphanVisits: newOrphanVisits } = groupNewVisitsByType(
payload.createdVisits,
);
const newNonOrphanTotalVisits = newNonOrphanVisits.length;
const newNonOrphanBotVisits = countBots(newNonOrphanVisits);
const newNonOrphanNonBotVisits = newNonOrphanTotalVisits - newNonOrphanBotVisits;
const newOrphanTotalVisits = newOrphanVisits.length;
const newOrphanBotVisits = countBots(newOrphanVisits);
const newOrphanNonBotVisits = newOrphanTotalVisits - newOrphanBotVisits;
return {
...rest,
nonOrphanVisits: {
total: nonOrphanVisits.total + newNonOrphanTotalVisits,
bots: nonOrphanVisits.bots && nonOrphanVisits.bots + newNonOrphanBotVisits,
nonBots: nonOrphanVisits.nonBots && nonOrphanVisits.nonBots + newNonOrphanNonBotVisits,
},
orphanVisits: {
total: orphanVisits.total + newOrphanTotalVisits,
bots: orphanVisits.bots && orphanVisits.bots + newOrphanBotVisits,
nonBots: orphanVisits.nonBots && orphanVisits.nonBots + newOrphanNonBotVisits,
},
};
});
},
});

View File

@@ -0,0 +1,107 @@
import { isNil, map } from 'ramda';
import { hasValue } from '../../../src/utils/utils';
import type { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers';
import { extractDomain, parseUserAgent } from '../utils';
/* eslint-disable no-param-reassign */
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
!isNil(visit) && hasValue(visit[propertyName]);
const optionalNumericToNumber = (numeric: string | number | null | undefined): number => {
if (typeof numeric === 'number') {
return numeric;
}
return numeric ? parseFloat(numeric) : 0;
};
const updateOsStatsForVisit = (osStats: Stats, { os }: NormalizedVisit) => {
osStats[os] = (osStats[os] || 0) + 1;
};
const updateBrowsersStatsForVisit = (browsersStats: Stats, { browser }: NormalizedVisit) => {
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
};
const updateReferrersStatsForVisit = (referrersStats: Stats, { referer: domain }: NormalizedVisit) => {
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
};
const updateLocationsStatsForVisit = (propertyName: 'country' | 'city') => (stats: Stats, visit: NormalizedVisit) => {
const hasLocationProperty = visitHasProperty(visit, propertyName);
const value = hasLocationProperty ? visit[propertyName] : 'Unknown';
stats[value] = (stats[value] || 0) + 1;
};
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country');
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city');
const updateCitiesForMapForVisit = (citiesForMapStats: Record<string, CityStats>, visit: NormalizedVisit) => {
if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') {
return;
}
const { city, latitude, longitude } = visit;
const currentCity = citiesForMapStats[city] || {
cityName: city,
count: 0,
latLong: [optionalNumericToNumber(latitude), optionalNumericToNumber(longitude)],
};
currentCity.count += 1;
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(
(stats: VisitsStats, visit: NormalizedVisit) => {
// We mutate the original object because it has a big performance impact when large data sets are processed
updateOsStatsForVisit(stats.os, visit);
updateBrowsersStatsForVisit(stats.browsers, visit);
updateReferrersStatsForVisit(stats.referrers, visit);
updateCountriesStatsForVisit(stats.countries, visit);
updateCitiesStatsForVisit(stats.cities, visit);
updateCitiesForMapForVisit(stats.citiesForMap, visit);
updateVisitedUrlsForVisit(stats.visitedUrls, visit);
return stats;
},
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} },
);
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
const { userAgent, date, referer, visitLocation, potentialBot } = visit;
const common = {
date,
potentialBot,
...parseUserAgent(userAgent),
referer: extractDomain(referer),
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
latitude: visitLocation?.latitude,
longitude: visitLocation?.longitude,
};
if (!isOrphanVisit(visit)) {
return common;
}
return { ...common, type: visit.type, visitedUrl: visit.visitedUrl };
});
export interface VisitsParser {
processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats;
normalizeVisits: (visits: Visit[]) => NormalizedVisit[];
}

View File

@@ -0,0 +1,93 @@
import type Bottle from 'bottlejs';
import { prop } from 'ramda';
import type { ConnectDecorator } from '../../container';
import { DomainVisits } from '../DomainVisits';
import { MapModal } from '../helpers/MapModal';
import { NonOrphanVisits } from '../NonOrphanVisits';
import { OrphanVisits } from '../OrphanVisits';
import { domainVisitsReducerCreator, getDomainVisits } from '../reducers/domainVisits';
import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits';
import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits';
import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits';
import { getTagVisits, tagVisitsReducerCreator } from '../reducers/tagVisits';
import { createNewVisits } from '../reducers/visitCreation';
import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview';
import { ShortUrlVisits } from '../ShortUrlVisits';
import { TagVisits } from '../TagVisits';
import * as visitsParser from './VisitsParser';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('MapModal', () => MapModal);
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'ReportExporter');
bottle.decorator('ShortUrlVisits', connect(
['shortUrlVisits', 'shortUrlDetail', 'mercureInfo'],
['getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator', 'ReportExporter');
bottle.decorator('TagVisits', connect(
['tagVisits', 'mercureInfo'],
['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter');
bottle.decorator('DomainVisits', connect(
['domainVisits', 'mercureInfo'],
['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter');
bottle.decorator('OrphanVisits', connect(
['orphanVisits', 'mercureInfo'],
['getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
));
bottle.serviceFactory('NonOrphanVisits', NonOrphanVisits, 'ReportExporter');
bottle.decorator('NonOrphanVisits', connect(
['nonOrphanVisits', 'mercureInfo'],
['getNonOrphanVisits', 'cancelGetNonOrphanVisits', 'createNewVisits', 'loadMercureInfo'],
));
// Services
bottle.serviceFactory('VisitsParser', () => visitsParser);
// Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'apiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator');
bottle.serviceFactory('getTagVisits', getTagVisits, 'apiClient');
bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator');
bottle.serviceFactory('getDomainVisits', getDomainVisits, 'apiClient');
bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator');
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'apiClient');
bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator');
bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'apiClient');
bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator');
bottle.serviceFactory('createNewVisits', () => createNewVisits);
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'apiClient');
// Reducers
bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview');
bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator');
bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisits');
bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator');
bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisits');
bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator');
bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisits');
bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator');
bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisits');
bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator');
bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisits');
bottle.serviceFactory('tagVisitsReducer', prop('reducer'), 'tagVisitsReducerCreator');
};

View File

@@ -0,0 +1,37 @@
import { countBy, groupBy, pipe, prop } from 'ramda';
import type { ShlinkVisitsParams } from '../../../api/types';
import { formatIsoDate } from '../../../src/utils/helpers/date';
import type { CreateVisit, NormalizedOrphanVisit, NormalizedVisit, OrphanVisit, Stats, Visit, VisitsParams } from './index';
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => (visit as OrphanVisit).visitedUrl !== undefined;
export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit =>
(visit as NormalizedOrphanVisit).visitedUrl !== undefined;
export interface GroupedNewVisits {
orphanVisits: CreateVisit[];
nonOrphanVisits: CreateVisit[];
}
export const groupNewVisitsByType = pipe(
groupBy((newVisit: CreateVisit) => (isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'nonOrphanVisits')),
// @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props
(result): GroupedNewVisits => ({ orphanVisits: [], nonOrphanVisits: [], ...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 toApiParams = ({ page, itemsPerPage, filter, dateRange }: VisitsParams): ShlinkVisitsParams => {
const startDate = (dateRange?.startDate && formatIsoDate(dateRange?.startDate)) ?? undefined;
const endDate = (dateRange?.endDate && formatIsoDate(dateRange?.endDate)) ?? undefined;
const excludeBots = filter?.excludeBots || undefined;
return { page, itemsPerPage, startDate, endDate, excludeBots };
};

View File

@@ -0,0 +1,89 @@
import type { DateRange } from '../../../src/utils/helpers/dateIntervals';
import type { ShortUrl } from '../../short-urls/data';
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
interface VisitLocation {
countryCode: string | null;
countryName: string | null;
regionName: string | null;
cityName: string | null;
latitude: number | null;
longitude: number | null;
timezone: string | null;
isEmpty: boolean;
}
export interface RegularVisit {
referer: string;
date: string;
userAgent: string;
visitLocation: VisitLocation | null;
potentialBot: boolean;
}
export interface OrphanVisit extends RegularVisit {
visitedUrl: string;
type: OrphanVisitType;
}
export type Visit = RegularVisit | OrphanVisit;
export interface UserAgent {
browser: string;
os: string;
}
export interface NormalizedRegularVisit extends UserAgent {
date: string;
referer: string;
country: string;
city: string;
latitude?: number | null;
longitude?: number | null;
potentialBot: boolean;
}
export interface NormalizedOrphanVisit extends NormalizedRegularVisit {
visitedUrl: string;
type: OrphanVisitType;
}
export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit;
export interface CreateVisit {
shortUrl?: ShortUrl;
visit: Visit;
}
export type Stats = Record<string, number>;
export type StatsRow = [string, number];
export interface CityStats {
cityName: string;
count: number;
latLong: [number, number];
}
export interface VisitsStats {
os: Stats;
browsers: Stats;
referrers: Stats;
countries: Stats;
cities: Stats;
citiesForMap: Record<string, CityStats>;
visitedUrls: Stats;
}
export interface VisitsFilter {
orphanVisitsType?: OrphanVisitType | undefined;
excludeBots?: boolean;
}
export interface VisitsParams {
page?: number;
itemsPerPage?: number;
dateRange?: DateRange;
filter?: VisitsFilter;
}

View File

@@ -0,0 +1,41 @@
import bowser from 'bowser';
import { zipObj } from 'ramda';
import type { Empty } from '../../../src/utils/utils';
import { hasValue } from '../../../src/utils/utils';
import type { Stats, UserAgent } from '../types';
const DEFAULT = 'Others';
const BROWSERS_WHITELIST = [
'Android Browser',
'Chrome',
'Chromium',
'Firefox',
'Internet Explorer',
'Microsoft Edge',
'Opera',
'Safari',
'Samsung Internet for Android',
'Vivaldi',
'WeChat',
];
export const parseUserAgent = (userAgent: string | Empty): UserAgent => {
if (!hasValue(userAgent)) {
return { browser: DEFAULT, os: DEFAULT };
}
const { browser: { name: browser }, os: { name: os } } = bowser.parse(userAgent);
return { os: os ?? DEFAULT, browser: browser && BROWSERS_WHITELIST.includes(browser) ? browser : DEFAULT };
};
export const extractDomain = (url: string | Empty): string => {
if (!hasValue(url)) {
return 'Direct';
}
return url.split('/')[url.includes('://') ? 2 : 0]?.split(':')[0] ?? '';
};
export const fillTheGaps = (stats: Stats, labels: string[]): number[] =>
Object.values({ ...zipObj(labels, labels.map(() => 0)), ...stats });