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

Issue:
Allow configuring the default interval for visits
<!--
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

Since version 3, when loading visits, only the last 30 days are loaded by default.

Add new settings to allow customizing that.


Pull request description:
Feature/visits default value
Closes #368 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ca5ced5..45b03681 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * [#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.
 * [#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.
 
 ### Changed
 * [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.
diff --git a/src/settings/RealTimeUpdates.tsx b/src/settings/RealTimeUpdates.tsx
index d737f6e9..46ac074e 100644
--- a/src/settings/RealTimeUpdates.tsx
+++ b/src/settings/RealTimeUpdates.tsx
@@ -15,7 +15,7 @@ const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
 const RealTimeUpdates = (
   { settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
 ) => (
-  <SimpleCard title="Real-time updates">
+  <SimpleCard title="Real-time updates" className="h-100">
     <FormGroup>
       <ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
         Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index 8e6731f5..1c9a1b36 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -1,22 +1,29 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import { Row } from 'reactstrap';
 import NoMenuLayout from '../common/NoMenuLayout';
 
-const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC) => () => (
+const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
+  <>
+    {items.map((child, index) => (
+      <Row key={index}>
+        {child.map((subChild, subIndex) => (
+          <div key={subIndex} className="col-lg-6 mb-3 mb-md-4">
+            {subChild}
+          </div>
+        ))}
+      </Row>
+    ))}
+  </>
+);
+
+const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => (
   <NoMenuLayout>
-    <Row>
-      <div className="col-lg-6">
-        <div className="mb-3 mb-md-4">
-          <UserInterface />
-        </div>
-        <div className="mb-3 mb-md-4">
-          <ShortUrlCreation />
-        </div>
-      </div>
-      <div className="col-lg-6">
-        <RealTimeUpdates />
-      </div>
-    </Row>
+    <SettingsSections
+      items={[
+        [ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
+        [ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
+      ]}
+    />
   </NoMenuLayout>
 );
 
diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx
index afd88c12..b91ed500 100644
--- a/src/settings/ShortUrlCreation.tsx
+++ b/src/settings/ShortUrlCreation.tsx
@@ -12,7 +12,7 @@ interface ShortUrlCreationProps {
 export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
   { settings: { shortUrlCreation }, setShortUrlCreationSettings },
 ) => (
-  <SimpleCard title="Short URLs creation">
+  <SimpleCard title="Short URLs creation" className="h-100">
     <FormGroup className="mb-0">
       <ToggleSwitch
         checked={shortUrlCreation?.validateUrls ?? false}
diff --git a/src/settings/UserInterface.tsx b/src/settings/UserInterface.tsx
index 3180ebc4..9a915078 100644
--- a/src/settings/UserInterface.tsx
+++ b/src/settings/UserInterface.tsx
@@ -13,7 +13,7 @@ interface UserInterfaceProps {
 }
 
 export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
-  <SimpleCard title="User interface">
+  <SimpleCard title="User interface" className="h-100">
     <FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
     <ToggleSwitch
       checked={ui?.theme === 'dark'}
diff --git a/src/settings/Visits.tsx b/src/settings/Visits.tsx
new file mode 100644
index 00000000..6100cb5b
--- /dev/null
+++ b/src/settings/Visits.tsx
@@ -0,0 +1,22 @@
+import { FormGroup } from 'reactstrap';
+import { FC } from 'react';
+import { SimpleCard } from '../utils/SimpleCard';
+import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
+import { Settings, VisitsSettings } from './reducers/settings';
+
+interface VisitsProps {
+  settings: Settings;
+  setVisitsSettings: (settings: VisitsSettings) => void;
+}
+
+export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
+  <SimpleCard title="Visits" className="h-100">
+    <FormGroup className="mb-0">
+      <label>Default interval to load on visits sections:</label>
+      <DateIntervalSelector
+        active={settings.visits?.defaultInterval ?? 'last30Days'}
+        onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
+      />
+    </FormGroup>
+  </SimpleCard>
+);
diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts
index e5c0d1f9..0ba6f106 100644
--- a/src/settings/reducers/settings.ts
+++ b/src/settings/reducers/settings.ts
@@ -3,6 +3,7 @@ import { dissoc, mergeDeepRight } from 'ramda';
 import { buildReducer } from '../../utils/helpers/redux';
 import { RecursivePartial } from '../../utils/utils';
 import { Theme } from '../../utils/theme';
+import { DateInterval } from '../../utils/dates/types';
 
 export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
 
@@ -24,10 +25,15 @@ export interface UiSettings {
   theme: Theme;
 }
 
+export interface VisitsSettings {
+  defaultInterval: DateInterval;
+}
+
 export interface Settings {
   realTimeUpdates: RealTimeUpdatesSettings;
   shortUrlCreation?: ShortUrlCreationSettings;
   ui?: UiSettings;
+  visits?: VisitsSettings;
 }
 
 const initialState: Settings = {
@@ -40,6 +46,9 @@ const initialState: Settings = {
   ui: {
     theme: 'light',
   },
+  visits: {
+    defaultInterval: 'last30Days',
+  },
 };
 
 type SettingsAction = Action & Settings;
@@ -69,3 +78,8 @@ export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
   type: SET_SETTINGS,
   ui: settings,
 });
+
+export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsAction => ({
+  type: SET_SETTINGS,
+  visits: settings,
+});
diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts
index cd01599b..52652154 100644
--- a/src/settings/services/provideServices.ts
+++ b/src/settings/services/provideServices.ts
@@ -5,16 +5,18 @@ import {
   setRealTimeUpdatesInterval,
   setShortUrlCreationSettings,
   setUiSettings,
+  setVisitsSettings,
   toggleRealTimeUpdates,
 } from '../reducers/settings';
 import { ConnectDecorator } from '../../container/types';
 import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
 import { ShortUrlCreation } from '../ShortUrlCreation';
 import { UserInterface } from '../UserInterface';
+import { Visits } from '../Visits';
 
 const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   // Components
-  bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface');
+  bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits');
   bottle.decorator('Settings', withoutSelectedServer);
   bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
 
@@ -30,11 +32,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   bottle.serviceFactory('UserInterface', () => UserInterface);
   bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
 
+  bottle.serviceFactory('Visits', () => Visits);
+  bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
+
   // Actions
   bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
   bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
   bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
   bottle.serviceFactory('setUiSettings', () => setUiSettings);
+  bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
 };
 
 export default provideServices;
diff --git a/src/utils/dates/DateIntervalDropdownItems.tsx b/src/utils/dates/DateIntervalDropdownItems.tsx
new file mode 100644
index 00000000..b1ea2208
--- /dev/null
+++ b/src/utils/dates/DateIntervalDropdownItems.tsx
@@ -0,0 +1,20 @@
+import { DropdownItem } from 'reactstrap';
+import { FC } from 'react';
+import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
+
+export interface DateIntervalDropdownProps {
+  active?: DateInterval;
+  onChange: (interval: DateInterval) => void;
+}
+
+export const DateIntervalDropdownItems: FC<DateIntervalDropdownProps> = ({ active, onChange }) => (
+  <>
+    {DATE_INTERVALS.map(
+      (interval) => (
+        <DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval)}>
+          {rangeOrIntervalToString(interval)}
+        </DropdownItem>
+      ),
+    )}
+  </>
+);
diff --git a/src/utils/dates/DateIntervalSelector.tsx b/src/utils/dates/DateIntervalSelector.tsx
new file mode 100644
index 00000000..59d741db
--- /dev/null
+++ b/src/utils/dates/DateIntervalSelector.tsx
@@ -0,0 +1,10 @@
+import { FC } from 'react';
+import { DropdownBtn } from '../DropdownBtn';
+import { rangeOrIntervalToString } from './types';
+import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
+
+export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active }) => (
+  <DropdownBtn text={rangeOrIntervalToString(active) ?? ''}>
+    <DateIntervalDropdownItems active={active} onChange={onChange} />
+  </DropdownBtn>
+);
diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx
index 1b680f90..4b6212b8 100644
--- a/src/utils/dates/DateRangeSelector.tsx
+++ b/src/utils/dates/DateRangeSelector.tsx
@@ -10,6 +10,7 @@ import {
   rangeIsInterval,
 } from './types';
 import DateRangeRow from './DateRangeRow';
+import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
 
 export interface DateRangeSelectorProps {
   initialDateRange?: DateInterval | DateRange;
@@ -47,13 +48,7 @@ export const DateRangeSelector = (
         {defaultText}
       </DropdownItem>
       <DropdownItem divider />
-      {([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map(
-        (interval) => (
-          <DropdownItem key={interval} active={activeInterval === interval} onClick={updateInterval(interval)}>
-            {rangeOrIntervalToString(interval)}
-          </DropdownItem>
-        ),
-      )}
+      <DateIntervalDropdownItems active={activeInterval} onChange={(interval) => updateInterval(interval)()} />
       <DropdownItem divider />
       <DropdownItem header>Custom:</DropdownItem>
       <DropdownItem text>
diff --git a/src/utils/dates/types/index.ts b/src/utils/dates/types/index.ts
index aeb3c93a..07e5e6db 100644
--- a/src/utils/dates/types/index.ts
+++ b/src/utils/dates/types/index.ts
@@ -24,6 +24,8 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string> = {
   last365Days: 'Last 365 days',
 };
 
+export const DATE_INTERVALS: DateInterval[] = Object.keys(INTERVAL_TO_STRING_MAP) as DateInterval[];
+
 const dateRangeToString = (range?: DateRange): string | undefined => {
   if (!range || dateRangeIsEmpty(range)) {
     return undefined;
diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx
index cb959869..04d421eb 100644
--- a/src/visits/OrphanVisits.tsx
+++ b/src/visits/OrphanVisits.tsx
@@ -2,6 +2,7 @@ import { RouteComponentProps } from 'react-router';
 import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
 import { ShlinkVisitsParams } from '../api/types';
 import { Topics } from '../mercure/helpers/Topics';
+import { Settings } from '../settings/reducers/settings';
 import VisitsStats from './VisitsStats';
 import { OrphanVisitsHeader } from './OrphanVisitsHeader';
 import { VisitsInfo } from './types';
@@ -10,6 +11,7 @@ export interface OrphanVisitsProps extends RouteComponentProps {
   getOrphanVisits: (params: ShlinkVisitsParams) => void;
   orphanVisits: VisitsInfo;
   cancelGetOrphanVisits: () => void;
+  settings: Settings;
 }
 
 export const OrphanVisits = boundToMercureHub(({
@@ -18,12 +20,14 @@ export const OrphanVisits = boundToMercureHub(({
   getOrphanVisits,
   orphanVisits,
   cancelGetOrphanVisits,
+  settings,
 }: OrphanVisitsProps) => (
   <VisitsStats
     getVisits={getOrphanVisits}
     cancelGetVisits={cancelGetOrphanVisits}
     visitsInfo={orphanVisits}
     baseUrl={url}
+    settings={settings}
   >
     <OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
   </VisitsStats>
diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx
index 509e34ae..d4894b0a 100644
--- a/src/visits/ShortUrlVisits.tsx
+++ b/src/visits/ShortUrlVisits.tsx
@@ -5,6 +5,7 @@ import { ShlinkVisitsParams } from '../api/types';
 import { parseQuery } from '../utils/helpers/query';
 import { Topics } from '../mercure/helpers/Topics';
 import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
+import { Settings } from '../settings/reducers/settings';
 import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
 import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
 import VisitsStats from './VisitsStats';
@@ -15,6 +16,7 @@ export interface ShortUrlVisitsProps extends RouteComponentProps<{ shortCode: st
   getShortUrlDetail: Function;
   shortUrlDetail: ShortUrlDetail;
   cancelGetShortUrlVisits: () => void;
+  settings: Settings;
 }
 
 const ShortUrlVisits = boundToMercureHub(({
@@ -26,6 +28,7 @@ const ShortUrlVisits = boundToMercureHub(({
   getShortUrlVisits,
   getShortUrlDetail,
   cancelGetShortUrlVisits,
+  settings,
 }: ShortUrlVisitsProps) => {
   const { shortCode } = params;
   const { domain } = parseQuery<{ domain?: string }>(search);
@@ -42,6 +45,7 @@ const ShortUrlVisits = boundToMercureHub(({
       visitsInfo={shortUrlVisits}
       baseUrl={url}
       domain={domain}
+      settings={settings}
     >
       <ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
     </VisitsStats>
diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx
index d6772682..0f687382 100644
--- a/src/visits/TagVisits.tsx
+++ b/src/visits/TagVisits.tsx
@@ -3,6 +3,7 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
 import ColorGenerator from '../utils/services/ColorGenerator';
 import { ShlinkVisitsParams } from '../api/types';
 import { Topics } from '../mercure/helpers/Topics';
+import { Settings } from '../settings/reducers/settings';
 import { TagVisits as TagVisitsState } from './reducers/tagVisits';
 import TagVisitsHeader from './TagVisitsHeader';
 import VisitsStats from './VisitsStats';
@@ -11,6 +12,7 @@ export interface TagVisitsProps extends RouteComponentProps<{ tag: string }> {
   getTagVisits: (tag: string, query: any) => void;
   tagVisits: TagVisitsState;
   cancelGetTagVisits: () => void;
+  settings: Settings;
 }
 
 const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
@@ -19,12 +21,19 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
   getTagVisits,
   tagVisits,
   cancelGetTagVisits,
+  settings,
 }: TagVisitsProps) => {
   const { tag } = params;
   const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params);
 
   return (
-    <VisitsStats getVisits={loadVisits} cancelGetVisits={cancelGetTagVisits} visitsInfo={tagVisits} baseUrl={url}>
+    <VisitsStats
+      getVisits={loadVisits}
+      cancelGetVisits={cancelGetTagVisits}
+      visitsInfo={tagVisits}
+      baseUrl={url}
+      settings={settings}
+    >
       <TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
     </VisitsStats>
   );
diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx
index 0d391717..c8d80918 100644
--- a/src/visits/VisitsStats.tsx
+++ b/src/visits/VisitsStats.tsx
@@ -13,6 +13,7 @@ import { ShlinkVisitsParams } from '../api/types';
 import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
 import { Result } from '../utils/Result';
 import { ShlinkApiError } from '../api/ShlinkApiError';
+import { Settings } from '../settings/reducers/settings';
 import SortableBarGraph from './helpers/SortableBarGraph';
 import GraphCard from './helpers/GraphCard';
 import LineChartCard from './helpers/LineChartCard';
@@ -25,6 +26,7 @@ import './VisitsStats.scss';
 export interface VisitsStatsProps {
   getVisits: (params: Partial<ShlinkVisitsParams>) => void;
   visitsInfo: VisitsInfo;
+  settings: Settings;
   cancelGetVisits: () => void;
   baseUrl: string;
   domain?: string;
@@ -59,7 +61,6 @@ const highlightedVisitsToStats = (
   return acc;
 }, {});
 let selectedBar: string | undefined;
-const initialInterval: DateInterval = 'last30Days';
 
 const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title, icon, to }) => (
   <NavLink
@@ -74,7 +75,10 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
   </NavLink>
 );
 
-const VisitsStats: FC<VisitsStatsProps> = ({ children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain }) => {
+const VisitsStats: FC<VisitsStatsProps> = (
+  { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings },
+) => {
+  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>();
diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts
index 5e7775f5..3ebb8c08 100644
--- a/src/visits/services/provideServices.ts
+++ b/src/visits/services/provideServices.ts
@@ -18,19 +18,19 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
 
   bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits);
   bottle.decorator('ShortUrlVisits', connect(
-    [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
+    [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'settings' ],
     [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
   ));
 
   bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator');
   bottle.decorator('TagVisits', connect(
-    [ 'tagVisits', 'mercureInfo' ],
+    [ 'tagVisits', 'mercureInfo', 'settings' ],
     [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
   ));
 
   bottle.serviceFactory('OrphanVisits', () => OrphanVisits);
   bottle.decorator('OrphanVisits', connect(
-    [ 'orphanVisits', 'mercureInfo' ],
+    [ 'orphanVisits', 'mercureInfo', 'settings' ],
     [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
   ));
 


Test patch:
diff --git a/test/servers/Overview.test.tsx b/test/servers/Overview.test.tsx
index 7827b637..3b29b3e4 100644
--- a/test/servers/Overview.test.tsx
+++ b/test/servers/Overview.test.tsx
@@ -45,7 +45,7 @@ describe('<Overview />', () => {
 
   afterEach(() => wrapper?.unmount());
 
-  test('cards display loading messages when still loading', () => {
+  it('displays loading messages when still loading', () => {
     const wrapper = createWrapper(true);
     const cards = wrapper.find(CardText);
 
@@ -53,7 +53,7 @@ describe('<Overview />', () => {
     cards.forEach((card) => expect(card.html()).toContain('Loading...'));
   });
 
-  test('amounts are displayed in cards after finishing loading', () => {
+  it('displays amounts in cards after finishing loading', () => {
     const wrapper = createWrapper();
     const cards = wrapper.find(CardText);
 
@@ -64,21 +64,21 @@ describe('<Overview />', () => {
     expect(cards.at(3).html()).toContain(prettify(3));
   });
 
-  test('first card displays warning for old shlink versions', () => {
+  it('displays warning in first card for old shlink versions', () => {
     const wrapper = createWrapper();
     const firstCard = wrapper.find(CardText).first();
 
     expect(firstCard.html()).toContain('Shlink 2.2 is needed');
   });
 
-  test('nests complex components', () => {
+  it('nests complex components', () => {
     const wrapper = createWrapper();
 
     expect(wrapper.find(CreateShortUrl)).toHaveLength(1);
     expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
   });
 
-  test('links to other sections are displayed', () => {
+  it('displays links to other sections', () => {
     const wrapper = createWrapper();
     const links = wrapper.find(Link);
 
diff --git a/test/settings/Visits.test.tsx b/test/settings/Visits.test.tsx
new file mode 100644
index 00000000..cfbaf833
--- /dev/null
+++ b/test/settings/Visits.test.tsx
@@ -0,0 +1,66 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { Mock } from 'ts-mockery';
+import { Settings } from '../../src/settings/reducers/settings';
+import { Visits } from '../../src/settings/Visits';
+import { SimpleCard } from '../../src/utils/SimpleCard';
+import { DateIntervalSelector } from '../../src/utils/dates/DateIntervalSelector';
+
+describe('<Visits />', () => {
+  let wrapper: ShallowWrapper;
+  const setVisitsSettings = jest.fn();
+  const createWrapper = (settings: Partial<Settings> = {}) => {
+    wrapper = shallow(<Visits settings={Mock.of<Settings>(settings)} setVisitsSettings={setVisitsSettings} />);
+
+    return wrapper;
+  };
+
+  afterEach(jest.clearAllMocks);
+  afterEach(() => wrapper?.unmount());
+
+  it('renders expected components', () => {
+    const wrapper = createWrapper();
+
+    expect(wrapper.find(SimpleCard).prop('title')).toEqual('Visits');
+    expect(wrapper.find('label').prop('children')).toEqual('Default interval to load on visits sections:');
+    expect(wrapper.find(DateIntervalSelector)).toHaveLength(1);
+  });
+
+  it.each([
+    [ Mock.all<Settings>(), 'last30Days' ],
+    [ Mock.of<Settings>({ visits: {} }), 'last30Days' ],
+    [
+      Mock.of<Settings>({
+        visits: {
+          defaultInterval: 'last7Days',
+        },
+      }),
+      'last7Days',
+    ],
+    [
+      Mock.of<Settings>({
+        visits: {
+          defaultInterval: 'today',
+        },
+      }),
+      'today',
+    ],
+  ])('sets expected interval as active', (settings, expectedInterval) => {
+    const wrapper = createWrapper(settings);
+
+    expect(wrapper.find(DateIntervalSelector).prop('active')).toEqual(expectedInterval);
+  });
+
+  it('invokes setVisitsSettings when interval changes', () => {
+    const wrapper = createWrapper();
+    const selector = wrapper.find(DateIntervalSelector);
+
+    selector.simulate('change', 'last7Days');
+    selector.simulate('change', 'last180days');
+    selector.simulate('change', 'yesterday');
+
+    expect(setVisitsSettings).toHaveBeenCalledTimes(3);
+    expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' });
+    expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180days' });
+    expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
+  });
+});
diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts
index 57d22066..9699f57a 100644
--- a/test/settings/reducers/settings.test.ts
+++ b/test/settings/reducers/settings.test.ts
@@ -4,13 +4,15 @@ import reducer, {
   setRealTimeUpdatesInterval,
   setShortUrlCreationSettings,
   setUiSettings,
+  setVisitsSettings,
 } from '../../../src/settings/reducers/settings';
 
 describe('settingsReducer', () => {
   const realTimeUpdates = { enabled: true };
   const shortUrlCreation = { validateUrls: false };
   const ui = { theme: 'light' };
-  const settings = { realTimeUpdates, shortUrlCreation, ui };
+  const visits = { defaultInterval: 'last30Days' };
+  const settings = { realTimeUpdates, shortUrlCreation, ui, visits };
 
   describe('reducer', () => {
     it('returns realTimeUpdates when action is SET_SETTINGS', () => {
@@ -49,4 +51,12 @@ describe('settingsReducer', () => {
       expect(result).toEqual({ type: SET_SETTINGS, ui: { theme: 'dark' } });
     });
   });
+
+  describe('setVisitsSettings', () => {
+    it('creates action to set visits settings', () => {
+      const result = setVisitsSettings({ defaultInterval: 'last180days' });
+
+      expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180days' } });
+    });
+  });
 });
diff --git a/test/utils/CopyToClipboardIcon.test.tsx b/test/utils/CopyToClipboardIcon.test.tsx
index 9f0d709b..86d7a924 100644
--- a/test/utils/CopyToClipboardIcon.test.tsx
+++ b/test/utils/CopyToClipboardIcon.test.tsx
@@ -13,7 +13,7 @@ describe('<CopyToClipboardIcon />', () => {
   });
   afterEach(() => wrapper?.unmount());
 
-  test('expected components are wrapped', () => {
+  it('wraps expected components', () => {
     const copyToClipboard = wrapper.find(CopyToClipboard);
     const icon = wrapper.find(FontAwesomeIcon);
 
diff --git a/test/utils/dates/DateIntervalDropdownItems.test.tsx b/test/utils/dates/DateIntervalDropdownItems.test.tsx
new file mode 100644
index 00000000..2d15ebd8
--- /dev/null
+++ b/test/utils/dates/DateIntervalDropdownItems.test.tsx
@@ -0,0 +1,39 @@
+import { DropdownItem } from 'reactstrap';
+import { shallow, ShallowWrapper } from 'enzyme';
+import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
+import { DATE_INTERVALS } from '../../../src/utils/dates/types';
+
+describe('<DateIntervalDropdownItems />', () => {
+  let wrapper: ShallowWrapper;
+  const onChange = jest.fn();
+
+  beforeEach(() => {
+    wrapper = shallow(<DateIntervalDropdownItems active="last180days" onChange={onChange} />);
+  });
+
+  afterEach(jest.clearAllMocks);
+  afterEach(() => wrapper?.unmount());
+
+  it('renders expected amount of items', () => {
+    const items = wrapper.find(DropdownItem);
+
+    expect(items).toHaveLength(DATE_INTERVALS.length);
+  });
+
+  it('sets expected item as active', () => {
+    const items = wrapper.find(DropdownItem);
+    const EXPECTED_ACTIVE_INDEX = 5;
+
+    expect.assertions(DATE_INTERVALS.length);
+    items.forEach((item, index) => expect(item.prop('active')).toEqual(index === EXPECTED_ACTIVE_INDEX));
+  });
+
+  it('triggers onChange callback when selecting an element', () => {
+    const items = wrapper.find(DropdownItem);
+
+    items.at(2).simulate('click');
+    items.at(4).simulate('click');
+    items.at(1).simulate('click');
+    expect(onChange).toHaveBeenCalledTimes(3);
+  });
+});
diff --git a/test/utils/dates/DateIntervalSelector.test.tsx b/test/utils/dates/DateIntervalSelector.test.tsx
new file mode 100644
index 00000000..94c068d7
--- /dev/null
+++ b/test/utils/dates/DateIntervalSelector.test.tsx
@@ -0,0 +1,26 @@
+import { shallow, ShallowWrapper } from 'enzyme';
+import { DateInterval, rangeOrIntervalToString } from '../../../src/utils/dates/types';
+import { DateIntervalSelector } from '../../../src/utils/dates/DateIntervalSelector';
+import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
+import { DropdownBtn } from '../../../src/utils/DropdownBtn';
+
+describe('<DateIntervalSelector />', () => {
+  let wrapper: ShallowWrapper;
+  const activeInterval: DateInterval = 'last7Days';
+  const onChange = jest.fn();
+
+  beforeEach(() => {
+    wrapper = shallow(<DateIntervalSelector active={activeInterval} onChange={onChange} />);
+  });
+  afterEach(() => wrapper?.unmount());
+
+  it('passes props down to nested DateIntervalDropdownItems', () => {
+    const items = wrapper.find(DateIntervalDropdownItems);
+    const dropdown = wrapper.find(DropdownBtn);
+
+    expect(dropdown.prop('text')).toEqual(rangeOrIntervalToString(activeInterval));
+    expect(items).toHaveLength(1);
+    expect(items.prop('onChange')).toEqual(onChange);
+    expect(items.prop('active')).toEqual(activeInterval);
+  });
+});
diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx
index 78a375c7..f26ef990 100644
--- a/test/utils/dates/DateRangeSelector.test.tsx
+++ b/test/utils/dates/DateRangeSelector.test.tsx
@@ -4,6 +4,7 @@ import moment from 'moment';
 import { Mock } from 'ts-mockery';
 import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector';
 import { DateInterval } from '../../../src/utils/dates/types';
+import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
 
 describe('<DateRangeSelector />', () => {
   let wrapper: ShallowWrapper;
@@ -17,42 +18,52 @@ describe('<DateRangeSelector />', () => {
   afterEach(jest.clearAllMocks);
   afterEach(() => wrapper?.unmount());
 
-  test('proper amount of items is rendered', () => {
+  it('renders proper amount of items', () => {
     const wrapper = createWrapper();
     const items = wrapper.find(DropdownItem);
+    const dateIntervalItems = wrapper.find(DateIntervalDropdownItems);
 
-    expect(items).toHaveLength(12);
+    expect(items).toHaveLength(5);
+    expect(dateIntervalItems).toHaveLength(1);
     expect(items.filter('[divider]')).toHaveLength(2);
     expect(items.filter('[header]')).toHaveLength(1);
     expect(items.filter('[text]')).toHaveLength(1);
-    expect(items.filter('[active]')).toHaveLength(8);
+    expect(items.filter('[active]')).toHaveLength(1);
   });
 
-  test.each([
-    [ undefined, 0 ],
-    [ 'today' as DateInterval, 1 ],
-    [ 'yesterday' as DateInterval, 2 ],
-    [ 'last7Days' as DateInterval, 3 ],
-    [ 'last30Days' as DateInterval, 4 ],
-    [ 'last90Days' as DateInterval, 5 ],
-    [ 'last180days' as DateInterval, 6 ],
-    [ 'last365Days' as DateInterval, 7 ],
-    [{ startDate: moment() }, 8 ],
-  ])('proper element is active based on provided date range', (initialDateRange, expectedActiveIndex) => {
+  it.each([
+    [ undefined, 1, 0 ],
+    [ 'today' as DateInterval, 0, 1 ],
+    [ 'yesterday' as DateInterval, 0, 1 ],
+    [ 'last7Days' as DateInterval, 0, 1 ],
+    [ 'last30Days' as DateInterval, 0, 1 ],
+    [ 'last90Days' as DateInterval, 0, 1 ],
+    [ 'last180days' as DateInterval, 0, 1 ],
+    [ 'last365Days' as DateInterval, 0, 1 ],
+    [{ startDate: moment() }, 0, 0 ],
+  ])('sets proper element as active based on provided date range', (
+    initialDateRange,
+    expectedActiveItems,
+    expectedActiveIntervalItems,
+  ) => {
     const wrapper = createWrapper({ initialDateRange });
-    const items = wrapper.find(DropdownItem).filter('[active]');
+    const items = wrapper.find(DropdownItem).filterWhere((item) => item.prop('active') === true);
+    const dateIntervalItems = wrapper.find(DateIntervalDropdownItems).filterWhere(
+      (item) => item.prop('active') !== undefined,
+    );
 
-    expect.assertions(8);
-    items.forEach((item, index) => expect(item.prop('active')).toEqual(index === expectedActiveIndex));
+    expect(items).toHaveLength(expectedActiveItems);
+    expect(dateIntervalItems).toHaveLength(expectedActiveIntervalItems);
   });
 
-  test('selecting an element triggers onDatesChange callback', () => {
+  it('triggers onDatesChange callback when selecting an element', () => {
     const wrapper = createWrapper();
-    const items = wrapper.find(DropdownItem).filter('[active]');
+    const item = wrapper.find(DropdownItem).at(0);
+    const dateIntervalItems = wrapper.find(DateIntervalDropdownItems);
 
-    items.at(2).simulate('click');
-    items.at(4).simulate('click');
-    items.at(1).simulate('click');
+    item.simulate('click');
+    item.simulate('click');
+    dateIntervalItems.simulate('change');
     expect(onDatesChange).toHaveBeenCalledTimes(3);
   });
 });
diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx
index be362905..ad953bbc 100644
--- a/test/visits/OrphanVisits.test.tsx
+++ b/test/visits/OrphanVisits.test.tsx
@@ -7,6 +7,7 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
 import { VisitsInfo } from '../../src/visits/types';
 import VisitsStats from '../../src/visits/VisitsStats';
 import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
+import { Settings } from '../../src/settings/reducers/settings';
 
 describe('<OrphanVisits />', () => {
   it('wraps visits stats and header', () => {
@@ -24,6 +25,7 @@ describe('<OrphanVisits />', () => {
         history={Mock.of<History>({ goBack })}
         location={Mock.all<Location>()}
         match={Mock.of<match>({ url: 'the_base_url' })}
+        settings={Mock.all<Settings>()}
       />,
     ).dive();
     const stats = wrapper.find(VisitsStats);
diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx
index d878d2e8..d66255cb 100644
--- a/test/visits/VisitsStats.test.tsx
+++ b/test/visits/VisitsStats.test.tsx
@@ -9,6 +9,7 @@ import { Visit, VisitsInfo } from '../../src/visits/types';
 import LineChartCard from '../../src/visits/helpers/LineChartCard';
 import VisitsTable from '../../src/visits/VisitsTable';
 import { Result } from '../../src/utils/Result';
+import { Settings } from '../../src/settings/reducers/settings';
 
 describe('<VisitStats />', () => {
   const visits = [ Mock.all<Visit>(), Mock.all<Visit>(), Mock.all<Visit>() ];
@@ -23,6 +24,7 @@ describe('<VisitStats />', () => {
         visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
         cancelGetVisits={() => {}}
         baseUrl={''}
+        settings={Mock.all<Settings>()}
       />,
     );