Lab: Concert Calendar
My own gig agenda, rendered as a month grid with countdowns and exportable to any calendar app. No date library, no calendar component, no API.
21 shows ahead · next: Swallow The Sun in 14 days
August 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
27 | 28 | 29 | 30 | 31 | 1 | 2 (today) |
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16
|
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26
| 27 | 28 | 29
| 30 |
31 | 1 | 2 | 3 | 4 | 5 | 6 |
August 2026
- Sun, Aug 16Swallow The SunIn 2 weeks
- Wed, Aug 26Suicide CommandoIn 3 weeks
- Sat, Aug 29Gustavo SantaolallaBogotáIn 4 weeks
Problem
Calendar UIs look trivial until you write one. Two things break them: dates that are calendar days, not instants, and multi-day events. Parsing new Date("2026-08-16") gives you midnight UTC, which in Bogotá (UTC-5) is the evening of the 15th — so the show renders on the wrong cell for every visitor west of Greenwich.
On top of that, exporting to a real calendar app is where most hand-rolled solutions give up and pull in a library.
Solution
Events are typed data (src/lib/concerts.ts) where dates are plain YYYY-MM-DD strings and all arithmetic goes through Date.UTC. Everything the view needs — month matrix, grouping, status, countdown, iCalendar serialization — is a pure function, so it is unit-tested without a DOM.
The .ics export is written by hand against RFC 5545: all-day VALUE=DATE events, exclusive DTEND, escaped separators, and 75-octet line folding. Zero new dependencies.
Architecture
Server component holds the data and renders the shell; a single client component owns the interaction (month navigation, filter, download). Today's date comes from useSyncExternalStore instead of useEffect + setState: the server snapshot keeps the static HTML stable and the client snapshot switches to the visitor's local day on hydration, with no mismatch and no flash.
Trade-off taken: the selected month is nullby default and only becomes a number once you navigate. That way the visible month keeps following "today" while the date is still being corrected client-side, instead of freezing on the build-time month.
The grid is a real <table> with a caption and column headers — a calendar is tabular data, and screen readers get row/column context for free.
Lessons learned
Exclusive DTEND is the classic bug. An all-day event ending on Oct 12 must declare DTEND:20261013; without it a three-day festival imports as two. It only shows up when you actually open the file in a calendar app, which is why that rule has its own test.
Line folding counts octets, not characters. Splitting a folded line at 75 characterscan cut "Estéreo" in half and corrupt the file. The fold walks UTF-8 bytes and backs off continuation bytes.
Not every event has a date yet. Three shows on this list were announced before they were scheduled. Modeling startas nullable up front meant that filling the dates in later was a data edit — no migration, no new state, and the "to be announced" block simply stops rendering when it is empty.
Code
Data and helpers in src/lib/concerts.ts, tests in src/lib/concerts.test.ts, view in src/components/lab/concerts/.
Next
Start times (which means timed VEVENTs with a proper VTIMEZONE, not all-day ones); ticket links per event; a week view for festival weekends; reading the agenda from an external source instead of a hardcoded array; and a per-event .ics download so you can add just one show.