What even is a week? (dates are hard)
Unpicking an ISO Week conundrum leads into a pit of JavaScript date madness.
Anyone who's ever worked with dates in code will know that they can be, well, unpredictable. Timezones are the classic bête noire of many a programmer and there are many, many horror stories of bugs caused by daylight savings time transitions.
My most recent instalment of "dates are hard" comes courtesy of a simple question:
What even is a week?
Why care about weeks?
It's easy to ignore "weeks" as a concept when working with dates in code. They rarely feature when we write dates; year-month-day
is the international standard, although a lot of non-techie folks still often prefer "day-month-year" (and don't even get me started on the madness that is month-day-year
). But when was the last time anyone asked you what week it was?
Days of the week are important, sure, but does it really matter if the 1st of June is in the 22nd week of the year or the 23rd?
Business folks (you know, the suits that talk about "roadmaps" and say things like "Q3 is right around the corner") obviously care about week numbers. But a situation where weeks are important to folks like me (i.e. demand-avoidant developers) is in visualising date information. Most calendars are laid out in a grid, with weeks as the rows and days as the columns. And if you're creating a calendar UI with code, knowing which week "row" a given day falls in is pretty helpful.
The classic example of this is the GitHub contribution graph: a grid-view of an entire calendar-year where each column represents a week.
![GitHub contribution graph](/images/articles/github-contribution-pacman.png)
So how does this translate into the world of frontend JavaScript? Is there a function we can call to get the week number of a given date?
Native JS date methods
In theory (note: this is foreshadowing) getting numerical values for "day", "month", "year", etc. is straightforward when using the JS Date
object. We can create a date object with the new Date()
constructor, and then use the various get
methods to extract the relevant information.
To get a day number (Sunday = 0, Monday = 1, Saturday = 6) we can use the getDay()
method.
const date = new Date('2020-12-25');
// Fri Dec 25 2020 00:00:00 GMT+0000 (Greenwich Mean Time)
const day = date.getDay();
console.log(day); // 5 (a.k.a. Friday)
For months it's the same: we can use the getMonth
method to return a numerical representation of the month. Like the days, these are zero-indexed so January = 0
, February = 1
, and December = 11
.
const date = new Date('2020-12-25');
const month = date.getMonth();
console.log(month); // 11 (a.k.a. December)
And the same can be done for years:
const date = new Date('2020-12-25');
const year = date.getYear();
console.log(year); // 120
Wait, what?! Why is the year 120
? Silly me, of course getYear()
returns the number of years since 1900. Totally logical.
Thankfully (if somewhat confusingly), getYear
is deprecated, and we should use getFullYear
instead:
const date = new Date('2020-12-25');
const year = date.getFullYear();
console.log(year); // 2020 (phew!)
There is no native getWeek()
So we can get the day, month, and year of a date with the native JS Date
object. But what about the week? Well, there is no getWeek()
method.
This is where date libraries come in.
For a long time, the go-to library for date manipulation in JavaScript was moment.js. But in an admirable display of humility, the maintainers of moment.js have deprecated the library in favour of smaller libraries like date-fns.
There are lots of good reasons to not use a library (they can be heavy, for starters, and often you can do everything you want with the native Date
object anyway) but they do make some things a lot easier. Like getting the week number of a date.
date-fns is my current go-to when I need a date library. It's small, works well with tree-shaking, and it's date objects are immutable (a massive source of weirdness with both Moment and native date objects). And date-fns has a getWeek
method!
import { getWeek } from 'date-fns';
const date = new Date('2020-12-25');
const week = getWeek(date); // 52
Great! Now we can start to build our grid; from the first day of the year to the last day of the year.
// Week values for the first and last days of 2024
const first = getWeek(new Date("2024-01-01"));
console.log(first);// 1
const last = getWeek(new Date("2024-12-31"));
console.log(last);// 1
Say what now? The first day of the year is in week 1
, and so is the last day of the year? That can't be right. Does the same thing happen for all years?
// Week values for the first and last days of 2023
const first = getWeek(new Date("2023-01-01"));
console.log(first);// 1
const last = getWeek(new Date("2023-12-31"));
console.log(last);// 1
// Week values for the first and last days of 2022
const first = getWeek(new Date("2022-01-01"));
console.log(first);// 1
const last = getWeek(new Date("2022-12-31"));
console.log(last);// 53
Fifty-three?! Something fishy is going on. Clearly there are some (literal) edge-cases at play here.
Why are the first and last days of 2023 and 2024 in the same week?
The key thing to get our heads around here is that weeks don't necessarily start on the first day of the year. We can say a week starts on a Monday, but not every year will start on a Monday. 365 (or 366, of course) does not evenly divide by seven, so a given date will inevitably fall on a different day-of-the-week each year.
This means that sometimes the first week of the year starts before the first day of the year.
Thus the last day of 2024 is actually in the first week of 2025, so returning 1
when we ask for the week number makes sense. We'll just need to account for the overflow by checking the year when getting the week number, and if the year is actually "next year", rather than returning 1
we should add it on to the end of the week count (by adding 52
).
Why is the last day of 2022 in week 53?
This is also an artefact caused by weeks always starting on the same day (traditionally Monday or Sunday). The first week of the year is "officially" (we'll dive into this "officialness" in more detail soon) defined as the week that contains the first Thursday of the year. This means that the first week of the year can have up to three days from the previous year in it (assuming a week-start of Monday). Even taking into account that the following year can "steal" a few days from the previous year, the days still occasionally fall so that a year will have 53 numbered weeks.
In general, "week number 53" is accurate and not a bug, so we just make sure our design accounts for it (e.g. by making sure our grid has 53 rows).
Finding an immutable reference point
So we've got it sorted, right? Hahaha, no. We've forgotten to account for timezones and localization!
The Date
object in JavaScript is based on the user's local timezone. If you initialize a day with new Date('2020-12-25')
you're actually creating a date object that represents midnight on the 25th of December 2020 in whatever timezone the user is in. So you could theoretically get different week numbers for the same date, depending on where in the world the user is. And if you can't rely on the date to be consistent, you can't rely on the week to be consistent either.
Local Timezone vs. UTC
Suppose we have a date, December 25, 2020. Depending on the user's local timezone, this date could correspond to different times, which might push the date into the next day or even the next week.
// User in New York (UTC-5)
const dateNY = new Date('2020-12-25T00:00:00-05:00');
console.log(dateNY.toISOString()); // Outputs: 2020-12-25T05:00:00.000Z
// User in Tokyo (UTC+9)
const dateTokyo = new Date('2020-12-25T00:00:00+09:00');
console.log(dateTokyo.toISOString()); // Outputs: 2020-12-24T15:00:00.000Z
Notice how the same local date corresponds to different UTC times. This discrepancy can lead to different week numbers if the week is calculated based on local time.
Different Locales, Different Week Starts
And to add another level of complexity, different locales have different definitions of what a "week" is. In the UK, weeks start on a Monday and end on a Sunday. In the US, weeks start on a Sunday and end on a Saturday (And in Iran, Afghanistan, and Somalia weeks start on a Saturday and end on a Friday).
import { getWeek } from 'date-fns';
// Assuming weeks start on Sunday
const weekUS = getWeek(new Date('2020-12-25'), { weekStartsOn: 0 });
console.log(weekUS); // Outputs: 52
// Assuming weeks start on Monday
const weekUK = getWeek(new Date('2020-12-25'), { weekStartsOn: 1 });
console.log(weekUK); // Outputs: 52
// Assuming weeks start on Saturday
const weekIR = getWeek(new Date('2020-12-25'), { weekStartsOn: 6 });
console.log(weekIR); // Outputs: 53
Here, the same date can belong to different weeks depending on the locale. This inconsistency can be problematic when you need a reliable week number for global applications.
Standardizing with UTC and ISO Weeks
The first step in any date-related code should be to find an immutable reference point. For dates and times, this is UTC format.
A UTC time is the same no matter where you are in the world, and it's a sensible approach for any date-related code to get your dates into UTC format as soon as possible.
// Unpredictable localized date:
const date = new Date(`2020-12-25`);
// Predictable UTC date:
const date = new Date(Date.UTC(2020, 11, 25));
There are a couple of things to note about Date.UTC
:
Firstly, months are zero-based which means 11
is December. Date.UTC(2020, 12, 25)
would roll over to the next year (effectively "month thirteen") and give you January 25th, 2021. It's logical if you take into account the zero-based indexing, but it does make the declarations hard to read at a glance. In normal usage, 2020, 11, 25
looks a lot like the 25th of November.
Secondly, Date.UTC
returns a timestamp (the number of milliseconds since the Unix epoch) rather than a Date
object. This means it doesn't have any of the get
methods that a Date
object has. This is why the example above wraps Date.UTC
in a new Date()
constructor. date-fns functions, however, will happily accept a timestamp or a date object as an argument.
What is an ISO week?
Getting dates nailed down in UTC format is great, but how can we do the same for weeks? The week-equivalent of this standardization is the ISO week. If a (UTC) day falls on ISO week 03, it will always be the third week of the year, no matter where you are in the world.
We mentioned earlier that weeks are "officially" defined as starting on a Monday and ending on a Sunday. But defined by whom?
Our old friend The International Standards Organization has us covered with ISO 8601, the standard covering the worldwide exchange and communication of date and time-related data. ISO 8601 lays out the concept of an ISO week which has "several mutually equivalent and compatible descriptions" of week 01
:
- the week with the first business day in the starting year (considering that Saturdays, Sundays and 1 January are non-working days),
- the week with the starting year's first Thursday in it (the formal ISO definition),
- the week with 4 January in it,
- the first week with the majority (four or more) of its days in the starting year, and
- the week starting with the Monday in the period 29 December - 4 January.
date-fns, of course, has a handy getISOWeek
method, and combining that with a UTC timestamp gives us a reliable, predictable, and immutable reference point for weeks. Hurrah!
import { getISOWeek } from 'date-fns';
const date = Date.UTC(2020,11,25);
const week = getISOWeek(date); // 52
One final spanner in the works: what day does the week start on?
We've already touched on the fact that weeks start on different days in different locales. The ISO week, by definition, always starts on a Monday. But what if you want the bug-free predictable experience of using UTC and ISO week numbers, but want your week to start on a Sunday?
I ran into this exact scenario recently when recreating the GitHub contribution graph. As a reminder, the graph is a 7x52 (or 53 sometimes!) grid, with each column representing a week and each row representing a day. Because I've been burned by dates before, I used UTC dates and ISO weeks, but I wanted the graph to start on a Sunday, not a Monday.
This meant I needed to write a function that gave me all the immutable predictability of an ISO week number, but offset by a day.
The steps for that function were as follows:
- Work out the adjustment needed to get the week to start on the desired day. I.e. by how many days would I need to offset the date to get the week to start on a Sunday?
0
for Monday (as the ISO week already starts on a Monday) or1
for Sunday. - Apply that adjustment to the date. Date-fns's handy
addDays()
function allows us to shift a date object by any given number of days. - Get the ISO week number of the adjusted date. This is the easy part, we just use
getISOWeek()
on the adjusted date. - Check if the ISO week is in the correct year. If the ISO week is from the preceding or succeeding year, we need to ensure we use sequential week numbers. If the ISO week is from the preceding year, we return
0
. If the ISO week is from the succeeding year, we returnISOWeek + 52
. If the ISO week is from the correct year, we return the ISO week number.
import { addDays, getISOWeek, getISOWeekYear } from "date-fns";
const getAdjustedISOWeek = (date, weekStart = 7, year) => {
const adjustments = {
1: 0, // isoWeek already starts on Monday
2: 6, // Add 6 days to start the week on Tuesday
3: 5, // Add 5 days to start the week on Wednesday
4: 4, // Add 4 days to start the week on Thursday
5: 3, // Add 3 days to start the week on Friday
6: 2, // Add 2 days to start the week on Saturday
7: 1 // Add a day to start the week on Sunday
};
const adjustment = adjustments[weekStart];
const adjustedDay = addDays(date, adjustment);
const ISOWeekYear = getISOWeekYear(adjustedDay);
const ISOWeek = getISOWeek(adjustedDay);
// If the ISO week is from the preceding or succeeding
// year, ensure we use sequential week numbers.
if (ISOWeekYear < year) return 0;
if (ISOWeekYear > year) return ISOWeek + 52;
return ISOWeek;
};
The one downside to this approach is that when you run the function for every day in a year, some years will start at week 0
and others on week 1
. This is because the first day of the year might fall on a Sunday, and the first week of the year might start on the following Monday.
// Assuming `days` is an array of objects that each have a `week` value created by `getAdjustedISOWeek`
const normaliseWeekNumbers = days => {
// If the first day of the year is in week 0, increment all week numbers by 1
if (days[0].week === 0) {
return days.map(day => ({ ...day, week: day.week + 1 }));
}
// If the first day of the year is in week 1, do nothing
return days;
};
Working with dates in JavaScript is always going to be hard.
There are so many edge cases and inconsistencies that it's impossible to cover them all. Libraries like date-fns help a lot, and the upcoming Temporal API will hopefully make things much easier. In the meantime, my recommendations when working with dates in JavaScript are:
- If you need to handle and store date information, get your dates into UTC format as early as possible.
- If you need to use week numbers, use ISO weeks.
Both these strategies help you avoid the pitfalls of timezones and locale-specific. Or at least, they help you avoid the most common issues. As sure as the sun will rise tomorrow, you will always be able to find more date-related bugs.
Related posts
If you enjoyed this article, RoboTom 2000™️ (an LLM-powered bot) thinks you might be interested in these related posts:
Improving my Wordle opening words using simple node scripts
Crafting command-line scripts to calculate the most frequently used letters in Wordle (and finding an optimal sequence of starting words).
Similarity score: 59% match (okay, so not very similar). RoboTom says:
Line graphs with React and D3.js
Generating a dynamic SVG visualisation of audio frequency data.
Similarity score: 58% match (okay, so not very similar). RoboTom says: