Weeks of your life
I made a website where you can visualize your life in weeks, grouped by decade: weeksofyour.life
In parallel with this Show ’n Tell, I also wrote an essay about thinking small on side projects. In order to actually ship this in ~one weekend, I limited myself to one new unknown: the Next.js
/app router. This website still uses
/pages, and I thought it would be nice to get comfortable with the new patterns before migrating a larger (although still pretty small) codebase.
I also used CSS modules for styling, which I’m familiar with from my day job but hadn’t used for a personal project. I’m considering reaching for CSS modules as my go-to for new projects, so this was a low risk way to test that out.
I marked Framer Motion as a stretch goal that I could check off my wishlist, but after shipping the SLC (Simple, Lovable, and Complete) version in the afternoon I walked to the grocery store and kept thinking about the slow initial page load. So when I got home I wrote a
Loading component using
Since this a low stakes personal project, I could afford to experiment a bit.
Pushing the boundaries of URL as state
URLs are the fundamental state management tool. For the limited scope of this project I decided against adding auth or backend persistence, so it really came down to URL or localStorage.
The reason I went with URLs over localStorage is that URLs are shareable. The main drawback is that super long ones are very ugly, so unless they are hidden behind a nice hyperlink, they just look bad. URLs can have a maximum character length of about 8,000 characters, which should be enough in most cases. From some quick napkin math, I think that allows for about 100 life events, including some characters like emojis that require longer URI encodings.
The primary heading and all decade headings use the HTML attribute
contenteditable="true", which allows rich text editing without some of the features of
input elements, like client-side validation.
CSS ch unit
As far as I know, there’s no way to set a dynamic width for an HTML
input with pure CSS (please lmk if that’s not the case). Setting
max-content doesn’t work, for example, because inputs are replaced elements that have intrinsic dimensions and behaviors that are not fully governed by CSS.
So to size inputs dynamically based on the width of their child text, I used
ch units. One ch unit is the width of the
0 character for a given font, so I used a monospace font for consistent width. It’s not perfect—e.g. emojis don’t exactly match the ch width—but it seemed like the right tool for the job. Getting this to work was a helpful, humbling reminder that I’m ok but not great at CSS.
Five thousand DOM nodes
There are over 5,200 weeks in a century (~52 weeks per year * 100 years, ignoring leap years). Rendering a DOM node for each week in a century is a lot. First I built a working version without any performance optimizations, which was both slow to load and laggy on interaction. Here’s the short list of where I looked first to optimize:
- Memoize the React component that re-renders thousands of times
- Memoize the function that computes a century of birthdates
- Debounce the function that handles accent color picker updates
Memoize the React component that re-renders thousands of times
First I wanted to know if the problem was rendering thousands of HTML inputs, or re-rendering thousands of React components. I temporarily swapped all inputs for divs, and the lag was still there: React re-rendering problem. Each individual input shouldn’t need to re-render when its parent updates—e.g. when a new accent color is chosen from the color picker—so I memoized the component with React.memo:
Memoizing means that a component will only re-render when its props, state, or context updates, so that change cut down on thousands of unnecessary re-renders. From the React docs:
Optimizing with memo is only valuable when your component re-renders often with the same exact props, and its re-rendering logic is expensive. If there is no perceptible lag when your component re-renders, memo is unnecessary...
There is no benefit to wrapping a component in memo in other cases. There is no significant harm to doing that either...
Isolate FutureWeek component from Week component
I initally rendered both future weeks and past weeks using the same component, but since future weeks are styled using the dynamic accent color, they will re-render whenever the accent changes. To avoid coupling past weeks in those re-renders, I isolated
That led to a 3+ second difference on the initial page load delay to populate birthdays (e.g. “10 in 2007”) per DevTools > Performance tab. I did not do a rigorous benchmarking exercise, though, so I am a little suspicious that such a simple change could have made such an enormous difference. More investigation for me to do there. It’s worth noting that birthdays populate immediately in the future week divs compared to a multiple second lag for the past week inputs, so it could have something to do with the overhead of controlled state for each input.
Memoize the function that computes a century of birthdates
useBirthdate custom hook generates a nested array of weeks grouped by decade. Since that computation should only occur when birthdate updates and not when other state updates trigger a re-render, I wrapped it in
I also timed the function using
console.timeEnd to gauge how long it actually takes, and the result was consistently about 10 milliseconds. Time spent in the past studying time complexity and Big O notation etc. for coding interviews tempted me to optimize this function, but I think any improvement would be immaterial.
Debounce the function that handles accent color picker updates
Dragging the color picker to change the accent color was extremely laggy because each change event triggered a re-render of thousands of future week divs. The only change event that matters is the final color chosen, so to ignore the intermediate dragging states I debounced the change handler:
I chose 50 milliseconds because it’s under the threshold of 100ms that humans perceive as instant.
Where to go from here
There’s definitely more I can do to improve performance, and this is not a topic of expertise for me, so feel free to reach out with any suggestions. Things I have in mind:
Dates are tricky
I got tripped up on UTC versus local time a couple times while writing date logic. For example,
Date.prototype.getDay() should return 0 for Sunday, 1 for Monday, …, 6 for Saturday. But when you invoke
new Date("2023-12-20"), it’ll create a local date based on your timezone at midnight, then when you call
.getDay() it’ll take UTC time, which will be the day before. I suspect there could be more date bugs hiding in edge cases, so let me know if you find any.
There are a handful of features I could add.
- Custom OG images, e.g. in a style similar to the loader including one of the life events you added, to improve the sharing experience
- Custom font as a natural extension of the custom accent color
- Anniversary emails, i.e. an opt-in to receive emails on the anniversary of certain life events you added
Feel free to suggest any you’d like, too!