📓 4.4.2.2 Adding Wait Time to the Queue
In this lesson we're going to work with the Firestore server timestamp to add a wait time to our Help Queue project. For this refactor, we'll need to complete a few steps:
- We'll use a new function called
serverTimestamp()
to generate a timestamp when a ticket is initially created. This will be the exact time when the ticket is added to our database. - Then, we'll use this timestamp to generate a formatted wait time using the
date-fns
library. - Finally, we'll set up a
useEffect()
hook with asetInterval()
function that updates the formatted wait time for all tickets every minute.
We'll also learn how to use a Firestore timestamp to order our tickets from oldest to newest. Why bother? Well, we're making use of timestamps for another scenario: preserving the creation order for every document in a collection.
Previously we learned that the auto-generated IDs from Firestore are always random and they do not include any reference as to the order in which each document was created. However, Firestore still orders the documents in a collection alphabetically by its identifier, with numbers taking precedence over letters. This means that the order in which documents appear in the database and our website is subject to change anytime a new document is added. That's no good. So, we'll solve that issue in this lesson, and we'll do so with the help of server timestamps.
Let's get into this refactor!
Adding a Server Timestamp at Ticket Creation
The first thing we'll do in this refactor is create a server timestamp when a ticket is first created. Later on, we'll use this value to calculate a formatted time that shows how long a ticket has been open.
To add a Firestore server timestamp to new tickets, we'll need to update NewTicketForm.js
. Here's the new code:
...
// new import!
import { serverTimestamp } from "firebase/firestore";
function NewTicketForm(props){
function handleNewTicketFormSubmission(event) {
event.preventDefault();
props.onNewTicketCreation({
names: event.target.names.value,
location: event.target.location.value,
issue: event.target.issue.value,
// new property!
timeOpen: serverTimestamp()
});
}
return (
...
);
}
NewTicketForm.propTypes = {
onNewTicketCreation: PropTypes.func
};
export default NewTicketForm;
We've added a new import to get access to the serverTimestamp()
function from firebase/firestore
. Then, we've added a new timeOpen
property that's set to the serverTimestamp()
:
timeOpen: serverTimestamp()
As you may guess, the serverTimestamp()
function returns a new timestamp corresponding to when the ticket gets added as a document in the database.
Adding a Formatted Wait Time
The next step is to add a formatted wait time that displays how long a ticket has been open. Our next refactor will start in TicketControl.js
, and then move to TicketList.js
and Ticket.js
.
We'll start by installing the date-fns
library, a popular JavaScript library for working with dates and time. We can use date-fns to manipulate and parse time, which is exactly what we'll use it for.
In the root directory of your Help Queue project, run the following command:
$ npm install date-fns@2
We'll be using the same formatDistanceToNow()
helper function as we did in the last course section "React with Redux". The documentation for date-fns is extensive, and there are many other helper functions available. We recommend checking it out when you have the time — there are many use cases where it can add valuable functionality to an application.
To start, we'll import formatDistanceToNow
at the top of TicketControl.js
:
import { formatDistanceToNow } from 'date-fns';
This is how we'll use the formatDistanceToNow
helper function:
formatDistanceToNow(new Date());
This time, we're not going to include the options object that will add "ago" to the end of the formatted time, like "7 minutes ago". You can add it if you like. This is what the syntax looks like:
formatDistanceToNow(new Date(), {
addSuffix: true
});
Since formatDistanceToNow()
takes a JavaScript date object as its first argument, we'll need to translate the Firestore server timestamp into a JavaScript date. Let's do that next:
...
import { formatDistanceToNow } from 'date-fns';
function TicketControl() {
...
useEffect(() => {
const unSubscribe = onSnapshot(
collection(db, "tickets"),
(querySnapshot) => {
const tickets = [];
querySnapshot.forEach((doc) => {
// new code below!
const timeOpen = doc.get('timeOpen', {serverTimestamps: "estimate"}).toDate();
const jsDate = new Date(timeOpen);
tickets.push({
names: doc.data().names,
location: doc.data().location,
issue: doc.data().issue,
// new code below!
timeOpen: jsDate,
formattedWaitTime: formatDistanceToNow(jsDate),
id: doc.id
});
});
setMainTicketList(tickets);
},
(error) => {
setError(error.message);
}
);
return () => unSubscribe();
}, []);
...
...
}
export default TicketControl;
Let's break down this new code. First we go through the process of turning the Firestore server timestamp into a JS Date object:
const timeOpen = doc.get('timeOpen', {serverTimestamps: "estimate"}).toDate();
const jsDate = new Date(timeOpen);
The code doc.get('timeOpen', {serverTimestamps: "estimate"})
gets the value of the timeOpen
field for the current document; this value is a Firestore Timestamp
object. Then we call the Timestamp.toDate()
method on the server timestamp to turn it into data that's formatted for JavaScript.
Then we pass in the timeOpen
variable — a Firestore timestamp that's been transformed into data that's formatted for JavaScript — and pass it into the JavaScript Date constructor: new Date(timeOpen)
.
We then add two new properties to our ticket object:
tickets.push({
...
timeOpen: jsDate,
formattedWaitTime: formatDistanceToNow(jsDate),
...
});
The timeOpen
property is set to the jsDate
variable, and the formattedWaitTime
property is set to a time that's formatted by date-fns
.
Why not leave timeOpen
as a server Timestamp? We'll use the timeOpen
property later to update the formattedWaitTime
every minute. Let's do that part next!
Adding a Side Effect: Updating formattedWaitTime
Every Minute
To update the formattedWaitTime
property every minute, we'll need to make use of the JavaScript setInterval()
function and React's useEffect()
hook.
Here's the new code:
...
import { formatDistanceToNow } from 'date-fns';
function TicketControl() {
const [formVisibleOnPage, setFormVisibleOnPage] = useState(false);
const [mainTicketList, setMainTicketList] = useState([]);
...
useEffect(() => {
function updateTicketElapsedWaitTime() {
const newMainTicketList = mainTicketList.map(ticket => {
const newFormattedWaitTime = formatDistanceToNow(ticket.timeOpen);
return {...ticket, formattedWaitTime: newFormattedWaitTime};
});
setMainTicketList(newMainTicketList);
}
const waitTimeUpdateTimer = setInterval(() =>
updateTicketElapsedWaitTime(),
60000
);
return function cleanup() {
clearInterval(waitTimeUpdateTimer);
}
}, [mainTicketList])
...
}
export default TicketControl;
Let's first note that our useEffect()
hook depends on the mainTicketList
state variable, because we look through it within the useEffect()
hook; that's why we've added mainTicketList
to the dependencies array:
useEffect(() => {
// code for side effect
}, [mainTicketList])
This also means that our effect will get called anytime the mainTicketList
state variable updates.
Next, let's take a look at the effect itself, all of the code inside of the callback function that we pass as the first argument to the useEffect()
hook:
() => {
function updateTicketWaitTime() {
const newMainTicketList = mainTicketList.map(ticket => {
const newFormattedWaitTime = formatDistanceToNow(ticket.timeOpen);
return {...ticket, formattedWaitTime: newFormattedWaitTime};
});
setMainTicketList(newMainTicketList);
}
const waitTimeInterval = setInterval(() =>
updateTicketWaitTime(),
60000
);
return function cleanup() {
clearInterval(waitTimeInterval);
}
}
First we set up a helper function called updateTicketWaitTime()
. This function handles two main tasks:
- Mapping through the
mainTicketList
state variable to update theformattedWaitTime
for each ticket, and return a new array of updated tickets. - Passing the updated version of the ticket list to
setMainTicketList()
so that our state variable is updated and ourTicketControl
component re-renders with the updated list of tickets.
We then call the updateTicketWaitTime()
when we set up the interval. As a reminder, JavaScript's setInterval()
function takes two arguments: a function that should be run on every interval, and the length of the interval in milliseconds. We've set up our interval to run updateTicketWaitTime()
every minute, which corresponds to 60000
milliseconds.
The setInterval()
function itself returns a reference to itself, which we store in the variable waitTimeInterval
. We can then use this reference to stop/clear the interval. That's exactly what we do with the function that we return from the effect:
() => {
...
return function cleanup() {
clearInterval(waitTimeInterval);
}
}
The function that we return from the effect is the clean up function that will run when the component unmounts, and before every rerun of the effect. What we do is call clearInterval(waitTimeInterval)
, stopping the interval from running. This is an important step, because it prevents the creation of multiple intervals!
Displaying formattedWaitTime
in the Ticket
Component
Next, let's update our Ticket
component to display the formattedWaitTime
. In this case, we'll need to update TicketList.js
to pass in the formattedWaitTime
as a prop to each <Ticket />
component. Let's start there.
Here's the updated code:
import React from "react";
import Ticket from "./Ticket";
import PropTypes from "prop-types";
function TicketList(props){
return (
<React.Fragment>
<hr/>
{props.ticketList.map((ticket) =>
<Ticket
whenTicketClicked={props.onTicketSelection}
names={ticket.names}
location={ticket.location}
// new prop!
formattedWaitTime={ticket.formattedWaitTime}
issue={ticket.issue}
id={ticket.id}
key={ticket.id}/>
)}
</React.Fragment>
);
}
TicketList.propTypes = {
ticketList: PropTypes.array,
onTicketSelection: PropTypes.func
};
export default TicketList;
Next, we'll update Ticket.js
to display the new formattedWaitTime
prop, and add it to our prop-types.
Here's the new code:
import React from "react";
import PropTypes from "prop-types";
function Ticket(props){
return (
<React.Fragment>
<div onClick = {() => props.whenTicketClicked(props.id)}>
<h3>{props.location} - {props.names}</h3>
<p><em>{props.issue}</em></p>
{/* new code below! */}
<p><em>{props.formattedWaitTime}</em></p>
<hr/>
</div>
</React.Fragment>
);
}
Ticket.propTypes = {
names: PropTypes.string,
location: PropTypes.string,
issue: PropTypes.string,
// new code below!
formattedWaitTime: PropTypes.string,
id: PropTypes.string,
whenTicketClicked: PropTypes.func
}
export default Ticket;
At this point, we've completed our refactor. Serve your Help Queue and test out the changes we made; you should now see a formatted time listed that shows how long a ticket has been open.
Before proceeding, delete any old tickets created without a formatted wait time. Doing this will help us avoid errors when we sort the Firestore tickets by their creation date.
Ordering Tickets by Creation Timestamp
Now that we have a timestamp associated with each ticket, let's sort our tickets by their creation date. To do this we need to change our onSnapshot()
Firestore listener in TicketControl
to use a query()
.
Here's the updated code:
...
// Two new imports: query and orderBy
import { collection, addDoc, doc, updateDoc, onSnapshot, deleteDoc, query, orderBy } from "firebase/firestore";
...
function TicketControl() {
...
useEffect(() => {
// new code below!
const queryByTimestamp = query(
collection(db, "tickets"),
orderBy('timeOpen')
);
const unSubscribe = onSnapshot(
// new code below!
queryByTimestamp,
(querySnapshot) => {
const tickets = [];
querySnapshot.forEach((doc) => {
const timeOpen = doc.get('timeOpen', {serverTimestamps: "estimate"}).toDate();
const jsDate = new Date(timeOpen);
tickets.push({
names: doc.data().names,
location: doc.data().location,
issue: doc.data().issue,
timeOpen: jsDate,
formattedWaitTime: formatDistanceToNow(jsDate),
id: doc.id
});
});
setMainTicketList(tickets);
},
(error) => {
setError(error.message);
}
);
return () => unSubscribe();
}, []);
...
...
}
export default TicketControl;
We start by updating our import statement from firebase/firestore
to also import query
and orderby
.
Then, in our effect, we start by constructing a query:
const queryByTimestamp = query(
collection(db, "tickets"),
orderBy('timeOpen')
);
The queryByTimestamp
query gets all documents in the "tickets"
collection and orders them by the value set in each ticket's timeOpen
field. The order of the tickets will be ascending: older tickets will be at the top and newer tickets will be at the bottom.
If we wanted descending order instead, we could specify that by adding a second argument to the orderBy()
function:
const queryByTimestamp = query(
collection(db, "tickets"),
orderBy('timeOpen', 'desc')
);
The only other change we make is updating the first argument in the onSnapshot()
function call to use the queryByTimeStamp
variable, which represents our Firestore query:
useEffect(() => {
const queryByTimestamp = query(
collection(db, "tickets"),
orderBy('timeOpen')
);
const unSubscribe = onSnapshot(
queryByTimestamp, // new code!
...,
...
);
return () => unSubscribe();
}, []);
And that's it! Now our tickets are organized by their creation date.
The best thing about this refactor is that we didn't have to write our own function to sort the Firestore data. That's a big advantage of using Firestore: it's flexible like all NoSQL databases are, but it contains enough structure (in the form of collections and documents) and built-in helper functions to make filtering and sorting data easy and sweat-free!
Next up, let's learn how to host our Help Queue web app with Firebase.