The link is the account: building a no-signup, real-time timezone board
Why timezoners.com has no login, how Supabase realtime keeps a shared board in sync, and the timezone math behind the overlap grid. -
The Slack thread that started this had fourteen replies and no decision. Someone in Lisbon wanted a call. San Francisco said "mornings are good." Bangalore went quiet, because morning in San Francisco is nine at night for him, and nobody in the thread had done that subtraction.
I didn't want to build another wall of world clocks. I wanted the overlap: the band of hours where everyone is awake and working, shown without anyone doing arithmetic. And I gave myself one rule before writing a line. Nobody creates an account to find out what time it is.
That rule ended up shaping the whole app.
The link is the account
There's no login. No email, no OAuth, no "sign in to save" modal. When you start a board, the app mints a UUID and that becomes the URL:
export const generateId = () => typeof crypto.randomUUID === "function" ? crypto.randomUUID() : /* fallback */ ...; // /new-board const newBoardId = generateId(); router.replace(`/${newBoardId}`);
Your browser remembers the last board you touched, so you land back on it next time:
const BOARD_ID_KEY = "timezoners-board-id"; localStorage.setItem(BOARD_ID_KEY, boardId);
Share the URL and the other person is on the same board, editing the same rows. The link is the credential. It's the Google Doc "anyone with the link" model, and a v4 UUID has enough entropy that nobody is guessing yours.
The tradeoff is real, so I'll say it plainly: anyone with the link can edit, and there's no permission layer. For a team picking a meeting time, that is the point. For anything you'd want private beyond an unguessable URL, it would be the wrong design. I decided a scheduling scratchpad doesn't need auth, and skipping it deleted an entire category of work. No password reset emails. No session bugs. No accounts table to get breached, and no GDPR-shaped headache over data I never wanted to hold.
Realtime I didn't have to write
Two people on the same board should see each other's edits without a refresh. I did not want to stand up a websocket server for that. Supabase exposes Postgres row changes as a subscription, so the "backend" is one channel:
const ch = supabase .channel(`timezones:${boardId}`) .on( "postgres_changes", { event: "*", schema: "public", table: "colleagues", filter: `board_id=eq.${boardId}`, }, (payload) => { // apply INSERT / UPDATE / DELETE to local state }, ) .subscribe();
The filter is the part you don't skip. Without it, every client gets every board's changes and you sort it out client-side, which is slower and a quiet data leak. Filtering on board_id means a browser only hears about the board it's actually looking at.
Local edits don't wait for the round trip. Add a teammate and it appears instantly, with the network call running behind it. If the insert fails, it rolls back:
const addColleague = async (c) => { const rollback = optimisticInsert(setColleagues, c); const { error } = await supabase .from("colleagues") .insert({ ...c, board_id: boardId }); if (error) rollback(); };
Typing your own working hours should feel like editing a local array. The network is for telling everyone else.
The day boundary is the hard part
Here's the bug everyone hits. It's 9am in London. What day is it in Auckland? Not what time. What day. Because if you render someone's Tuesday-morning availability against your Monday-evening row, the overlap you're showing is a lie.
First call: store the IANA zone name, never the offset. Europe/London, not +01:00. Offsets move twice a year, on dates that politicians change with a few months' notice. The name is stable and the offset is derived from it.
Then the comparison. I use Luxon to drop a local hour into both zones and ask whether the calendar date moved:
const local = DateTime.now() .setZone(localTimezone) .set({ hour: localHour, minute: 0 }); const target = local.setZone(targetTimezone); let delta: -1 | 0 | 1; if (target.toISODate() === local.toISODate()) delta = 0; else if (target.toISODate()! < local.toISODate()!) delta = -1; else delta = 1;
That -1 | 0 | 1 is what draws the small "previous day" and "next day" markers on the grid, so a 7am slot for you correctly reads as 11pm-the-night-before for someone twelve hours west.
The comparison leans on a quiet trick: ISO date strings sort in the same order as the dates they encode, so a plain < gives you the right answer without parsing anything. Cheap, and easy to forget why it works six months later.
One more defensive piece, because timezone strings show up from the wild (old links, hand-edited rows, a browser reporting something strange):
export const normalizeTimeZone = (tz) => { if (typeof tz !== "string" || tz.trim() === "") return "UTC"; try { new Intl.DateTimeFormat("en-US", { timeZone: tz.trim() }); return tz.trim(); } catch { return "UTC"; } };
Hand a junk zone straight to Luxon and you get cascades of invalid DateTimes that are miserable to trace back. Validate at the door, fall back to UTC, carry on.
The URL carries the whole view
The board ID isn't the only thing living in the URL. When you pick which teammates to compare, or drag the rows into a different order, that goes into the query string too:
/8f3c.../?selected=ab,cd,ef&order=cd,ab,ef
So the link you paste doesn't just open the board. It opens your view of the board, same people highlighted, same rows in the same order. I write those updates with history.replaceState instead of pushing, because forty selections shouldn't cost forty back-button presses to escape.
Never show an empty board
A blank board is where tools like this die. You land, you see nothing, you don't get what it's for, you leave. So a fresh board seeds itself with three random cities and a believable working day:
seedBoardWithRandomTimezones(newBoardId, 3);
You arrive to a board that already shows overlap across three continents. The feature explains itself in the first second, before you've typed a single real name.
Where it is
It's at timezoners.com. Start a board, add the people you actually work with, send the link. No signup, which by now you'd be right to expect.
The part I'm still not happy with: a board lives forever, with no cleanup. It's on the list. It's not on fire.