I wanted a place to count down to things – a trip, a birthday, a deadline – and to count up from things, like the day I started a habit. Most countdown sites I tried were full of ads or wanted an account before I could type a date. So I built my own: CountMyDays. It's a solo side project, and this post is about how it's put together and a few things that bit me along the way.
The stack
- React 19 + Vite + TypeScript for the frontend
- Supabase for Postgres, magic-link auth and Realtime
- Vercel for hosting
- Tailwind for styling, and a service worker so it installs as a PWA and works offline
Nothing exotic. The interesting parts are in how data moves between "guest" and "signed in", and in how the timer stays accurate.
Guest first, account later
I didn't want a sign-up wall. When you open the app you can create countdowns immediately, and they're stored in localStorage under a single key:
const STORAGE_KEY_LOCAL_COUNTDOWNS = 'countmydays_local_milestones';
Pins and favourites live in their own small keys (countmydays_pinned_ids, countmydays_favorite_ids) so they're cheap to toggle.
Sign-in is passwordless: Supabase sends a magic link. The tricky bit is what happens to the guest data afterwards. I didn't want people to lose the countdowns they made before signing in, so after login the app maps the local items into rows for the countdowns table and inserts them in one go:
const rows = local.map((m) => ({
user_id: session.user.id,
title: (m.title || 'Untitled Milestone').trim().slice(0, 120),
target_date: new Date(m.target_date || m.targetDate || Date.now()).toISOString(),
type: m.type || 'countdown',
is_pinned: Boolean(m.is_pinned ?? m.isPinned),
is_favorite: Boolean(m.is_favorite ?? m.isFavorite),
show_progress_bar: m.show_progress_bar !== false && m.showProgressBar !== false,
start_date: m.start_date || m.startDate || null,
created_at: m.created_at || m.createdAt || new Date().toISOString(),
}));
let { data, error } = await supabase.from('countdowns').insert(rows).select();
A few lessons from this:
-
Normalize on the way in. Early versions stored camelCase (
targetDate), later ones snake_case (target_date). The migration accepts both rather than pretending old data doesn't exist. - Have a fallback for schema drift. If the insert fails (for example an older database without the pin/favourite columns), it retries without those fields instead of failing the whole migration.
-
Only clear local data after the insert succeeds. The
localStoragekeys are removed after Supabase confirms the rows, never before.
Security-wise, the browser only ever has the publishable key; what a user can read and write is enforced by Row Level Security policies in Postgres, so each row is tied to user_id.
A timer that doesn't drift
The obvious approach is setInterval(tick, 1000). The problem is that intervals drift and can fire just after a second boundary, so the display looks laggy next to a real clock.
Instead the timer is pure date math – every frame it computes the difference from Date.now() – and a requestAnimationFrame loop only calls setState when the visible second actually changes:
const tick = () => {
const current = calculateLiveTime(targetDate, type);
if (current.seconds !== lastSecondRef.current) {
lastSecondRef.current = current.seconds;
setTime(current);
}
animId = requestAnimationFrame(tick);
};
Because the value is always derived from the clock, there's no accumulated error. Browsers pause requestAnimationFrame in background tabs, so the hook listens for visibilitychange and switches to a 500 ms interval while hidden, then back to rAF when the tab returns.
One small product decision lives in calculateLiveTime: when a countdown passes zero, it doesn't just show "0". It flips to counting the elapsed time since the date. And "days since" trackers with a future start date count down until they start. Same function, four cases.
Live shared links with Supabase Realtime
Every countdown can be shared via a public link. I wanted the shared page to update the moment the owner edits it – handy if you put a countdown on a TV and change the title from your phone.
The shared view subscribes to one channel per countdown and listens to two things: Postgres changes on that single row, and broadcast events.
channel = supabase
.channel(`live_milestone_${id}`)
.on('broadcast', { event: 'milestone:live_stream' }, ({ payload }) => {
/* apply title / date changes instantly */
})
.on('broadcast', { event: 'milestone:deleted' }, () => onNavigate('404'))
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'countdowns', filter: `id=eq.${id}` },
(payload) => { /* source of truth from the database */ })
.subscribe();
Why both? postgres_changes is the reliable source of truth, but it only fires after the write lands. Broadcast lets the editor push changes straight to viewers with less delay. If the owner un-shares the countdown (is_shared === false), viewers are sent to a 404 straight away.
On the sending side, I keep a small pool of joined channels in a Map so repeated broadcasts don't re-subscribe each time. Channels are removed when the view unmounts.
The Vercel SPA rewrite gotcha
The app is a single-page app, so every route needs to serve the same HTML. The standard Vercel config is a rewrite that sends everything except the API to index.html:
{
"rewrites": [
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}
That works – until you turn on cleanUrls. While experimenting with adding static pages, I enabled cleanUrls: true, and suddenly existing routes started returning 404. With cleanUrls, Vercel serves /index.html at / and treats .html paths differently, so a rewrite to /index.html no longer resolves the way you'd expect. The fix was to point the rewrite destination at / instead:
{ "source": "/((?!api/).*)", "destination": "/" }
The related SEO lesson: a catch-all rewrite also means every unknown URL returns 200 with your app shell – a "soft 404" for search engines. I also had to add real robots.txt and sitemap.xml files, because before that, those URLs simply returned the app's HTML.
What I'd do differently
- Decide on one data shape on day one. Supporting both camelCase and snake_case cost more code than it should.
-
Think about crawlers early. A client-rendered SPA shows an empty
<body>to anything that doesn't run JavaScript. Pre-rendering key pages is next on my list. - Keep the guest path. Letting people try the app without an account was the best decision I made.
Try it
If you count down to anything – or count days since something – it's free and has no ads: countmydays.to. Feedback on the code approach is very welcome in the comments.
Top comments (0)