In case you missed the first part, you can find it here. First of all, wow! Thanks for all the feedback and twitter messages! 💝 I never imagined to reach such a wide audience. But enough of that. Let’s get to work 💪.
So, we are building npm-stats.org. A small web application build with Vue.js, vue-chartjs and the npm API, to grab the download statistics of packages and generate charts based on them.
A small recap of what we have build in Part 1:
vue-init
vue-router
vue-chartjs
This is quite a lot. In the end we had our app running. However there is always space for improvement!
Right now our default period is set to last-month
but it would be awesome if we could set the startPeriod and endPeriod. This way we could inspect the statistics for a whole year or more.
For this purpose we need to add two additional input fields and data models. But for a better user experience we will pull in an external datepicker component. We don’t have to reinvent the wheel right? 👨🔬 And to format our date properly we will also pull in moment.js
Datepicker
📆 Install dependencies
yarn add vuejs-datepicker moment
Todos for our Start.vue:
Import the datepicker
Add two datepicker fields for the start and end period
Add two data models
// Start.vue<template>...<datepicker placeholder="Start Date" v-model="periodStart" name="start-date"></datepicker><datepicker placeholder="Start Date" v-model="periodStart" name="start-date"></datepicker>...</template><script> import axios from 'axios' import Datepicker from 'vuejs-datepicker' import LineChart from '@/components/LineChart' export default { components: { LineChart, Datepicker }, data () { return { package: null, packageName: '', loaded: false, downloads: [], labels: [], showError: false, errorMessage: 'Please enter a package name', periodStart: '', periodEnd: new Date() } }, .....} </script>
We also removed our old period
data model which was set to last-month
. As the period will now be composed of periodStart
and periodEnd
. We also set the periodEnd
to the current day. As most of the time you will only change the start date.
We need a format like this: 2017-04-18:2017-04-30
. However if we now select a date we get something like this: 2017-04-17T22:00:00.000Z
. A Date()
with the time attributes, which we don't need. This is a nice job for moment.js
.
For this case we have computed properties which are a pleasure to work with. To keep things a bit cleaner, we will create three properties. A formatted startDate, a formatted endDate and the composed period.
computed: { _endDate () { return moment(this.periodEnd).format('YYYY-MM-DD') }, _startDate () { return moment(this.periodStart).format('YYYY-MM-DD') }, period () { return this.periodStart ? `${this._startDate}:${this._endDate}` : 'last-month' }},
As we want to persist the default behaviour of fetching data of the last month if no start date is set, we add our condition to the period
property.
And thats it! We don’t need to change our request, as we simply replaced the content of period
which we are using in our request. You can check the code on github in this feature branch
Well, the chart with the daily statistics is great. But we can generate more! We have all data we need for that. We will not transform and group our data, so we can pass it to another line chart for yearly statistics. And we will refactor a bit our code.
So, our data we get from the npm api looks like this:
data: [{day: "2017-04-18", downloads: 16280},{day: "2017-04-19", downloads: 14280},{day: "2017-04-20", downloads: 17280}]
But to pass it to our chart we need two arrays, one which the labels (day) and one with the data (downloads). For the daily statistics, it was pretty easy. As we could simply use map()
to get the data and labels. However now we need to do more.
day
key to a year.☝ But first, it is a good time to refactor some bits of our code. We see that step 1 is to format our date to a year. And later if we want monthly statistics we need to format it to a month format and so on. So it is a good time to introduce a helper method and extract the logic from the Start.vue file.
So we create src/utils/dateFormatter.js
which will help us to format our date.
import moment from 'moment' export const dateToYear = date => moment(date).format('YYYY')export const dateToMonth = date => moment(date).format('MMM YYYY')export const dateToWeek = date => moment(date).format('GGGG-[W]WW')export const dateToDay = date => moment(date).format('YYYY-MM-DD')export const dateBeautify = date => moment(date).format('Do MMMM YYYY')
And in our Start.vue we can now remove the moment
import and import our helper modules and replace the moment statements with them. (In our _startPeriod
and _endPeriod
. And we add a new computed property to which will replace the period
in our chart container.
import { dateToYear, dateToDay, dateBeautify } from '../utils/dateFormatter' computed: { _endDate () { return dateToDay(this.periodEnd) }, _startDate () { return dateToDay(this.periodStart) }, period () { return this.periodStart ? `${this._startDate}:${this._endDate}` : 'last-month' }, formattedPeriod () { return this.periodStart ? `${dateBeautify(this._startDate)} - ${dateBeautify(this._endDate)}` : 'last-month' } },
Chart with formattedPeriod in title
Now we create three new data models rawData
which will hold our downloads data we get from the api call, downloadsYear: []
and labelsYear: []
. And we create a new method called formatYear()
.
In our axios request promise we then assign the data.downloads to rawData
and call formatYear()
.
axios.get(`https://api.npmjs.org/downloads/range/${this.period}/${this.package}`) .then(response => { this.rawData = response.data.downloads // 🆕 this.downloads = response.data.downloads.map(entry => entry.downloads) this.labels = response.data.downloads.map(entry => entry.day) this.packageName = response.data.package this.formatYear() // 🆕 this.setURL() this.loaded = true })
Now we will create two additional helper methods. Because we want different data transformations in the future. Like weekly stats and monthly. And to keep our component clean we extract those into a separate file.
So we create our src/utils/downloadFormatter.js
which will contain two methods:
In our Start.vue we import both modules and now we can use them in our formatYear()
method.
formatYear () { this.labelsYear = this.rawData .map(entry => dateToYear(entry.day)) .reduce(removeDuplicate, []) this.downloadsYear = getDownloadsPerYear(this.rawData)},
Now thats a bit tricky now. Normally I write down my methods and clean then up later. However I hope you can follow me here.
First we need to get the years, right? So we use again map()
to get the day
key like we did in the request for our daily statistics. But now we format it with our dateToYear()
helper. So now our data will look like this:
data: [{day: "2017", downloads: 16280},{day: "2017", downloads: 14280},{day: "2017", downloads: 17280}]
And now we will use reduce()
to remove the duplicated years. Because our labels array will have only unique years. And as we may use this more then once, we extracted it into our downloadFormatter.js
In our downloadFormatter.js we now finish our removeDuplicate function.
export function removeDuplicate (a, b) { if (a.indexOf(b) < 0) { a.push(b) } return a }
Now our this.labelYear
array will contain only unique years. And it's time to group and transform our data. For this we will again use map()
and reduce()
and filter()
.
The rawData
looks like this:
data: [{day: "2017-04-18", downloads: 16280},{day: "2017-04-19", downloads: 14280},{day: "2017-04-20", downloads: 17280}]
So first we need to find the unique dates again. Which we do with reduce()
then we chain a map()
to it and return an object with the date and the downloads which are in that date. Thats why we use filter()
there and our dateToYear()
helper.
However our data will look now like this:
First reduce
But as we don’t need the date now, we map
it to only the downloads and then use reduce()
to sum it up. Now our object will contain the sum of all downloads of the year. However it is nested and we still have the date key.
After map and second reduce
But a last map()
will fix this.
export const getDownloadsPerYear = (data) => { // Find unique dates return data.reduce((date, current) => { if (date.indexOf(dateToYear(current.day)) < 0) { date.push(dateToYear(current.day)) } return date }, []) .map((date) => { return { date: date, downloads: data.filter(el => dateToYear(el.day) === date) .map(el => el.downloads) .reduce((total, download) => total + download) } }) .map(element => element.downloads) }
Now we have our transformation done and can copy the template for our first chart and pass in the downloadsYear
props.
Downloads per Year Chart
You can check the source up to this point at this feature branch
As we now finished our data transformer it is pretty easy to add new charts. Like weekly statistics and monthly. We could duplicate our getDownloadsPerYear()
method and change our dateToYear()
helper to dateToMonth()
. But, this is not very DRY. Because we end up with multiple methods which are doing pretty much the same.
So let’s refactor our specific getDownloadsPerYear()
method to a more general one. The only thing, that would change it our dateFormatter helper. So we should make it an argument you can pass to our method.
Rename our method to something more general like groupData
Add a second argument which will be our helper function
???
Profit!
export const groupData = (data, dateFormatter) => { return data.reduce((date, current) => { if (date.indexOf(dateFormatter(current.day)) < 0) { date.push(dateFormatter(current.day)) } return date }, []) .map((date) => { return { date: date, downloads: data.filter(el => dateFormatter(el.day) === date) .map(el => el.downloads) .reduce((total, download) => total + download) } }) .map(element => element.downloads)}
In our Start.vue we now need to pass our helper function.
this.downloadsYear = groupData(this.rawData, dateToYear)
Now we can do this for our monthly and weekly data too, with only swapping out the dateToYear
You can find the source up to this point in this feature branch with some additions like a loading indicator and a new fetch of the data if the datepicker value changed, which I did not covered here.
I hope you enjoyed it and learned something. Feel free to leave me feedback! ✌