From 252edaa2ca9b0377254a11daa1d94e4b689b8edd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Mar 2019 18:14:45 +0100 Subject: [PATCH 1/5] Improved performance while calculating status by doing one iteration only and memoizing the result when possible --- package.json | 2 +- src/visits/ShortUrlVisits.js | 42 +++---- src/visits/VisitsHeader.js | 2 +- src/visits/services/VisitsParser.js | 127 ++++++++++------------ test/visits/ShortUrlVisits.test.js | 13 +-- test/visits/VisitsHeader.test.js | 2 +- test/visits/services/VisitsParser.test.js | 50 ++++----- yarn.lock | 7 +- 8 files changed, 118 insertions(+), 127 deletions(-) diff --git a/package.json b/package.json index 2720942b..6b7a4245 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "promise": "^8.0.1", "prop-types": "^15.6.2", "qs": "^6.5.2", - "ramda": "^0.25.0", + "ramda": "^0.26.1", "react": "^16.7.0", "react-autosuggest": "^9.4.0", "react-chartjs-2": "^2.7.4", diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index d3644404..cb064ee2 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -8,20 +8,13 @@ import DateInput from '../utils/DateInput'; import MutedMessage from '../utils/MuttedMessage'; import SortableBarGraph from './SortableBarGraph'; import { shortUrlVisitsType } from './reducers/shortUrlVisits'; -import { VisitsHeader } from './VisitsHeader'; +import VisitsHeader from './VisitsHeader'; import GraphCard from './GraphCard'; import { shortUrlDetailType } from './reducers/shortUrlDetail'; import './ShortUrlVisits.scss'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; -const ShortUrlVisits = ({ - processOsStats, - processBrowserStats, - processCountriesStats, - processCitiesStats, - processReferrersStats, - processCitiesStatsForMap, -}) => class ShortUrlVisits extends React.Component { +const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits extends React.PureComponent { static propTypes = { match: PropTypes.shape({ params: PropTypes.object, @@ -35,18 +28,25 @@ const ShortUrlVisits = ({ state = { startDate: undefined, endDate: undefined }; loadVisits = () => { const { match: { params }, getShortUrlVisits } = this.props; - - getShortUrlVisits(params.shortCode, mapObjIndexed( + const { shortCode } = params; + const dates = mapObjIndexed( (value) => value && value.format ? value.format('YYYY-MM-DD') : value, this.state - )); + ); + const { startDate, endDate } = dates; + + // While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs + this.memoizationId = `${new Date().getTime()}_${shortCode}_${startDate}_${endDate}`; + + getShortUrlVisits(shortCode, dates); }; componentDidMount() { const { match: { params }, getShortUrlDetail } = this.props; + const { shortCode } = params; this.loadVisits(); - getShortUrlDetail(params.shortCode); + getShortUrlDetail(shortCode); } render() { @@ -71,17 +71,21 @@ const ShortUrlVisits = ({ return There are no visits matching current filter :(; } + const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits( + { id: this.memoizationId, visits } + ); + return (
- +
- +
( ), ]} diff --git a/src/visits/VisitsHeader.js b/src/visits/VisitsHeader.js index 3103c2a4..34bb0288 100644 --- a/src/visits/VisitsHeader.js +++ b/src/visits/VisitsHeader.js @@ -11,7 +11,7 @@ const propTypes = { shortUrlVisits: shortUrlVisitsType.isRequired, }; -export function VisitsHeader({ shortUrlDetail, shortUrlVisits }) { +export default function VisitsHeader({ shortUrlDetail, shortUrlVisits }) { const { shortUrl, loading } = shortUrlDetail; const { visits } = shortUrlVisits; const shortLink = shortUrl && shortUrl.shortUrl ? shortUrl.shortUrl : ''; diff --git a/src/visits/services/VisitsParser.js b/src/visits/services/VisitsParser.js index 174291d1..76ba477b 100644 --- a/src/visits/services/VisitsParser.js +++ b/src/visits/services/VisitsParser.js @@ -1,4 +1,4 @@ -import { assoc, isNil, isEmpty, reduce } from 'ramda'; +import { isNil, isEmpty, memoizeWith, prop, reduce } from 'ramda'; const osFromUserAgent = (userAgent) => { const lowerUserAgent = userAgent.toLowerCase(); @@ -42,79 +42,70 @@ const extractDomain = (url) => { return domain.split(':')[0]; }; -export const processOsStats = (visits) => - reduce( - (stats, { userAgent }) => { - const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent); - - return assoc(os, (stats[os] || 0) + 1, stats); - }, - {}, - visits, - ); - -export const processBrowserStats = (visits) => - reduce( - (stats, { userAgent }) => { - const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent); - - return assoc(browser, (stats[browser] || 0) + 1, stats); - }, - {}, - visits, - ); - -export const processReferrersStats = (visits) => - reduce( - (stats, visit) => { - const notHasDomain = isNil(visit.referer) || isEmpty(visit.referer); - const domain = notHasDomain ? 'Unknown' : extractDomain(visit.referer); - - return assoc(domain, (stats[domain] || 0) + 1, stats); - }, - {}, - visits, - ); - const visitLocationHasProperty = (visitLocation, propertyName) => !isNil(visitLocation) && !isNil(visitLocation[propertyName]) && !isEmpty(visitLocation[propertyName]); -const buildLocationStatsProcessorByProperty = (propertyName) => (visits) => +const updateOsStatsForVisit = (osStats, { userAgent }) => { + const os = isNil(userAgent) ? 'Others' : osFromUserAgent(userAgent); + + osStats[os] = (osStats[os] || 0) + 1; +}; + +const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => { + const browser = isNil(userAgent) ? 'Others' : browserFromUserAgent(userAgent); + + browsersStats[browser] = (browsersStats[browser] || 0) + 1; +}; + +const updateReferrersStatsForVisit = (referrersStats, { referer }) => { + const notHasDomain = isNil(referer) || isEmpty(referer); + const domain = notHasDomain ? 'Unknown' : extractDomain(referer); + + referrersStats[domain] = (referrersStats[domain] || 0) + 1; +}; + +const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => { + const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName); + const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown'; + + stats[value] = (stats[value] || 0) + 1; +}; + +const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName'); +const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName'); + +const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => { + if (!visitLocationHasProperty(visitLocation, 'cityName')) { + return; + } + + const { cityName, latitude, longitude } = visitLocation; + const currentCity = citiesForMapStats[cityName] || { + cityName, + count: 0, + latLong: [ parseFloat(latitude), parseFloat(longitude) ], + }; + + currentCity.count++; + + citiesForMapStats[cityName] = currentCity; +}; + +export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) => reduce( - (stats, { visitLocation }) => { - const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName); - const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown'; + (stats, visit) => { + // We mutate the original object because it has a big side effect 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); - return assoc(value, (stats[value] || 0) + 1, stats); + return stats; }, - {}, + { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }, visits, - ); - -export const processCountriesStats = buildLocationStatsProcessorByProperty('countryName'); - -export const processCitiesStats = buildLocationStatsProcessorByProperty('cityName'); - -export const processCitiesStatsForMap = (visits) => - reduce( - (stats, { visitLocation }) => { - if (!visitLocationHasProperty(visitLocation, 'cityName')) { - return stats; - } - - const { cityName, latitude, longitude } = visitLocation; - const currentCity = stats[cityName] || { - cityName, - count: 0, - latLong: [ parseFloat(latitude), parseFloat(longitude) ], - }; - - currentCity.count++; - - return assoc(cityName, currentCity, stats); - }, - {}, - visits, - ); + )); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index 843bc68a..a24e8645 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -11,21 +11,16 @@ import SortableBarGraph from '../../src/visits/SortableBarGraph'; describe('', () => { let wrapper; - const statsProcessor = () => ({}); + const processStatsFromVisits = () => ( + { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } + ); const getShortUrlVisitsMock = sinon.spy(); const match = { params: { shortCode: 'abc123' }, }; const createComponent = (shortUrlVisits) => { - const ShortUrlVisits = createShortUrlVisits({ - processBrowserStats: statsProcessor, - processCountriesStats: statsProcessor, - processOsStats: statsProcessor, - processReferrersStats: statsProcessor, - processCitiesStats: statsProcessor, - processCitiesStatsForMap: statsProcessor, - }); + const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }); wrapper = shallow( ', () => { diff --git a/test/visits/services/VisitsParser.test.js b/test/visits/services/VisitsParser.test.js index f5002e7c..c6dfd717 100644 --- a/test/visits/services/VisitsParser.test.js +++ b/test/visits/services/VisitsParser.test.js @@ -1,11 +1,4 @@ -import { - processOsStats, - processBrowserStats, - processReferrersStats, - processCountriesStats, - processCitiesStats, - processCitiesStatsForMap, -} from '../../../src/visits/services/VisitsParser'; +import { processStatsFromVisits } from '../../../src/visits/services/VisitsParser'; describe('VisitsParser', () => { const visits = [ @@ -50,64 +43,71 @@ describe('VisitsParser', () => { }, ]; - describe('processOsStats', () => { + describe('processStatsFromVisits', () => { + let stats; + + beforeAll(() => { + stats = processStatsFromVisits({ id: 'id', visits }); + }); + it('properly parses OS stats', () => { - expect(processOsStats(visits)).toEqual({ + const { os } = stats; + + expect(os).toEqual({ Linux: 3, Windows: 1, MacOS: 1, }); }); - }); - describe('processBrowserStats', () => { it('properly parses browser stats', () => { - expect(processBrowserStats(visits)).toEqual({ + const { browsers } = stats; + + expect(browsers).toEqual({ Firefox: 2, Chrome: 2, Opera: 1, }); }); - }); - describe('processReferrersStats', () => { it('properly parses referrer stats', () => { - expect(processReferrersStats(visits)).toEqual({ + const { referrers } = stats; + + expect(referrers).toEqual({ 'Unknown': 2, 'google.com': 2, 'm.facebook.com': 1, }); }); - }); - describe('processCountriesStats', () => { it('properly parses countries stats', () => { - expect(processCountriesStats(visits)).toEqual({ + const { countries } = stats; + + expect(countries).toEqual({ 'Spain': 3, 'United States': 1, 'Unknown': 1, }); }); - }); - describe('processCitiesStats', () => { it('properly parses cities stats', () => { - expect(processCitiesStats(visits)).toEqual({ + const { cities } = stats; + + expect(cities).toEqual({ 'Zaragoza': 2, 'New York': 1, 'Unknown': 2, }); }); - }); - describe('processCitiesStatsForMap', () => { it('properly parses cities stats with lat and long', () => { + const { citiesForMap } = stats; const zaragozaLat = 123.45; const zaragozaLong = -543.21; const newYorkLat = 1029; const newYorkLong = 6758; - expect(processCitiesStatsForMap(visits)).toEqual({ + expect(citiesForMap).toEqual({ 'Zaragoza': { cityName: 'Zaragoza', count: 2, diff --git a/yarn.lock b/yarn.lock index ef2345f2..cfb4bbea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7910,9 +7910,10 @@ railroad-diagrams@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" -ramda@^0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" +ramda@^0.26.1: + version "0.26.1" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== randexp@0.4.6: version "0.4.6" From 7e27ceb88515118ea5f2a921c20b4004875c7097 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Mar 2019 18:19:50 +0100 Subject: [PATCH 2/5] Ensured same timestamp is used when generating memoization ID after mounting the component --- src/visits/ShortUrlVisits.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index cb064ee2..f7979d19 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -36,8 +36,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte const { startDate, endDate } = dates; // While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calcs - this.memoizationId = `${new Date().getTime()}_${shortCode}_${startDate}_${endDate}`; - + this.memoizationId = `${this.timeWhenMounted}_${shortCode}_${startDate}_${endDate}`; getShortUrlVisits(shortCode, dates); }; @@ -45,6 +44,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte const { match: { params }, getShortUrlDetail } = this.props; const { shortCode } = params; + this.timeWhenMounted = new Date().getTime(); this.loadVisits(); getShortUrlDetail(shortCode); } @@ -137,7 +137,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte placeholderText="Since" isClearable maxDate={this.state.endDate} - onChange={(date) => this.setState({ startDate: date }, () => this.loadVisits())} + onChange={(date) => this.setState({ startDate: date }, this.loadVisits)} />
@@ -147,7 +147,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte placeholderText="Until" isClearable minDate={this.state.startDate} - onChange={(date) => this.setState({ endDate: date }, () => this.loadVisits())} + onChange={(date) => this.setState({ endDate: date }, this.loadVisits)} />
From 1bc406b0d9b23e3f9b541498ad58fa8ba6188991 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Mar 2019 19:21:46 +0100 Subject: [PATCH 3/5] Ensured requests when loading visits are made in parallel for big dataset --- src/visits/ShortUrlVisits.js | 6 ++-- src/visits/reducers/shortUrlVisits.js | 40 ++++++++++++++++++++- test/visits/ShortUrlVisits.test.js | 10 +++++- test/visits/reducers/shortUrlVisits.test.js | 8 +++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index f7979d19..cd14bc22 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -53,10 +53,12 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte const { shortUrlVisits, shortUrlDetail } = this.props; const renderVisitsContent = () => { - const { visits, loading, error } = shortUrlVisits; + const { visits, loading, loadingLarge, error } = shortUrlVisits; if (loading) { - return Loading...; + const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; + + return {message}; } if (error) { diff --git a/src/visits/reducers/shortUrlVisits.js b/src/visits/reducers/shortUrlVisits.js index 32a86f47..3d97d4df 100644 --- a/src/visits/reducers/shortUrlVisits.js +++ b/src/visits/reducers/shortUrlVisits.js @@ -1,9 +1,11 @@ import PropTypes from 'prop-types'; +import { flatten, range, splitEvery } from 'ramda'; /* eslint-disable padding-line-between-statements, newline-after-var */ export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR'; export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; +export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; /* eslint-enable padding-line-between-statements, newline-after-var */ export const shortUrlVisitsType = PropTypes.shape({ @@ -15,6 +17,7 @@ export const shortUrlVisitsType = PropTypes.shape({ const initialState = { visits: [], loading: false, + loadingLarge: false, error: false, }; @@ -24,19 +27,27 @@ export default function reducer(state = initialState, action) { return { ...state, loading: true, + loadingLarge: false, }; case GET_SHORT_URL_VISITS_ERROR: return { ...state, loading: false, + loadingLarge: false, error: true, }; case GET_SHORT_URL_VISITS: return { visits: action.visits, loading: false, + loadingLarge: false, error: false, }; + case GET_SHORT_URL_VISITS_LARGE: + return { + ...state, + loadingLarge: true, + }; default: return state; } @@ -58,9 +69,36 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => return data; } - return data.concat(await loadVisits(page + 1)); + // If there are more pages, make requests in blocks of 4 + const parallelRequestsCount = 4; + const parallelStartingPage = 2; + const pagesRange = range(parallelStartingPage, pagination.pagesCount + 1); + const pagesBlocks = splitEvery(parallelRequestsCount, pagesRange); + + if (pagination.pagesCount - 1 > parallelRequestsCount) { + dispatch({ type: GET_SHORT_URL_VISITS_LARGE }); + } + + return data.concat(await loadPagesBlocks(pagesBlocks)); }; + const loadPagesBlocks = async (pagesBlocks, index = 0) => { + const data = await loadVisitsInParallel(pagesBlocks[index]); + + if (index < pagesBlocks.length - 1) { + return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); + } + + return data; + }; + + const loadVisitsInParallel = (pages) => + Promise.all(pages.map(async (page) => { + const { data } = await getShortUrlVisits(shortCode, { ...dates, page, itemsPerPage }); + + return data; + })).then(flatten); + try { const visits = await loadVisits(); diff --git a/test/visits/ShortUrlVisits.test.js b/test/visits/ShortUrlVisits.test.js index a24e8645..8f792852 100644 --- a/test/visits/ShortUrlVisits.test.js +++ b/test/visits/ShortUrlVisits.test.js @@ -43,7 +43,7 @@ describe('', () => { } }); - it('Renders a preloader when visits are loading', () => { + it('renders a preloader when visits are loading', () => { const wrapper = createComponent({ loading: true }); const loadingMessage = wrapper.find(MutedMessage); @@ -51,6 +51,14 @@ describe('', () => { expect(loadingMessage.html()).toContain('Loading...'); }); + it('renders a warning when loading large amounts of visits', () => { + const wrapper = createComponent({ loading: true, loadingLarge: true }); + const loadingMessage = wrapper.find(MutedMessage); + + expect(loadingMessage).toHaveLength(1); + expect(loadingMessage.html()).toContain('This is going to take a while... :S'); + }); + it('renders an error message when visits could not be loaded', () => { const wrapper = createComponent({ loading: false, error: true }); const errorMessage = wrapper.find(Card); diff --git a/test/visits/reducers/shortUrlVisits.test.js b/test/visits/reducers/shortUrlVisits.test.js index 676490e7..93e8fbe4 100644 --- a/test/visits/reducers/shortUrlVisits.test.js +++ b/test/visits/reducers/shortUrlVisits.test.js @@ -4,6 +4,7 @@ import reducer, { GET_SHORT_URL_VISITS_START, GET_SHORT_URL_VISITS_ERROR, GET_SHORT_URL_VISITS, + GET_SHORT_URL_VISITS_LARGE, } from '../../../src/visits/reducers/shortUrlVisits'; describe('shortUrlVisitsReducer', () => { @@ -15,6 +16,13 @@ describe('shortUrlVisitsReducer', () => { expect(loading).toEqual(true); }); + it('returns loadingLarge on GET_SHORT_URL_VISITS_LARGE', () => { + const state = reducer({ loadingLarge: false }, { type: GET_SHORT_URL_VISITS_LARGE }); + const { loadingLarge } = state; + + expect(loadingLarge).toEqual(true); + }); + it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => { const state = reducer({ loading: true, error: false }, { type: GET_SHORT_URL_VISITS_ERROR }); const { loading, error } = state; From ba5ea7407ba4e58b95c99d262192d59e99b31c15 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Mar 2019 19:28:24 +0100 Subject: [PATCH 4/5] Used native javascript reduce instead of ramda reduce --- src/visits/services/VisitsParser.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/visits/services/VisitsParser.js b/src/visits/services/VisitsParser.js index 76ba477b..8aef797f 100644 --- a/src/visits/services/VisitsParser.js +++ b/src/visits/services/VisitsParser.js @@ -1,4 +1,4 @@ -import { isNil, isEmpty, memoizeWith, prop, reduce } from 'ramda'; +import { isNil, isEmpty, memoizeWith, prop } from 'ramda'; const osFromUserAgent = (userAgent) => { const lowerUserAgent = userAgent.toLowerCase(); @@ -94,7 +94,7 @@ const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => { }; export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) => - reduce( + visits.reduce( (stats, visit) => { // We mutate the original object because it has a big side effect when large data sets are processed updateOsStatsForVisit(stats.os, visit); @@ -106,6 +106,5 @@ export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) => return stats; }, - { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }, - visits, + { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } )); From 2820caf95596fcfb5ff74197f873792a3ce49c1f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Mar 2019 19:29:56 +0100 Subject: [PATCH 5/5] Updated changelog --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3089e92..2b1b1ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] + +#### Added + +* *Nothing* + +#### Changed + +* *Nothing* + +#### Deprecated + +* *Nothing* + +#### Removed + +* *Nothing* + +#### Fixed + +* [#103](https://github.com/shlinkio/shlink-web-client/issues/103) Fixed visits page getting freezed when loading large amounts of visits. + + ## 2.0.1 - 2019-03-03 #### Added