Time is a fun and challenging technical problem to solve, and most recently I had to answer the question: how many days is it until Christmas?

This post is an over the top deep dive into the complexities of accurately answering a question, which boils down to: a ball of wibbly wobbly… time-y wimey… stuff.

Christmas religious (or commercial!) feelings aside, this question, although simple from one human to another, when broken down has lots of complicated moving parts.

I had made a LaMetric countdown to Christmas that displayed the number of days to Christmas, which worked for me, but with all software, didn't work completely right all of the time.

Christmas countdown clock

In code, how many days?

The code below is the simplistic answer to "how many days until Christmas":

const toDays = ms => ms / 1000 / 60 / 60 / 24;

const today = new Date(); // assume 2018-12-15 10:30:00
const target = new Date(`${today.getFullYear()}-12-25 00:00:00`);

toDays(target - today); // about 9.565944 days

Yes, the above code is extremely simplistic and bug ridden.

What's a day?

Say, for instance, that today is the 24th December. The question: how many days until the 25th can be correctly answered in two ways:

  1. One day
  2. None

Technically correct is zero days, since less than 24 hours equals zero days. This is fine if working with hours, minutes and seconds, but since I want to display a human readable value, the display would say "0 days" to Christmas, a reader would understandably either be confused, wonder if this day is the 25th or raising a bug.

I asked my 7 year old son the same question, and his first answer was one day, then after a moment, zero days, then he wasn't sure.

For a human readable time, I believe the appropriate answer is "1 day" to Christmas (and sure, counting in "sleeps" is a lot easier!). This shows already that the algorithm I'll use needs to calculate the days needs to artificially round the days up.

Updated to "whole" days

The change needs to happen in my toDays function. Normally to round a number, I'll use a bitwise OR | operation - which I learnt about at jsconf 2009 as a "fast" way to get the value I want. Except the value | 0 operation actually floors the value and doesn't round it. The flooring method works fine with positive values (in this particular case), but when it comes to negative values, it gets a bit unexpected particularly as -0.9 floored is actually -0, which for JavaScript is zero - whereas I would prefer the value -1 to be rounded from -0.9 (specifically as I need to refer to days).

const toDays = ms => Math.round(ms / 1000 / 60 / 60 / 24 + 1);

const today = new Date(); // assume 2018-12-15 10:30:00
const target = new Date(`${today.getFullYear()}-12-25 00:00:00`);

toDays(target - today); // 10 days

Not everyone lives in the UK

Imagine my delight and surprise when I found that my countdown clock was being used by someone out in San Francisco. First of all that's great. Second of all: the countdown will only be right for some of the time for that person 😱.

As my server has it's clock set to UTC+0 and during December UK time is GMT+0 which happens to be UTC+0, the countdown is correct for me when I view it in the UK. However, when viewed in San Francisco, it will say it's Christmas day from 4pm on Christmas eve, and we really can't have that.

The next task is: if I can get your location (by asking), can I work out the timezone adjustment?

v1: "just ask the user"

The UI for the countdown could ask the user what timezone they're on.

But that's riddled with problems. Sure, from a computer's point of view, UTC+5 is fine, but who in their right might is going to enter their timezone like that? Or even from a dropdown list, not everyone is going to know. Maybe UTC isn't obvious and we offer GMT+5, but again it's not obvious.

Perhaps there's a list of the timezones by name? There's "only" 200 of them. Then there's things like PST is both Pacific Standard Time - UTC-8 and Philippine Standard Time - UTC+8 - a pretty big difference if you select the wrong one.

Not so good.

v2: Names are better than numbers

How about instead the user offers their timezone as text? The PHP timezone documentation lists all the timezones by "standard" name.

There's an officially maintained database of timezone names and their offset (copied into Wikipedia for easy access).

The user could write their city, like "Chicago" and it could be matched up to the closest name, and then we'd have the timezone. Except: typos and then Christmas is incorrectly reported and the kids get upset!

So back to a dropdown…and that is unwieldy…much!

That list 👆 is literally adding 18Kb to the download size of this blog post. That feels a bit silly to me, and not very end-user-friendly!

v3: your timezone as a service

Being there's a service for nearly everything, I found a few offerings. I found a node module that would give me a timezone name given a latitude and longitude called geo-tz. All I needed was the lat & lng for an IP.

ipinfo.io is my goto service for, well, IP info. It will give me a latitude and longitude for an IP, so I signed up getting a developer API key and connected up the services.

Upon my countdown getting a request, the code (roughly) looked like this:

const info = await ipInfo(ip, process.env.TOKEN);
const timezone = geoTz(...info.loc.split(',').map(_ => parseFloat(_, 10)));
const tzOffset = tz.get(timezone);

// now use tzOffset in the toDays calculation…

The tz.get was a giant map of timezone name to offset - generated from the Wikipedia page from the following code:

(() => {
  const points = $$('.wikitable.sortable')[0].querySelectorAll(
    'td:nth-child(3), td:nth-child(6)'
  );
  const res = {};
  for (let i = 0; i < points.length; i += 2) {
    res[points[i].innerText] = points[i + 1].innerText;
  }
  return res;
})();

…which was turned into an ES6 Map object using the following code (I'm not sure why I used a map, I just felt like being swanky):

// where `raw` is the result scraped from wikipedia
module.exports = Object.keys(raw).reduce((acc, curr) => {
  const offset = raw[curr];
  let [, dir, hour, min] = offset.match(/([+-])(\d{2}):(\d{2})/);
  hour = parseInt(hour, 10);
  min = parseInt(min, 10);
  dir = parseInt(`${dir}1`, 10);
  const ms = (HOUR * hour + MIN * min) * dir;
  return acc.set(curr, {
    offset,
    hour,
    min,
    dir,
    ms,
  });
}, new Map());

So, how did that pan out?

Well, it started to work. Then… it didn't. Not because the code was bad, but because of the sheer number of requests.

The ipinfo.io service has a free teir of 10,000 requests per month. Plenty for my little countdown I'd image.

Nope.

ipinfo overdrive

In around 1½ days my countdown had made over 7 times the max number of requests for the month. I was quickly heading in to the $250 per month pricing bracket…for a chirstmas clock!? Nope!

v4: there's gotta be a cheaper way 😱

There is also the MaxMind (free) database that offers IP to country data that I've used for other services in the past. However, looking deeper there was a "city" database that gives me a latitude and longitude - yay.

Then by the miracle that is open source, there's an npm module that allows me to enter a lat & lng, and using a massive vector map, it will return the correct timezone - joy!

Then I spotted the MaxMind database, although the pricsion isn't super accurate, it's good enough for a city level accuracy, it also includes the timezone name. I can now wire that up to my timezone name lookup to offset and I don't have to rely on spending $3,000 per year for a silly little countdown!

Now in my server, I first open the MaxMind database so it's ready for requesting:

const geoLookup = require('maxmind');

const open = async () => {
  return new Promise((resolve, reject) => {
    geoLookup.open('./data/GeoLite2-City.mmdb', (err, lookup) =>
      err ? reject(err) : resolve(lookup)
    );
  });
};

const db = open();

Then in my web server, I use the request's IP address to lookup the details. Since the db object is a promise that will repeatidly resolve with the lookup function, this is what my request handler looks like:

db.then(lookup => {
  const ip =
    req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress;

  send(res, 200, { ...lookup.get(ip), ip });
});

The result, in a single service looks like this:

{
  "accuracy_radius": 1,
  "latitude": 50.8614,
  "longitude": -0.1204,
  "time_zone": "Europe/London",
  "ip": "185.65.110.157"
}

Connecting the dots

Now I have my own (read: don't have to pay) service for IP to timezone, when a requet comes in, I send off to my service to get the timezone (instead of previously ipinfo.io) and use that for the timezone offset.

To calculate the countdown correctly I use the following:

const toDays = ms => Math.round((ms / 1000 / 60 / 60 / 24) + 1);

async function getCountdown(ip) {
  // work out the timezone based on the IP
  const res = await fetch(`https://ip2tz.isthe.link/?ip=${ip}`);
  const data = await res.json();
  // includes offset in milliseconds
  const tzOffset tz.get(data.time_zone.toLowerCase());

  let today = new Date();
  const year = today.getFullYear();
  const target = new Date(`${year}-12-25 00:00:00`);

  today = today.getTime() + tzOffset.ms; // convert to milliseconds

  return toDays(target - today);
}

And finally we change the return line to account for going past Christmas day with:

let delta = toDays(target - today)

if (delta < 0) {
  delta = toDays(new Date(`${year + 1}-12-25T00:00:00`), today);
}

return delta;

And that's it. "Just" a little random side project that shows a countdown to a fix point in time.

But…what about birthdays 🎂

Time and programming is hard, there's no doubt. Now imagine this same project, but for birthdays. Similar, but not the same. Most importantly, you're now working with daylight saving time 😱

It means the user could have a birthday during daylight saving time but they're looking at the clock outside of daylight saving time. It could mean their birthday is 2 days away, but actually 47 hours away - and combining DST it's the same thing…or is it? It's a headache, that's all I can tell you 😵🤯😭

When (roaming) in Rome…

Roaming mobile networks does not (apparently) equate your phone thinking it's in that country.

Interestingly I found when testing using my mobile phone (on O2 on a UK mobile plan) testing whilst I was in Poland (sorry, "When in Poland" didn't have the same ring to it), the response was a UK IP address and UK timezone.

Apparently this is because mobile phone provides have a small set of IP addresses and when you're on roaming data, you're connecting through satalites to your network and then coming out on to the web on your provider's network (rather than the network you're roaming on). Useful and interesting for debugging future IP & location based projects.

Links and bits

Finally, the app should still provide a manual method - auto detection is great until it's not and it fails!