The Idea
Like a lot of people, I sometimes struggle with time. Not in an "I'm just bad at planning" way — more like my brain genuinely has a hard time feeling how long things take. Twenty minutes can feel like five. I'll think "I have time" right up until I definitely don't.
So I built Ready.
Ready is a PWA (a web app you can install on your phone like a native app) that counts down to your next event — but not just to the event itself. It counts down to when you need to leave, factoring in both how long it takes you to get ready and how long the journey takes. It sends you push notifications before it's time to move. So you don't accidentally forget about time, run out the door ..late again!
The app was designed with time blindness in mind — a challenge many people experience. The tone is always encouraging, never stressful. No red warnings. No "you're late." Just a gentle nudge that has your back.
It's also my portfolio project. I'm a junior developer learning in public, and this is me documenting the whole messy, rewarding process. (Which also happens to be great for recalling what you learned)
The Stack — and Why I Chose It
Before writing a single line of code, I had to decide what to build with. Here's what I landed on and why:
Next.js — a framework built on top of React (a popular way to build web interfaces). I chose it because it handles both the frontend (what you see and click) and the backend (the logic running behind the scenes) in one project. Less setup, more building.
Supabase — think of it as a database with superpowers. It handles storing your data and user authentication (logging in and out) out of the box. It has a generous free tier, which is great when you're learning.
Tailwind CSS — instead of writing traditional CSS in separate files, Tailwind lets you style things directly in your code using short class names like rounded-full or text-teal-600.
Web Push API + Service Workers — A service worker is a small script that runs in the background of your browser, even when the app isn't open. It's what catches the notification and shows it on your screen.
Vercel — where the app lives on the internet. You push your code to GitHub, Vercel picks it up, and deploys it automatically. Free for personal projects.
Claude — my senior helper. It helps me gather information quickly, make more informed decisions, and answer my questions when I get stuck.
I picked a stack that felt approachable after some research, based on my scope — and figured out the hard parts as I went. The goal is learning, so no use trying to only stick to what I know!
Fail to Plan, Plan to Fail
Planning is the most important step, in my opinion. Skip it, and you risk not knowing what you're actually building — or worse, scope creep: continuously changing the design and never landing on a finished product. And how can you get things done when you don't know what "done" looks like?
The second thing to consider is your time-frame. You want your project finished before the deadline, but with enough buffer that if development takes longer than expected, you're not scrambling — and if it goes faster, you might have time for some nice-to-haves.
This is where the MVP (Minimum Viable Product) comes in. Define the core features your app needs to function, and build those first. That way, you won't end up with a broken app that happens to look nice. Users abandon broken things in a second — but they'll forgive something that works well even if it isn't polished yet. And honestly, I wouldn't trust someone who can't deliver on time. Your future boss probably won't either.
So, now we got the MVP:
- Sign in and sign out
- Create, view, and delete events
- A countdown to departure — factoring in both prep time and travel time
That's it. Everything else was a bonus.
Potential bonuses:
- Weather indicator
- Prep list
Push notifications made the cut eventually, but I made a conscious decision early on: the core app had to work without them. If notifications broke, Ready still had to be useful. You want to build the thing that delivers the core value first, then layer on top.
Designing Before Coding
I knew I wanted the app to feel simple — not cluttered with information. I used Stitch, a design AI, to create a first draft of the look and feel, then tweaked it until I was happy. That design became my blueprint for everything I built afterward, even though I eventually made some minor changes.
By the end of this phase, I had four screens mapped out: the landing page, login, the create-event page, and the real star of this project: the countdown page.
Setting Up the Project
With the pages decided, it was time to actually set things up. Next.js makes routing (how your app navigates between pages) refreshingly simple: you create a folder named after the page, and inside it, a file always called page.tsx (or .js if you're using plain JavaScript).
Choosing a Database
Next was Supabase — the database for the app. I needed persistence, meaning the app had to actually remember things: users, their events, and so on. You can build an app without a backend or database, but then your app's "memory" is extremely limited — refresh the page, and it's gone.
So I created a new Supabase project and thought through what I needed to store. At minimum: a user, and the events that user creates — with each event linked back to the user it belongs to. That was enough for this small project so I didn't have to spend a lot of time designing databases and their relationships.
Adding Authentication
Now that a user could technically exist in the database, the app needed a way to recognize who was using it — so they could come back later and see their saved events. That meant building login functionality.
Here's where I learned something I didn't expect: I assumed I'd have to build login myself, using my own users table. Check if the email exists, verify a password, all of that. But the way Supabase has authentication built in it's completely separate from your own database tables. I just hadn't realized "Auth" and "my users table" were two different things until I went looking for it.
So instead of building login logic from scratch, all I needed to do was check that the Email provider was switched on in Supabase's dashboard (it was, by default), and call one function from my login page:
supabase.auth.signInWithOtp({ email })
That single line sends a magic link to whatever email the user types in. No passwords, no separate signup flow — Supabase handles creating the user behind the scenes the first time they log in.
The Missing Piece: Getting Back Into the App
Before testing the magic link for real, there was one setting to check: Supabase needs to know where to send the user back to after they click the link in their email. That's controlled by Site URL, under Authentication → URL Configuration. I went to check it — and it was already correctly set to http://localhost:3000. Nothing to change, just a box ticked.
The real complexity showed up right after. When a user clicks the magic link, they get redirected back to the app with a code attached to the URL — something like localhost:3000/?code=abc123. That code is essentially a claim ticket. It needs to be exchanged for a real, logged-in session. Without something to handle that exchange, the code just sits there, unused, and the user never actually gets logged in.
The fix was a new file: app/auth/callback/route.ts. Its job is simple — grab the code from the URL and trade it in for a session:
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = createClient()
await supabase.auth.exchangeCodeForSession(code)
}
return NextResponse.redirect(`${origin}/`)
}
The Bug That Took Me a While to Understand
I added the callback route, tested it — and the login still didn't stick. No error, no crash. Just... nothing happening. This one took me a while to wrap my head around, so here's the explanation that finally made it click.
Think of it like applying for an ID card at a bank. You submit your application (the magic link), and the bank creates an ID for you (the callback route exchanges the code for a session). But the bank can't just put that ID card in a drawer at your house (localStorage) — they've never been there, and they have no way to reach it. What they can do is mail it to your address (a cookie) — something that actually travels between the bank and you. Next time you visit, you bring the ID, and they recognize you.
My original setup had the bank trying to leave the ID in a drawer at your house. So the session got "created," technically — it was just never delivered where it could be found.
The fix was simple in principle: have the bank mail it instead. So the project ended up with two separate Supabase clients:
-
lib/supabase/client.ts— for code "running at your house" (the browser). Reads from the drawer (localStorage). -
lib/supabase/server.ts— for code "running at the bank" (the server). Mails things instead (cookies).
Once the callback route started mailing the session as a cookie instead of trying to leave it in a drawer, the login worked.
One last thing: sessions expire over time. So I added proxy.ts — it runs on every request and quietly refreshes the session in the background before it has a chance to expire. (In Next.js 16, it used to be called middleware.ts but was renamed to proxy.ts to better reflect what it actually does if I understood it correctly.)
The last piece of this puzzle: new users were logging in fine, but never showing up in my own users table. Supabase was creating them in its own internal auth.users table — completely separate from the one I'd built. The fix was a SQL trigger that automatically copies new users from auth.users into public.users the moment they're created.
Saving Events
With auth finally solid, the next piece was making the "Save Event" button actually do something. I had already created the form itself — name, date, time, and a row of prep-time "bubbles" (15/30/45/60 minutes, plus a custom option) — but clicking the button didn't save anything yet.
The fix was a handleSave function that did three things:
- Grabbed the currently logged-in user with
supabase.auth.getUser() - Combined the separate date and time fields into a single timestamp
- Inserted the event into the
eventstable, tagged with that user's ID
async function handleSave() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const eventDateTime = new Date(eventDate!)
eventDateTime.setHours(eventTime!.getHours())
eventDateTime.setMinutes(eventTime!.getMinutes())
const { error } = await supabase.from('events').insert({
user_id: user.id,
name: eventName,
event_time: eventDateTime.toISOString(),
custom_prep_time: selectedMinutes
})
}
This one was kind of straightforward, no mysterious bugs, just a matter of getting the column names right.
Realizing the Home Screen Wasn't Real Yet
At this point I had started testing the functionality I had and realized that the landing screen needed several states. That's an easy thing to overlook when you're building screen by screen instead of thinking about the full picture.
A real home screen needed to handle three different situations:
| State | What should show |
|---|---|
| Not logged in | Redirect to /login
|
| Logged in, no events yet | An empty state with a clear "+ Add event" call to action |
| Logged in, has events | The next upcoming event with its countdown |
Before any of that could work, there was one more piece needed first: Row Level Security (RLS). By default, Supabase doesn't automatically restrict who can read what — you have to explicitly tell it "a user should only ever see their own events." RLS policies are exactly that: rules attached directly to the database table that enforce this, no matter how the data is requested.
So the order ended up being: set up RLS policies first, then fetch real events from the database, then build the empty state, then handle the logged-out redirect — each piece depending on the one before it.
The Countdown — and the Timezone Bug
This was the actual heart of the app: the part that makes Ready, Ready. The core calculation is simple on paper — departure time is event_time − travel_time. A progress bar then fills in across the prep-time window, ending right at departure.
Where it got messy was time zones — a classic JavaScript trap. The database stores everything in UTC, but anything shown to the user — what counts as "today," what time to display — needs to be calculated in local (in my case Swedish) time. Get that wrong, and the app might think an event happening tonight is actually tomorrow, or show a countdown that's off by a couple of hours depending on daylight saving time.
The fix was running the "is this event today?" check on the client side, in the browser, rather than trusting raw UTC comparisons. A Date object in the browser automatically knows the user's local timezone — so comparing dates there, instead of doing the comparison against the server's UTC values directly, kept everything lined up with what the user actually sees on their clock.
Keeping the Screen On
One detail I hadn't anticipated: when it's time to get ready, you're moving around. You might glance at your phone every few minutes to check the countdown, but if the screen keeps turning off, that glance forces you to unlock every time.
The fix was the Wake Lock API — a browser feature that lets a web app ask the device to keep the screen on. It's a single line to request it:
wakeLock = await navigator.wakeLock.request('screen')
The tricky part was when to activate it. My first instinct was to tie it to the countdown timer, which updates every second. That turned out to be a bug in disguise — the screen lock was being released and re-acquired every single second, meaning the screen was technically never actually locked for more than a moment.
The fix was to stop thinking in seconds and start thinking in states. The screen should stay on when the user is in the prep window and hasn't departed yet. So instead of running the wake lock logic on every tick, I made inPrepWindow a boolean that only changes when the user actually crosses the threshold — and tied the wake lock to that instead. One transition in, one transition out.
That way it was a simple check if the user was in prep state or not, no constant updating.
Push Notifications — The Hardest Part
This was the most complex part of the whole build. A lot of moving pieces, and a lot of silent failures
The Setup
The idea: when a user saves an event, Ready schedules a set of notifications in the database — one when it's time to start getting ready, one halfway through prep, one at departure. A pg_cron job (a scheduler built into Supabase) triggers a Supabase Edge Function every minute. That function checks for any unsent notifications whose scheduled time has passed, and sends them.
For this to work, a few things needed to be in place first:
- A service worker (
sw.js) — a small script that runs in the background of the browser, even when the app isn't open. This is what actually receives and displays the notification. - VAPID keys — a public/private key pair that proves to Google's push service that the notifications really do come from Ready, not someone else. Think of it as an official seal on an envelope.
- A
push_subscriptionstable in Supabase to store each user's push subscription — the unique address that tells the server where to deliver notifications for that specific browser.
Notification permission is requested the first time a user saves an event — not on login, to avoid hitting people with a permission prompt before they've seen any value from the app.
Bug #1 — The Silent Crash
The Edge Function was deploying fine and being triggered every minute by pg_cron. But the logs only showed booted followed immediately by shutdown — no output from my own code at all.
After adding proper error logging, the real message appeared:
Could not find a relationship between 'scheduled_notifications' and 'push_subscriptions' in the schema cache
The original code tried to fetch scheduled_notifications and push_subscriptions in a single Supabase join query — the idea being to get the notification and the user's push subscription (endpoint, keys) in one go. But no foreign key existed between the two tables, so Supabase had no idea how they related and refused to join them.
The fix: fetch the push subscription separately using user_id inside the loop. (could probably have been fixed in a lot of other ways but this was the path my frustrated self took at that time, and that's fine as it can be overhauled in the future)
Bug #2 — Sent but Never Seen
With the join fixed, the Edge Function started running properly. Notifications were being marked sent: true in the database. The logs showed Push response status: 201 — meaning Google's FCM (Firebase Cloud Messaging, the service that actually delivers web push notifications) had accepted the push.
But nothing appeared on screen.
I checked everything: notification permission was granted, the service worker was active and registered, manually triggering a test push from Chrome DevTools showed Push received! in the console — and even Notification shown!. The whole pipeline worked in isolation. But no notification ever appeared visually.
The Breakthrough
The culprit turned out to be localhost. Push notifications on local development environments are notoriously unreliable — browsers handle them inconsistently, and there's no easy way to know if the issue is the code or the environment.
At this point the app was otherwise feature complete, so I deployed it to Vercel specifically to be able to test push notifications on a real device. I opened it on an Android phone in Chrome — the best-case scenario for PWA push support — and waited for the next scheduled notification.
It arrived. Title, body, app icon and all.
After hours of debugging in DevTools, the fix was just opening the real app on a real device.
Code Review — Catching What I'd Missed
Once the core features were working, I did a proper review of the code with fresh (Claude) eyes. Two issues stood out.
A security hole in the notification scheduling
When a user saved an event, the frontend sent a POST request to /api/schedule-notifications — and included user_id in the request body. The server trusted that value without question. That means a logged-in user could technically send a crafted request with someone else's user_id and schedule notifications on their behalf.
The fix: never trust user_id from the client. Always derive it from the authenticated session on the server instead. Whatever the client sends becomes irrelevant — the server uses the real, verified user.
Deleted events that kept notifying
When a user deleted an event, the rows in scheduled_notifications weren't being cleaned up. The next time pg_cron ran, it would find those rows, see sent: false, and send the notifications anyway — for an event that no longer existed.
The fix was simple: delete the associated scheduled notifications at the same time as the event.
UX Review
Running a structured UX analysis — going through the app screen by screen as if seeing it for the first time — revealed a few things I'd stopped noticing.
The biggest one: the countdown ring ( I changed it from a line to a ring sometime during my progress as I liked the look better) looked broken. It sits empty and gray until prep time starts — which could be an hour away. To a first-time user, an empty ring reads as "something went wrong," not "you have plenty of time."
The fix: a gently pulsing gray ring while waiting, switching to a teal draining ring once prep begins.
A few other things came up: the distinction between prep time and travel time wasn't obvious enough in the form — a new user could easily think they're answering the same question twice. The empty state had a large gray circle taking up half the screen without saying anything useful. And "Remove event" sat close enough to the countdown card that an impulsive tap could delete an event by accident, with no way to undo it. So I changed the things I found to be an issue for a better user experience. Nothing big or extremely technical here.
Privacy and GDPR
Ready is to be used by real people and stores real data — email addresses, event names, times. That comes with responsibility, especially under GDPR, which applies to any app used in Europe. So, time to look at the app from a GDPR perspective.
I went through what data the app actually collects and why:
- Email address — to send the magic link. Nothing else.
- Event data — name, time, prep time, travel time. Needed for the countdown and notifications.
- Push subscription — a technical identifier for the user's browser. No personal information, just an address to send notifications to.
No analytics. No tracking. No third-party scripts. Because there's nothing to track — the app does one thing, and everything it collects is in service of that one thing.
From there, I built out what GDPR actually requires in practice:
Privacy policy — a plain-language page at /privacy explaining what's collected, why, how long it's kept, and what rights users have. Accessible before login, so people can read it before deciding to sign up.
Delete account — a Settings page where users can permanently delete everything: their events, their push subscription, and their account. Not "we'll process your request in 30 days" — gone immediately, triggered by the user, no email required.
Data retention — a scheduled job that automatically deletes events older than 30 days. Past events have no value to the app, so there's no reason to keep them. It was only a query quickly run in Supabase.
Infrastructure — Supabase runs on servers in the EU (Ireland), so data never leaves Europe.
The honest version of GDPR compliance isn't a cookie banner and a wall of legal text. For me, to be smart when creating is: collecting less, keeping it shorter, and giving users real control. Instead of having a contact address for them to require their data (which is deliberately kept to a minimum), just give the users the possibility to get it directly from the app which is simple when you don't store heaps of data. And you don't give yourself more work by having to manually send it out.
The Last Step — Making It Installable
The final piece was turning Ready into a proper PWA (Progressive Web App) — something you can actually install on your phone's home screen like a native app. That required two things: an icon and a manifest.json file.
For the icon, I generated a simple teal clock on a light background using ChatGPT — minimal, recognizable, and matching the app's design system. The manifest tells the browser everything it needs to know about the app: its name, colors, icon paths, and how it should behave when installed.
Small step technically, but it is so exciting seeing it come together with the final polish.
How I Used AI Throughout This Project
AI was part of this build from the start — but not in the way people might assume. I didn't use it to write code for me and paste it in. I used it as a thinking partner.
The design document as an anchor
Before writing any code, I created a design document — a living file that captured the app's design system: colors, typography, component rules, database schema, architecture decisions. This became the source of truth for the whole project. Whenever I started a new feature, that document was the first thing I referenced.
I used Claude to help maintain and update this document as the project evolved. If a column name changed, or a new architectural decision was made, the document got updated. That way, AI assistance was always grounded in what the project actually looked like — not a generic guess.
The skill file
Claude has a feature called skills — files you can create that give it persistent context about a specific project. I created a skill file for Ready that contains the design system, file structure rules, database schema, component rules, and scope boundaries. Every coding session, Claude reads that file first. It means I never have to re-explain the project from scratch, and it never suggests something that conflicts with how the app is actually built.
How I used it
Mostly as a senior developer I could think out loud with. "Does this approach make sense?" "What are the trade-offs here?" "I got this error — what's happening?" It helped me make more informed decisions faster, without having to google through ten Stack Overflow threads to piece together an answer.
It also did code reviews — flagging the security hole in the notification scheduling, identifying the orphaned notifications bug, and pointing out places where the code could be cleaner.
What it didn't do: make the decisions. Every architecture choice, every UX call, every "is this good enough to ship?" was mine. AI is a good sounding board. It's a terrible substitute for a real user experiencing the app.
And ofc, I got some help to improve my blog text for a better reading experience.
What I Learned
Building Ready taught me more than any tutorial could. Not because tutorials are bad — but because there's a particular kind of learning that only happens when something is broken and you have to figure out why.
The @supabase/ssr bug taught me the difference between server and client. The push notification mystery taught me that sometimes the environment is the problem, not the code. The security review taught me that "it works" and "it's correct" aren't the same thing.
Ready is a real app, solving a real problem I actually have, deployed and running. That matters more to me than any grade.
If you've read this far and you also struggle with time — give it a try! (link below)






Top comments (0)