DEV Community

CountMyDays
CountMyDays

Posted on

"Tomorrow" or "in 2 days"? A timezone-safe 'time until' label with Intl.RelativeTimeFormat

Every countdown UI needs a short, friendly label: in 45 minutes, tomorrow, in 3 weeks, za 5 miesięcy. Modern JavaScript has a built-in for the wording, Intl.RelativeTimeFormat, so you no longer need a date library for it. But the API only formats a number and a unit that you pass in. Picking that number is where countdowns go wrong.

Here's the bug that pushed me to write this. It's 23:30 on 24 December in Warsaw, and the event is at midnight on the 26th, local time. Plain millisecond maths says "24.5 hours away → 1 day → tomorrow". The person reading it knows Christmas Day comes first, so for them the event is in 2 days.

This post builds a small helper that gets that right, works in any locale, and is tested in several time zones.

The easy part: wording

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

rtf.format(1, 'day');     // "tomorrow"
rtf.format(-1, 'day');    // "yesterday"
rtf.format(3, 'week');    // "in 3 weeks"
rtf.format(0, 'second');  // "now"

new Intl.RelativeTimeFormat('pl', { numeric: 'auto' }).format(5, 'month');
// "za 5 miesięcy"
Enter fullscreen mode Exit fullscreen mode

numeric: 'auto' turns 1 day into "tomorrow" and 0 seconds into "now". With numeric: 'always' you get "in 1 day". Pluralisation, word order and grammatical case are handled for every locale the runtime supports, which is a lot of work you don't have to do.

The hard part: which number?

The usual approach is:

const days = Math.round((target - now) / 86_400_000);
Enter fullscreen mode Exit fullscreen mode

This has two problems:

  1. It counts 24-hour blocks, not calendar days. People think "how many midnights until then?", not "how many periods of 86,400,000 ms?"
  2. It ignores whose midnight counts. The same instant is 24 December in New York and already 25 December in Auckland.

The fix is to turn both instants into calendar dates in the viewer's time zone, then subtract the dates.

Step 1: a day key in a given time zone

Intl.DateTimeFormat can tell you the calendar date of an instant in any IANA zone. The en-CA locale formats dates as YYYY-MM-DD, but I use formatToParts so the code doesn't depend on that formatting:

export function dayKey(date, timeZone) {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(date);
  const get = (t) => parts.find((p) => p.type === t).value;
  return `${get('year')}-${get('month')}-${get('day')}`;
}
Enter fullscreen mode Exit fullscreen mode

dayKey(new Date('2026-12-24T22:30:00Z'), 'Pacific/Auckland') returns "2026-12-25", because it's already Christmas morning there.

Step 2: subtract dates, not instants

Once you have two YYYY-MM-DD strings, build both as UTC midnights and subtract. UTC has no daylight saving time, so every day is exactly 86,400,000 ms and the division is exact:

export function daysBetweenKeys(fromKey, toKey) {
  const [y1, m1, d1] = fromKey.split('-').map(Number);
  const [y2, m2, d2] = toKey.split('-').map(Number);
  return Math.round(
    (Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86_400_000
  );
}

daysBetweenKeys('2026-03-28', '2026-03-30'); // 2, across the EU clock change
daysBetweenKeys('2026-11-01', '2026-11-02'); // 1, across the US clock change
Enter fullscreen mode Exit fullscreen mode

If you did the same subtraction with local midnights (new Date(2026, 2, 30)), the 23-hour day in March would give you 1.958… days. That rounds to the right answer, but only by luck.

Step 3: put it together

Use clock time for things less than a day away ("in 45 minutes" is more useful than "today"). Use calendar days for everything else, and switch to weeks, months and years as the distance grows:

export function timeUntil(target, now = new Date(), {
  locale = undefined,
  timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
} = {}) {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
  const diffSec = Math.round((target - now) / 1000);
  const abs = Math.abs(diffSec);

  if (abs >= 24 * 3600) {
    const days = daysBetweenKeys(dayKey(now, timeZone), dayKey(target, timeZone));
    if (Math.abs(days) >= 365) return rtf.format(Math.trunc(days / 365), 'year');
    if (Math.abs(days) >= 60)  return rtf.format(Math.trunc(days / 30), 'month');
    if (Math.abs(days) >= 14)  return rtf.format(Math.trunc(days / 7), 'week');
    return rtf.format(days, 'day');
  }
  if (abs >= 3600) return rtf.format(Math.trunc(diffSec / 3600), 'hour');
  if (abs >= 60)   return rtf.format(Math.trunc(diffSec / 60), 'minute');
  return rtf.format(diffSec, 'second');
}
Enter fullscreen mode Exit fullscreen mode

Notes on the choices:

  • Math.trunc, not Math.round, for the coarse units. 13 days should say "in 13 days", not "in 2 weeks", and 59 days shouldn't round up to "in 2 months". Under-promising reads better on a countdown.
  • Months are 30 days. That's fine for a label. If you need "exactly 3 months on the calendar", compare year/month pieces instead, but no one reading "in 5 months" will check.
  • Negative values work for free. The same helper says "4 days ago", which is a "days since" tracker for nothing.
  • timeZone is a parameter. On the server (SSR, or an OG image function) there is no "viewer zone", so pass one explicitly. That might come from a cookie or from the zone the event was created in.

Testing it in three time zones

Same instant (22:30 UTC on 24 December), same targets, three runtimes:

// test.mjs
import assert from 'node:assert/strict';
import { timeUntil, daysBetweenKeys } from './until.mjs';

const tz = process.env.TZ;
const now = new Date('2026-12-24T22:30:00Z');
const o = { locale: 'en', timeZone: tz };

console.log(tz,
  timeUntil(new Date('2026-12-25T23:00:00Z'), now, o),
  timeUntil(new Date('2026-12-24T23:15:00Z'), now, o),
  timeUntil(new Date('2026-12-20T12:00:00Z'), now, o));

assert.equal(daysBetweenKeys('2026-03-28', '2026-03-30'), 2);
assert.equal(timeUntil(new Date('2026-12-24T23:15:00Z'), now, o), 'in 45 minutes');
assert.equal(timeUntil(now, now, o), 'now');
Enter fullscreen mode Exit fullscreen mode
$ for z in Europe/Warsaw America/New_York Pacific/Auckland; do TZ=$z node test.mjs; done
Europe/Warsaw    in 2 days   in 45 minutes   4 days ago
America/New_York tomorrow    in 45 minutes   4 days ago
Pacific/Auckland tomorrow    in 45 minutes   4 days ago
Enter fullscreen mode Exit fullscreen mode

Warsaw is the case from the start of the post. It's 23:30 on the 24th there, and the target falls at 00:00 on the 26th, so it's two midnights away. New York is still on the afternoon of the 24th and the target is the evening of the 25th, so "tomorrow" is correct. Each answer matches what a person in that city would say, and none of them comes from rounding hours.

Keeping it live

For a ticking label, don't re-render every second when the text only changes once a day. Work out when the label will next change: the next minute if it shows minutes, otherwise the viewer's next midnight. Set one setTimeout for that moment. That's cheaper and avoids layout jitter.

Wrap-up

  • Let Intl.RelativeTimeFormat handle the words, including "tomorrow", plurals and other languages.
  • Work out the number yourself, using calendar days in an explicit time zone.
  • Turn instants into YYYY-MM-DD with Intl.DateTimeFormat, then subtract Date.UTC values so DST never gets involved.
  • Test with TZ=... node in at least one zone east and one zone west of UTC.

I use this approach in Count My Days, a free, ad-free app for countdowns and "days since" trackers. If you find an edge case it misses, I'd like to hear about it in the comments.

Top comments (0)