Software localization

A Human-Friendly Way to Display Dates in TypeScript/JavaScript

Descriptives such as "a month ago" tend to be simpler to understand than date and time stamps. Here's a human-friendly way to display dates.
Software localization blog category featured image | Phrase

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:

See the Pen

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.

Demo app with user friendly dates | PhraseMore 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 Dates 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 docstoLocaleDateString 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:

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.

See the Pen

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 🙂