Software localization
A Human-Friendly Way to Display Dates in TypeScript/JavaScript
User Experience (UX) can often mean the difference between a good product and a great one. In the increasingly competitive digital landscape, we need to always put our products' users first to ensure that we have an edge in the market. This often means going beyond features that our users need, and facilitating pleasurable experiences our users want. One way we can achieve this is by showing data in a natural way.
When it comes to dates, we humanoids often think of chronological events happening "a few minutes ago" or "on Tuesday", as opposed to "on 27 June 2019 3:51 pm". Social media feeds and text messaging applications are good examples of the former, human-friendly approach to displaying dates. Why don't we take a page from the socials when it's appropriate and reduce the cognitive friction of our web app users by displaying natural date strings?
"Because it's too hard", I hear you say. Well, it's not trivial. But the core human-friendly date formatting algorithm is simpler than you may think. Let's build a little app to demonstrate. Our demo is of a very simple social media feed, with posts by friends sorted in reverse-chronological order. Here's a look at what we're building:
Human-friendly Dates by Mohammad Ashour (@mashour)
on CodePen.
Notice that we can view the feed in English and Arabic (it's localized), and that posts are showing dates relative to the moment the page was loaded.
More human than human
Rendering the Feed
Let's take a quick look at the code that's rendering our feed.
<div class="container mt-4 mb-2" style="max-width: 600px"> <h2>Human Feed</h2> <p class="lead">This is a demo of human-friendly dates <!-- ... --></p> <div class="mx-auto text-center"> <p class="lead mb-1">View in</p> <div class="btn-group mb-2" id="localeControls"> <button id="localeControl-en" class="btn btn-sm btn-secondary active" > English </button> <button id="localeControl-ar" class="btn btn-sm btn-secondary" > عربي </button> </div> </div> <h3 id="feedHeader">What's New</h3> <ul class="list-group pr-0" id="feed"> </ul> </div>
That's the HTML: nothing too crazy so far. We have some static intro markup, followed by two buttons that make up our locale-switcher. These are followed by an empty feed container that we'll fill with posts from JavaScript.
💡FYI » The CSS classes you're seeing are Bootstrap classes.
Now let's view the shape of the mock data we're rendering.
/** * All dates are displayed relative to this */ const baseTime: number = Date.now(); /** * A post in our data set */ interface Post { name: string; photo: string; post: string; posted_at: Date; } const MILLISECONDS_TO_SECONDS: number = 0.001; /** * Mock English data for our demo */ const en_data: Post[] = [ { "name": "Fawzi Abdulrahman", "photo": "https://tinyfac.es/data/avatars/475605E3-69C5-4D2B-8727-61B7BB8C4699-500w.jpeg", "post": "Gun hornswaggle furl long clothes hands spike hardtack plunder log mizzen. Landlubber or just lubber chandler long clothes jib holystone yard warp lateen sail Cat o'nine tails fore.", // now "posted_at": new Date(baseTime), }, { "name": "June Cha", "photo": "https://tinyfac.es/data/avatars/8B510E03-96BA-43F0-A85A-F38BB3005AF1-500w.jpeg", "post": "Halvah fruitcake donut brownie chocolate bear claw. Muffin biscuit tootsie roll candy. Cheesecake jelly-o lemon drops sweet chocolate bar marshmallow marshmallow. Cotton candy donut cheesecake sweet.", // 1 second ago "posted_at": new Date(baseTime - 1 / MILLISECONDS_TO_SECONDS) }, // ... ]; /** * Mock Arabic data for our demo */ const ar_data: Post[] = [ { "name": "فوزي عبدالرحمان", "photo": "https://tinyfac.es/data/avatars/475605E3-69C5-4D2B-8727-61B7BB8C4699-500w.jpeg", "post": "بندقية hornswaggle furl ملابس طويلة الأيدي سبايك hardtack نهب سجل mizzen. Landlubber أو مجرد lubber chandler ملابس طويلة jib holystone yard warp lateen الشراع Cat o'nine ذيول الصدارة.", "posted_at": new Date(baseTime), }, { "name": "جون شا", "photo": "https://tinyfac.es/data/avatars/8B510E03-96BA-43F0-A85A-F38BB3005AF1-500w.jpeg", "post": "الحلاوة فواكه الكعك دونات براوني الشوكولاته الدب مخلب. الكعك بسكويت tootsie لفة الحلوى. تشيز كيك جيلي-ليمون يسقط حلوى الشوكولاته بار الخطمي الخطمي. حلوى القطن دونات تشيز كيك حلوة", "posted_at": new Date(baseTime - 1 / MILLISECONDS_TO_SECONDS) }, // ... ];
We'll switch between English and Arabic depending on the language selected in our locale-switcher. Important to note is the posted_at
values in the Post
objects. These Date
s are generated specifically to ensure a reverse-chronological sorting of posts going back in time from the moment the script was run. But, of course, they're just plain old JavaScript Dates
, so we'll need to do a bit of work to get them looking like "just now" and "yesterday". First, let's take a quick look at how a Post
is rendered.
/** * Supported locales */ type Locale = "en" | "ar"; /** * Determines localized UI elements and data shown */ let currentLocale: Locale = "en"; /** * Retrieve HTML for a single post, humanizing the post's date */ function renderPost(post: Post): string { const avatarMargin = currentLocale === "en" ? "mr-3" : "ml-3"; return ( `<li class="list-group-item"> <div class="d-flex"> <div class="${avatarMargin} rounded-circle" style="width: 4rem; height: 4rem; overflow: hidden;" > <img src="${post.photo}" style="object-fit: cover; object-position: center; min-height: 100%; width: 100%;" /> </div> <div style="flex: 1;"> <h4 class="h6 d-flex justify-content-between align-items-baseline"> <span>${post.name} ${__("posted")}</span> <span class="small">${humanFriendlyDate(post.posted_at)}</span> </h4> <p style="text-align: ${currentLocale === "en" ? "left" : "right"}"> ${post.post} </p> </div> </div> </li>` ); }
The renderPost
function is what generates the HTML for each post in the feed. It's mostly straight-forward. We have some logic to handle text directionality since we're supporting Arabic, a right-to-left language. We're also making use of a function called humanFriendlyDate
to display the posted_at
dates. This is where the magic happens.
Achieving Localized Human-friendly Dates in TypeScript/JavaScript
Let's dive into the function that formats a Date
as a human-friendly string, humandFriendlyDate
, to see how it ticks.
// Some configrable constants we need to do our work: /** * Past this number of days we'll no longer display the * day of the week and instead we'll display the date * with the month */ const DATE_WITH_MONTH_THRESHOLD_IN_DAYS: number = 6; /** * Past this number of seconds it's now longer "now" when * we're displaying dates */ const NOW_THRESHOLD_IN_SECONDS: number = 10; /** * Past this number of hours we'll no longer display "hours * ago" and instead we'll display "today" */ const TODAY_AT_THRESHOLD_IN_HOURS: number = 12; // The actual work starts here: /** * Retrieve a human-friendly date string relative to now and in the * current locale e.g. "two minutes ago" */ function humanFriendlyDate(date: Date): string { const unixTimestamp: number = millisecondsToSeconds(date.valueOf()); const now: number = millisecondsToSeconds(Date.now()); const diffComponents: DateTimeComponents = getDateTimeComponents(now - unixTimestamp); const { years, months, days, hours, minutes, seconds } = diffComponents; if (years > 0) { return formatLocalizedDateWithOrdinal(currentLocale, date, { includeYear: true }); } if (months > 0 || days > DATE_WITH_MONTH_THRESHOLD_IN_DAYS) { return formatLocalizedDateWithOrdinal(currentLocale, date, { includeYear: false }); } if (days > 1) { return date.toLocaleDateString(currentLocale, { weekday: "long" }); } if (days === 1) { return __("yesterday"); } if (hours > TODAY_AT_THRESHOLD_IN_HOURS) { return __("today at") + " " + date.toLocaleTimeString(currentLocale, { hour: "numeric", minute: "2-digit" }); } if (hours > 0) { return _p("hours ago", hours); } if (minutes > 0) { return _p("minutes ago", minutes); } if (seconds > NOW_THRESHOLD_IN_SECONDS) { return _p("seconds ago", seconds); } return __("just now"); }
The first thing we do is convert the given date to a Unix timestamp, using Date.prototype.valueOf()
, as well as get the current date & time as a Unix timestamp from Date.now()
: this makes our math quite easy later. We then convert our timestamps from milliseconds to seconds, since we don't need to worry about anything smaller than a second (we're only interested in human time). Our millisecond-to-second conversion is done via a trivial utility function.
const MILLISECONDS_TO_SECONDS: number = 0.001; /** * Convert milliseconds to seconds */ function millisecondsToSeconds(milliseconds: number): number { return Math.floor(milliseconds * MILLISECONDS_TO_SECONDS); }
Breaking our Date Difference into Components
We now have a simple integer representation of our dates, which makes it easy to calculate the difference between the current time and the given date. While we're at it, we also break up this difference into meaningful parts ie. years, months, days, etc. The break up happens using a helper getDateTimeComponents
function.
function humanFriendlyDate(date: Date): string { const unixTimestamp: number = millisecondsToSeconds(date.valueOf()); const now: number = millisecondsToSeconds(Date.now()); const diffComponents: DateTimeComponents = getDateTimeComponents(now - unixTimestamp); // ... } /** * Representation of a date & time in components */ interface DateTimeComponents { years: number; months: number; days: number; hours: number; minutes: number; seconds: number; } const MILLISECONDS_TO_SECONDS: number = 0.001; const SECONDS_IN_YEAR: number = 31557600; const SECONDS_IN_MONTH: number = 2629800; const SECONDS_IN_DAY: number = 86400; const SECONDS_IN_HOUR: number = 3600; const SECONDS_IN_MINUTE: number = 60; /** * Break up a unix timestamp into its date & time components */ function getDateTimeComponents(timestamp: number): DateTimeComponents { const components: DateTimeComponents = { years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0, }; let remaining: number = timestamp; // years components.years = Math.floor(remaining / SECONDS_IN_YEAR); remaining -= components.years * SECONDS_IN_YEAR; // months components.months = Math.floor(remaining / SECONDS_IN_MONTH); remaining -= components.months * SECONDS_IN_MONTH; // days components.days = Math.floor(remaining / SECONDS_IN_DAY); remaining -= components.days * SECONDS_IN_DAY; // hours components.hours = Math.floor(remaining / SECONDS_IN_HOUR); remaining -= components.hours * SECONDS_IN_HOUR; // minutes components.minutes = Math.floor(remaining / SECONDS_IN_MINUTE); remaining -= components.minutes * SECONDS_IN_MINUTE; // seconds components.seconds = remaining; return components; }
In getDateTimeComponents
, given our timestamp
in seconds, we start by seeing how many whole years are in it. We set the number of whole years to our components.years
field. We then take the remaining
number of seconds, and check how many whole months are in it. We keep going down this way until we reach seconds, at which point we've broken up our date difference into all needed component parts.
From Years to Seconds: Working Down in Granularity
Let's return to our humanFriendlyDate
function, where we actually use our date difference component parts.
function humanFriendlyDate(date: Date): string { const unixTimestamp: number = millisecondsToSeconds(date.valueOf()); const now: number = millisecondsToSeconds(Date.now()); const diffComponents: DateTimeComponents = getDateTimeComponents(now - unixTimestamp); const { years, months, days, hours, minutes, seconds } = diffComponents; if (years > 0) { return formatLocalizedDateWithOrdinal(currentLocale, date, { includeYear: true }); } if (months > 0 || days > DATE_WITH_MONTH_THRESHOLD_IN_DAYS) { return formatLocalizedDateWithOrdinal(currentLocale, date, { includeYear: false }); } // ... }
Years Ago
After we destructure our parts into separate variables—years
, months
, days
, etc.—we start at the highest level of granularity: years
. If the date
we've been given is years ago, we want to display the full date to avoid confusion: something along the lines of "June 28th, 2018."
We accomplish this formatting using a helper, formatLocalizedDateWithOrdinal
function. This function checks to see whether the current locale is English, and formats the full date with the ordinal day of the month if it is. If the locale is not English, formatLocalizedDateWithOrdinal
formats the date without the ordinal.
💡FYI » "Ordinal" is a fancy of way of saying a number that designates its place in an ordering e.g. "1st" is the ordinal for 1, and "2nd" is the ordinal for 2.
/** * Options when formatting a date */ interface DateFormatOptions { includeYear?: Boolean; } /** * For English, format a date with given options, adding an ordinal * e.g. "May 1st, 1992" (note the "1st"). For non-English locales, * format a date with given options (and no ordinal); */ function formatLocalizedDateWithOrdinal(locale: Locale, date: Date, options: DateFormatOptions = { includeYear: false }) { if (locale.toLowerCase().startsWith("en")) { return formatEnglishDateWithOrdinal(date, options); } return formatNonEnglishDate(locale, date, options); } /** * Format an English date with it ordinal e.g. "May 1st, 1992" */ function formatEnglishDateWithOrdinal(date: Date, { includeYear }: DateFormatOptions): string { const month: string = date.toLocaleDateString("en", { month: "long" }); const day: string = getOrdinal(date.getDate()); let formatted: string = `${month} ${day}`; if (includeYear) { formatted += `, ${date.getFullYear()}`; } return formatted; } /** * Format a non-English date */ function formatNonEnglishDate(locale: Locale, date: Date, { includeYear }: DateFormatOptions): string { const options: Intl.DateTimeFormatOptions = { day: "numeric", month: "long" }; if (includeYear) { options.year = "numeric"; } return date.toLocaleDateString(locale, options); } /** * Retrieve an English ordinal for a number, e.g. "2nd" for 2 */ function getOrdinal(n: number): string { // From https://community.shopify.com/c/Shopify-Design/Ordinal-Number-in-javascript-1st-2nd-3rd-4th/m-p/72156 var s=["th","st","nd","rd"], v=n%100; return n+(s[(v-20)%10]||s[v]||s[0]);
📖 Go Deeper » We're using the built-in
Date.prototype.toLocaleDateString
to get JavaScript to format our localized date strings for us. You can read more about this function on the MDN docs.toLocaleDateString
ties into the JavaScript localization API, Intl.
Months Ago & Beyond a Week Ago
You may have noticed that we're providing the option to format the date with or without the year in formatLocalizedDateWithOrdinal
. This is because when the date given to humanFriendlyDate
is months ago, or beyond a week ago, we want to format the full date without the year. If the date is beyond a week ago, it would generally be confusing to display something like "two Tuesdays ago." Showing the day of the month and the month itself, e.g. "August 24th", makes things much clearer. Since the date is within the current year, we can omit the year part of the date. So we use formatLocalizedDateWithOrdinal
, providing the includeYear
option as false
in this case.
/** * Past this number of days we'll no longer display the * day of the week and instead we'll display the date * with the month */ const DATE_WITH_MONTH_THRESHOLD_IN_DAYS: number = 6; function humanFriendlyDate(date: Date): string { // ... if (years > 0) { return formatLocalizedDateWithOrdinal(currentLocale, date, { includeYear: true }); } if (months > 0 || days > DATE_WITH_MONTH_THRESHOLD_IN_DAYS) { return formatLocalizedDateWithOrdinal(currentLocale, date, { includeYear: false }); } if (days > 1) { return date.toLocaleDateString(currentLocale, { weekday: "long" }); } if (days === 1) { return __("yesterday"); } // ... }
Days Ago
Note the days
code above. If the date
given to humanFriendlyDate
is yesterday, we simply display the word "yesterday", translated to the current locale. Before that, we check whether the date
is older than yesterday and within 7 days ago. If it is we display the day of the week, e.g. "Tuesday".
A Quick Note on General String Internationalization
We're using __()
and _p()
functions to display localized UI strings, like "yesterday", "just now", and more. __()
works with simple strings, and _p()
handles phrases that have pluralization. These two functions are part of a simple, custom i18n library that we added to this demo to facilitate our work. General i18n is a bit out of the scope of this article, however. If you'd like to see how the i18n library is implemented, you can see the full code of this demo on Codepen. And if you want to dive deeper into custom JavaScript/TypeScript i18n, or selecting an existing i18n library, check out one of our guides here:
- The Ultimate Guide to JavaScript Localization
- Roll Your Own JavaScript i18n Library with TypeScript – Part 1
- Roll Your Own JavaScript i18n Library with TypeScript – Part 2
- The Best JavaScript I18n Libraries
Hours Ago
Let's take a look at how we format a date
given to humanFriendlyDate
that is only hours old.
/** * Past this number of hours we'll no longer display "hours * ago" and instead we'll display "today" */ const TODAY_AT_THRESHOLD_IN_HOURS: number = 12; function humanFriendlyDate(date: Date): string { // ... if (days === 1) { return __("yesterday"); } if (hours > TODAY_AT_THRESHOLD_IN_HOURS) { return __("today at") + " " + date.toLocaleTimeString(currentLocale, { hour: "numeric", minute: "2-digit" }); } if (hours > 0) { return _p("hours ago", hours); } // ... }
If the date
is less than 12 hours old, we just display something like "3 hours ago", translated into the current locale of course. If, however—and we check for this first—our date is more than 12 hours old (but less than a day old), we display something more like "today at 9:22 AM". Where we draw the line between the two-hour formats is largely an aesthetic choice and is configurable via the TODAY_AT_THRESHOLD_IN_HOURS
constant.
📖 Go Deeper » To format the "today at X:XX AM" string, we're using JavaScript's
Date.prototype.toLocaleTimeString
function. You can read more about that function on the MDN docs.
Minutes & Seconds Ago
If humanFriendlyDate
gets a date
that is only minutes old, we just display something akin to "2 minutes ago", again translated into the current locale.
/** * Past this number of seconds it's now longer "now" when * we're displaying dates */ const NOW_THRESHOLD_IN_SECONDS: number = 10; function humanFriendlyDate(date: Date): string { // ... if (hours > 0) { return _p("hours ago", hours); } if (minutes > 0) { return _p("minutes ago", minutes); } if (seconds > NOW_THRESHOLD_IN_SECONDS) { return _p("seconds ago", seconds); } return __("just now"); }
When the given date
is only seconds old, we could consider displaying something like "now" to our users. First, however, we need to define "now", since from a human perspective "now" is generally a bit fluid. In our case, we define "now" as anything no older than 10 seconds (and we keep this number configurable).
When date
is seconds old and older than "now", we show the user something like "4 seconds ago." Otherwise, our user sees "just now". As usual, both these strings are localized.
When all is said and done, we get something that works like the following.
Human-friendly Dates by Mohammad Ashour (@mashour)
on CodePen.
Peacing Out
That's our demo pretty well done. If you're building an internationalized app, take a look at Phrase for a professional localization solution. With ever-growing integrations, a powerful translation web admin console, and a focus on both developers and translators, Phrase can really simplify your team's localization workflow. Take a look at all of Phrase's features, and sign up for a free 14-day trial.
That should cover it for our walk down human-readable memory lane. We hope you picked up some info here that will help your websites and apps better delight your users. Happy coding 🙂