Four Thousand Weeks in Go

Calculating my age in weeks.

First published on March 11, 2024. Last revised on March 11, 2024.

Four Thousand Weeks is a book written by Oliver Burkeman on the topic of time management. The premise of the title is that our average lifespan is around 4000 weeks, so let’s make them count.

“Missing out on something – indeed, on almost everything – is basically guaranteed. Which isn’t actually a problem anyway, it turns out, because ‘missing out’ is what makes our choices meaningful in the first place.” – Four Thousand Weeks, Oliver Burkeman

After listening to the audio book in 2022, I decided to build a week calculator. Its entire purpose is to remind me of how old I am. 😅

❯ ./weeks
The current time is Monday, March 11, 2024 at 10:42 PM (MDT)

Nathan was born on Tuesday, April 5, 1977 at 11:58 AM (PST)
He has been alive for 2448 weeks, 6 days, 8 hours and 44 minutes.

The Code

For this program, I kept to simple imperative code with constants instead of config.

// Me
const (
    name      = "Nathan"
    pronoun   = "He"
    birthTime = "1977-04-05 11:58 AM"
    birthZone = "America/Vancouver"
)

The entire program is 60 lines of code and only depends on the Go standard library. It benefits from Go’s multiple return values, built in unit testing, and a number of clean APIs, e.g. time.Since(birth). The full source code is available on GitHub.

Learnings

I may have written a book on Go, but it’s not like I stopped learning. I had a few surprises during this exercise, which I’d like to share.

Time Zone Abbreviations

Time zone abbreviations such as CST are ambiguous.1 I didn’t know this.

As such, it’s no trouble to display PST, but parsing it doesn’t work quite right. Oddly, the Go standard library makes an attempt at it, instead of rejecting layouts containing MST.

badTime, err := time.Parse("2006-01-02 3:04 PM (MST)", "1977-04-05 11:58 AM (PST)")
if err != nil {
    panic(err)
}
fmt.Println("bad", badTime)

location, err := time.LoadLocation("America/Vancouver")
if err != nil {
    panic(err)
}
goodTime, err := time.ParseInLocation("2006-01-02 3:04 PM", "1977-04-05 11:58 AM", location)
if err != nil {
    panic(err)
}
fmt.Println("good", goodTime)

This code produces the following result:

bad 1977-04-05 11:58:00 +0000 PST
good 1977-04-05 11:58:00 -0800 PST

Too bad the time zone offset is completely wrong! You can imagine how displaying the time with a format string would hide the offset and make it seem like everything was working. Except the calculations were off. Yes, that happened. 🤦🏼‍♂️

At least the issue is well documented:

“If the zone abbreviation is unknown, Parse records… the given zone abbreviation and a zero offset.”2

Unfortunately when I first implemented this, I didn’t read the documentation carefully enough. My bad. After tracking down the bug, I found a GitHub Issue to set me straight.

“It is not a goal that time.Time.Format and time.Parse be exact reverses of each other.”3

Switching to time.ParseInLocation solved all the things.

Floating Point DivMod

My initial implementation of splitting a duration into weeks, days, hours and minutes was rather cumbersome. I hit upon the idea of taking the remainder to simplify the code.

However, math.Remainder doesn’t produce the result I was looking for. Don’t be mislead by the name. Use math.Mod instead, as you can see with this code.

fmt.Println(math.Remainder(14.0, 3.0)) // -1
fmt.Println(math.Mod(14.0, 3.0))       // 2

Oh, and by the way, modulus using % works for integers but not floating point numbers. So no go:

fmt.Println(14.0 % 3.0)
// invalid operation: operator % not defined...

The Go standard library has a DivMod function for big.Int, but not for floating point. So I wrote a DivMod function and it cleaned up my code nicely.

func splitDuration(duration time.Duration) (weeks, days, hours, minutes float64) {
    minutes = duration.Minutes()
    weeks, minutes = divMod(minutes, 7*24*60)
    days, minutes = divMod(minutes, 24*60)
    hours, minutes = divMod(minutes, 60)
    return weeks, days, hours, minutes
}

You can see the old code get refactored in commits 0361702 and f8fabd1.

Summary

I’m happy with the end result. While it would be nice if the Go standard library was less surprising in a few places, it’s not likely to change at this point. Testing my work is what caught the issues. By verifying my calculation against an online calculator, I found the bug in my time zone handling. Reading the documentation carefully would’ve saved me some time, but there’s no substitute for testing.

Nathan Youngman

Software Developer and Author