Repository: shlinkio/shlink-web-client (ts)

Issue:
Improve orphan visits stats
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.

With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->

#### Summary

Improve new orphan visits stats section, implemented in https://github.com/shlinkio/shlink-web-client/pull/394, to include:

- [x] Display visited URL column in visits table.
- [x] Create a new "by context" chart to group by visited URL.
- [x] Allow filtering by type.


Pull request:
Feature/orphan visits improvements
Closes #395 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a924e1c3..593162a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default.
 * [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher.
 * [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme.
-* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0.
+* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) and [#395](https://github.com/shlinkio/shlink-web-client/issues/395) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0.
 * [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0.
 * [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages.
 * [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV.
diff --git a/src/utils/DropdownBtn.tsx b/src/utils/DropdownBtn.tsx
index d1cf6f6b..b658c218 100644
--- a/src/utils/DropdownBtn.tsx
+++ b/src/utils/DropdownBtn.tsx
@@ -7,16 +7,20 @@ export interface DropdownBtnProps {
   text: string;
   disabled?: boolean;
   className?: string;
+  dropdownClassName?: string;
+  right?: boolean;
 }
 
-export const DropdownBtn: FC<DropdownBtnProps> = ({ text, disabled = false, className = '', children }) => {
+export const DropdownBtn: FC<DropdownBtnProps> = (
+  { text, disabled = false, className = '', children, dropdownClassName, right = false },
+) => {
   const [ isOpen, toggle ] = useToggle();
   const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
 
   return (
-    <Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled}>
+    <Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
       <DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
-      <DropdownMenu className="w-100">{children}</DropdownMenu>
+      <DropdownMenu className="w-100" right={right}>{children}</DropdownMenu>
     </Dropdown>
   );
 };
diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx
index 54197a83..b79cfad8 100644
--- a/src/visits/OrphanVisits.tsx
+++ b/src/visits/OrphanVisits.tsx
@@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
       baseUrl={url}
       settings={settings}
       exportCsv={exportCsv}
+      isOrphanVisits
     >
       <OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
     </VisitsStats>
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index 09271d2d..d94d14bb 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -6,6 +6,7 @@ import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } fro
 import { IconDefinition } from '@fortawesome/fontawesome-common-types';
 import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom';
 import { Location } from 'history';
+import classNames from 'classnames';
 import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
 import Message from '../utils/Message';
 import { formatIsoDate } from '../utils/helpers/date';
@@ -18,10 +19,12 @@ import SortableBarGraph from './helpers/SortableBarGraph';
 import GraphCard from './helpers/GraphCard';
 import LineChartCard from './helpers/LineChartCard';
 import VisitsTable from './VisitsTable';
-import { NormalizedVisit, Stats, VisitsInfo } from './types';
+import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types';
 import OpenMapModalBtn from './helpers/OpenMapModalBtn';
-import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
+import { processStatsFromVisits } from './services/VisitsParser';
+import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown';
 import './VisitsStats.scss';
+import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers';
 
 export interface VisitsStatsProps {
   getVisits: (params: Partial<ShlinkVisitsParams>) => void;
@@ -31,6 +34,7 @@ export interface VisitsStatsProps {
   baseUrl: string;
   domain?: string;
   exportCsv: (visits: NormalizedVisit[]) => void;
+  isOrphanVisits?: boolean;
 }
 
 interface VisitsNavLinkProps {
@@ -39,7 +43,6 @@ interface VisitsNavLinkProps {
   icon: IconDefinition;
 }
 
-type HighlightableProps = 'referer' | 'country' | 'city';
 type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
 
 const sections: Record<Section, VisitsNavLinkProps> = {
@@ -49,18 +52,6 @@ const sections: Record<Section, VisitsNavLinkProps> = {
   list: { title: 'List', subPath: '/list', icon: faList },
 };
 
-const highlightedVisitsToStats = (
-  highlightedVisits: NormalizedVisit[],
-  prop: HighlightableProps,
-): Stats => highlightedVisits.reduce<Stats>((acc, highlightedVisit) => {
-  if (!acc[highlightedVisit[prop]]) {
-    acc[highlightedVisit[prop]] = 0;
-  }
-
-  acc[highlightedVisit[prop]] += 1;
-
-  return acc;
-}, {});
 let selectedBar: string | undefined;
 
 const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title, icon, to }) => (
@@ -77,12 +68,13 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
 );
 
 const VisitsStats: FC<VisitsStatsProps> = (
-  { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv },
+  { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false },
 ) => {
   const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
   const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
   const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
   const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
+  const [ orphanVisitType, setOrphanVisitType ] = useState<OrphanVisitType | undefined>();
 
   const buildSectionUrl = (subPath?: string) => {
     const query = domain ? `?domain=${domain}` : '';
@@ -90,8 +82,11 @@ const VisitsStats: FC<VisitsStatsProps> = (
     return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
   };
   const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
-  const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
-  const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
+  const normalizedVisits = useMemo(
+    () => normalizeAndFilterVisits(visits, orphanVisitType),
+    [ visits, orphanVisitType ],
+  );
+  const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
     () => processStatsFromVisits(normalizedVisits),
     [ normalizedVisits ],
   );
@@ -101,7 +96,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
     selectedBar = undefined;
     setHighlightedVisits(selectedVisits);
   };
-  const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => {
+  const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
     const newSelectedBar = `${prop}_${value}`;
 
     if (selectedBar === newSelectedBar) {
@@ -109,7 +104,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
       setHighlightedLabel(undefined);
       selectedBar = undefined;
     } else {
-      setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
+      setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
       setHighlightedLabel(value);
       selectedBar = newSelectedBar;
     }
@@ -171,13 +166,13 @@ const VisitsStats: FC<VisitsStatsProps> = (
             </Route>
 
             <Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
-              <div className="col-xl-4 col-lg-6 mt-4">
+              <div className={classNames('mt-4 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
                 <GraphCard title="Operating systems" stats={os} />
               </div>
-              <div className="col-xl-4 col-lg-6 mt-4">
+              <div className={classNames('mt-4 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
                 <GraphCard title="Browsers" stats={browsers} />
               </div>
-              <div className="col-xl-4 mt-4">
+              <div className={classNames('mt-4', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
                 <SortableBarGraph
                   title="Referrers"
                   stats={referrers}
@@ -191,6 +186,21 @@ const VisitsStats: FC<VisitsStatsProps> = (
                   onClick={highlightVisitsForProp('referer')}
                 />
               </div>
+              {isOrphanVisits && (
+                <div className="mt-4 col-lg-6">
+                  <SortableBarGraph
+                    title="Visited URLs"
+                    stats={visitedUrls}
+                    highlightedLabel={highlightedLabel}
+                    highlightedStats={highlightedVisitsToStats(highlightedVisits, 'visitedUrl')}
+                    sortingItems={{
+                      visitedUrl: 'Visited URL',
+                      amount: 'Visits amount',
+                    }}
+                    onClick={highlightVisitsForProp('visitedUrl')}
+                  />
+                </div>
+              )}
             </Route>
 
             <Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
@@ -232,6 +242,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
                   visits={normalizedVisits}
                   selectedVisits={highlightedVisits}
                   setSelectedVisits={setSelectedVisits}
+                  isOrphanVisits={isOrphanVisits}
                 />
               </div>
             </Route>
@@ -250,12 +261,24 @@ const VisitsStats: FC<VisitsStatsProps> = (
       <section className="mt-4">
         <div className="row flex-md-row-reverse">
           <div className="col-lg-7 col-xl-6">
-            <DateRangeSelector
-              disabled={loading}
-              initialDateRange={initialInterval}
-              defaultText="All visits"
-              onDatesChange={setDateRange}
-            />
+            <div className="d-md-flex">
+              <div className="flex-fill">
+                <DateRangeSelector
+                  disabled={loading}
+                  initialDateRange={initialInterval}
+                  defaultText="All visits"
+                  onDatesChange={setDateRange}
+                />
+              </div>
+              {isOrphanVisits && (
+                <OrphanVisitTypeDropdown
+                  text="Filter by type"
+                  className="ml-0 ml-md-2 mt-4 mt-md-0"
+                  selected={orphanVisitType}
+                  onChange={setOrphanVisitType}
+                />
+              )}
+            </div>
           </div>
           {visits.length > 0 && (
             <div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx
index fb99a950..9eb0eef2 100644
--- a/src/visits/VisitsTable.tsx
+++ b/src/visits/VisitsTable.tsx
@@ -12,7 +12,7 @@ import SimplePaginator from '../common/SimplePaginator';
 import SearchField from '../utils/SearchField';
 import { determineOrderDir, OrderDir } from '../utils/utils';
 import { prettify } from '../utils/helpers/numbers';
-import { NormalizedVisit } from './types';
+import { NormalizedOrphanVisit, NormalizedVisit } from './types';
 import './VisitsTable.scss';
 
 interface VisitsTableProps {
@@ -20,9 +20,10 @@ interface VisitsTableProps {
   selectedVisits?: NormalizedVisit[];
   setSelectedVisits: (visits: NormalizedVisit[]) => void;
   matchMedia?: (query: string) => MediaQueryList;
+  isOrphanVisits?: boolean;
 }
 
-type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer';
+type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl';
 
 interface Order {
   field?: OrderableFields;
@@ -30,8 +31,10 @@ interface Order {
 }
 
 const PAGE_SIZE = 20;
-const visitMatchesSearch = ({ browser, os, referer, country, city }: NormalizedVisit, searchTerm: string) =>
-  `${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase());
+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 = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort(
@@ -39,7 +42,7 @@ const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field
     const greaterThan = dir === 'ASC' ? 1 : -1;
     const smallerThan = dir === 'ASC' ? -1 : 1;
 
-    return a[field] > b[field] ? greaterThan : smallerThan;
+    return (a as NormalizedOrphanVisit)[field] > (b as NormalizedOrphanVisit)[field] ? greaterThan : smallerThan;
   },
 );
 const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => {
@@ -56,6 +59,7 @@ const VisitsTable = ({
   selectedVisits = [],
   setSelectedVisits,
   matchMedia = window.matchMedia,
+  isOrphanVisits = false,
 }: VisitsTableProps) => {
   const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
   const matchMobile = () => matchMedia('(max-width: 767px)').matches;
@@ -89,11 +93,8 @@ const VisitsTable = ({
   useEffect(() => {
     setPage(1);
 
-    if (isFirstLoad.current) {
-      isFirstLoad.current = false;
-    } else {
-      setSelectedVisits([]);
-    }
+    !isFirstLoad.current && setSelectedVisits([]);
+    isFirstLoad.current = false;
   }, [ searchTerm ]);
 
   return (
@@ -132,22 +133,28 @@ const VisitsTable = ({
             Referrer
             {renderOrderIcon('referer')}
           </th>
+          {isOrphanVisits && (
+            <th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
+              Visited URL
+              {renderOrderIcon('visitedUrl')}
+            </th>
+          )}
         </tr>
         <tr>
-          <td colSpan={7} className="p-0">
+          <td colSpan={isOrphanVisits ? 8 : 7} className="p-0">
             <SearchField noBorder large={false} onChange={setSearchTerm} />
           </td>
         </tr>
       </thead>
       <tbody>
-        {(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && (
+        {!resultSet.visitsGroups[page - 1]?.length && (
           <tr>
-            <td colSpan={7} className="text-center">
+            <td colSpan={isOrphanVisits ? 8 : 7} className="text-center">
               No visits found with current filtering
             </td>
           </tr>
         )}
-        {resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => {
+        {resultSet.visitsGroups[page - 1]?.map((visit, index) => {
           const isSelected = selectedVisits.includes(visit);
 
           return (
@@ -170,6 +177,7 @@ const VisitsTable = ({
               <td>{visit.browser}</td>
               <td>{visit.os}</td>
               <td>{visit.referer}</td>
+              {isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
             </tr>
           );
         })}
@@ -177,7 +185,7 @@ const VisitsTable = ({
       {resultSet.total > PAGE_SIZE && (
         <tfoot>
           <tr>
-            <td colSpan={7} className="visits-table__footer-cell visits-table__sticky">
+            <td colSpan={isOrphanVisits ? 8 : 7} className="visits-table__footer-cell visits-table__sticky">
               <div className="row">
                 <div className="col-md-6">
                   <SimplePaginator
diff --git a/src/visits/helpers/LineChartCard.tsx b/src/visits/helpers/LineChartCard.tsx
index 2190b4bf..cb180dca 100644
--- a/src/visits/helpers/LineChartCard.tsx
+++ b/src/visits/helpers/LineChartCard.tsx
@@ -9,7 +9,7 @@ import {
   DropdownItem,
 } from 'reactstrap';
 import { Line } from 'react-chartjs-2';
-import { always, cond, reverse } from 'ramda';
+import { always, cond, countBy, reverse } from 'ramda';
 import moment from 'moment';
 import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
 import { NormalizedVisit, Stats } from '../types';
@@ -70,15 +70,9 @@ const determineInitialStep = (oldestVisitDate: string): Step => {
   return matcher() ?? 'monthly';
 };
 
-const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visits.reduce<Stats>(
-  (acc, visit) => {
-    const key = STEP_TO_DATE_FORMAT[step](visit.date);
-
-    acc[key] = (acc[key] || 0) + 1;
-
-    return acc;
-  },
-  {},
+const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
+  (visit) => STEP_TO_DATE_FORMAT[step](visit.date),
+  visits,
 );
 
 const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
diff --git a/src/visits/helpers/OrphanVisitTypeDropdown.tsx b/src/visits/helpers/OrphanVisitTypeDropdown.tsx
new file mode 100644
index 00000000..61273c14
--- /dev/null
+++ b/src/visits/helpers/OrphanVisitTypeDropdown.tsx
@@ -0,0 +1,26 @@
+import { DropdownItem } from 'reactstrap';
+import { OrphanVisitType } from '../types';
+import { DropdownBtn } from '../../utils/DropdownBtn';
+
+interface OrphanVisitTypeDropdownProps {
+  onChange: (type: OrphanVisitType | undefined) => void;
+  selected?: OrphanVisitType | undefined;
+  className?: string;
+  text: string;
+}
+
+export const OrphanVisitTypeDropdown = ({ onChange, selected, text, className }: OrphanVisitTypeDropdownProps) => (
+  <DropdownBtn text={text} dropdownClassName={className} className="mr-3" right>
+    <DropdownItem active={selected === 'base_url'} onClick={() => onChange('base_url')}>
+      Base URL
+    </DropdownItem>
+    <DropdownItem active={selected === 'invalid_short_url'} onClick={() => onChange('invalid_short_url')}>
+      Invalid short URL
+    </DropdownItem>
+    <DropdownItem active={selected === 'regular_404'} onClick={() => onChange('regular_404')}>
+      Regular 404
+    </DropdownItem>
+    <DropdownItem divider />
+    <DropdownItem onClick={() => onChange(undefined)}><i>Clear selection</i></DropdownItem>
+  </DropdownBtn>
+);
diff --git a/src/visits/services/VisitsParser.ts b/src/visits/services/VisitsParser.ts
index 824fb796..9cef1faa 100644
--- a/src/visits/services/VisitsParser.ts
+++ b/src/visits/services/VisitsParser.ts
@@ -2,7 +2,7 @@ import { isNil, map } from 'ramda';
 import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
 import { hasValue } from '../../utils/utils';
 import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
-import { isOrphanVisit } from '../types/helpers';
+import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers';
 
 const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
   !isNil(visit) && hasValue(visit[propertyName]);
@@ -54,6 +54,16 @@ const updateCitiesForMapForVisit = (citiesForMapStats: Record<string, CityStats>
   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
@@ -63,10 +73,11 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
     updateCountriesStatsForVisit(stats.countries, visit);
     updateCitiesStatsForVisit(stats.cities, visit);
     updateCitiesForMapForVisit(stats.citiesForMap, visit);
+    updateVisitedUrlsForVisit(stats.visitedUrls, visit);
 
     return stats;
   },
-  { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} },
+  { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} },
 );
 
 export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts
index fdc45733..d2691504 100644
--- a/src/visits/types/helpers.ts
+++ b/src/visits/types/helpers.ts
@@ -1,8 +1,20 @@
-import { groupBy, pipe } from 'ramda';
-import { Visit, OrphanVisit, CreateVisit } from './index';
+import { countBy, filter, groupBy, pipe, prop } from 'ramda';
+import { normalizeVisits } from '../services/VisitsParser';
+import {
+  Visit,
+  OrphanVisit,
+  CreateVisit,
+  NormalizedVisit,
+  NormalizedOrphanVisit,
+  Stats,
+  OrphanVisitType,
+} from './index';
 
 export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
 
+export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit =>
+  visit.hasOwnProperty('visitedUrl');
+
 export interface GroupedNewVisits {
   orphanVisits: CreateVisit[];
   regularVisits: CreateVisit[];
@@ -13,3 +25,17 @@ export const groupNewVisitsByType = pipe(
   // @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props
   (result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
 );
+
+export type HighlightableProps<T extends NormalizedVisit> = T extends NormalizedOrphanVisit
+  ? ('referer' | 'country' | 'city' | 'visitedUrl')
+  : ('referer' | 'country' | 'city');
+
+export const highlightedVisitsToStats = <T extends NormalizedVisit>(
+  highlightedVisits: T[],
+  property: HighlightableProps<T>,
+): Stats => countBy(prop(property) as any, highlightedVisits);
+
+export const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe(
+  normalizeVisits,
+  filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type),
+)(visits);
diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts
index 813a8767..0e2879d5 100644
--- a/src/visits/types/index.ts
+++ b/src/visits/types/index.ts
@@ -20,7 +20,7 @@ export interface VisitsLoadFailedAction extends Action<string> {
   errorData?: ProblemDetailsError;
 }
 
-type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
+export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
 
 interface VisitLocation {
   countryCode: string | null;
@@ -90,4 +90,5 @@ export interface VisitsStats {
   countries: Stats;
   cities: Stats;
   citiesForMap: Record<string, CityStats>;
+  visitedUrls: Stats;
 }


Test patch:
diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx
index a2d01c4a..393ab64d 100644
--- a/test/visits/VisitsTable.test.tsx
+++ b/test/visits/VisitsTable.test.tsx
@@ -10,13 +10,14 @@ describe('<VisitsTable />', () => {
   const matchMedia = () => Mock.of<MediaQueryList>({ matches: false });
   const setSelectedVisits = jest.fn();
   let wrapper: ShallowWrapper;
-  const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => {
+  const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = [], isOrphanVisits = false) => {
     wrapper = shallow(
       <VisitsTable
         visits={visits}
         selectedVisits={selectedVisits}
         setSelectedVisits={setSelectedVisits}
         matchMedia={matchMedia}
+        isOrphanVisits={isOrphanVisits}
       />,
     );
 
@@ -134,4 +135,17 @@ describe('<VisitsTable />', () => {
     searchField.simulate('change', '');
     expect(wrapper.find('tbody').find('tr')).toHaveLength(7 + 2);
   });
+
+  it.each([
+    [ true, 8 ],
+    [ false, 7 ],
+  ])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => {
+    const wrapper = createWrapper([], [], isOrphanVisits);
+    const rowsWithColspan = wrapper.find('[colSpan]');
+    const cols = wrapper.find('th');
+
+    expect(cols).toHaveLength(expectedCols);
+    expect(rowsWithColspan).toHaveLength(2);
+    rowsWithColspan.forEach((row) => expect(row.prop('colSpan')).toEqual(expectedCols));
+  });
 });
diff --git a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx
new file mode 100644
index 00000000..c41b340a
--- /dev/null
+++ b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx
@@ -0,0 +1,56 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { DropdownItem } from 'reactstrap';
+import { OrphanVisitType } from '../../../src/visits/types';
+import { OrphanVisitTypeDropdown } from '../../../src/visits/helpers/OrphanVisitTypeDropdown';
+
+describe('<OrphanVisitTypeDropdown />', () => {
+  let wrapper: ShallowWrapper;
+  const onChange = jest.fn();
+  const createWrapper = (selected?: OrphanVisitType) => {
+    wrapper = shallow(<OrphanVisitTypeDropdown text="The text" selected={selected} onChange={onChange} />);
+
+    return wrapper;
+  };
+
+  beforeEach(jest.clearAllMocks);
+  afterEach(() => wrapper?.unmount());
+
+  it('has provided text', () => {
+    const wrapper = createWrapper();
+
+    expect(wrapper.prop('text')).toEqual('The text');
+  });
+
+  it.each([
+    [ 'base_url' as OrphanVisitType, 0, 1 ],
+    [ 'invalid_short_url' as OrphanVisitType, 1, 1 ],
+    [ 'regular_404' as OrphanVisitType, 2, 1 ],
+    [ undefined, -1, 0 ],
+  ])('sets expected item as active', (selected, expectedSelectedIndex, expectedActiveItems) => {
+    const wrapper = createWrapper(selected);
+    const items = wrapper.find(DropdownItem);
+    const activeItem = items.filterWhere((item) => !!item.prop('active'));
+
+    expect.assertions(expectedActiveItems + 1);
+    expect(activeItem).toHaveLength(expectedActiveItems);
+    items.forEach((item, index) => {
+      if (item.prop('active')) {
+        expect(index).toEqual(expectedSelectedIndex);
+      }
+    });
+  });
+
+  it.each([
+    [ 0, 'base_url' ],
+    [ 1, 'invalid_short_url' ],
+    [ 2, 'regular_404' ],
+    [ 4, undefined ],
+  ])('invokes onChange with proper type when an item is clicked', (index, expectedType) => {
+    const wrapper = createWrapper();
+    const itemToClick = wrapper.find(DropdownItem).at(index);
+
+    itemToClick.simulate('click');
+
+    expect(onChange).toHaveBeenCalledWith(expectedType);
+  });
+});
diff --git a/test/visits/services/VisitsParser.test.ts b/test/visits/services/VisitsParser.test.ts
index 96936f44..7edafab8 100644
--- a/test/visits/services/VisitsParser.test.ts
+++ b/test/visits/services/VisitsParser.test.ts
@@ -64,7 +64,7 @@ describe('VisitsParser', () => {
     }),
     Mock.of<OrphanVisit>({
       type: 'invalid_short_url',
-      visitedUrl: 'baz',
+      visitedUrl: 'bar',
       userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
       referer: 'https://m.facebook.com',
       visitLocation: {
@@ -153,6 +153,15 @@ describe('VisitsParser', () => {
         },
       });
     });
+
+    it('properly parses visited URL stats', () => {
+      const { visitedUrls } = processStatsFromVisits(normalizeVisits(orphanVisits));
+
+      expect(visitedUrls).toEqual({
+        foo: 1,
+        bar: 2,
+      });
+    });
   });
 
   describe('normalizeVisits', () => {
@@ -247,7 +256,7 @@ describe('VisitsParser', () => {
           latitude: 123.45,
           longitude: -543.21,
           type: 'invalid_short_url',
-          visitedUrl: 'baz',
+          visitedUrl: 'bar',
         },
       ]);
     });