← Projects

Wafa

A private PWA for shared commitments between people you trust — installable, offline-capable, push-notifying.

status: in-progress

Type Self-initiated product
Role Solo — design + engineering
Stack Next.js · Supabase · Cloudflare R2 · Web Push API · GitHub Actions · next-pwa
Status In active development

Why It Exists

Built from
a felt need.

Generic task managers don’t model mutual accountability between two specific people. Social apps lack privacy boundaries between distinct relationships. I wanted a private space — per relationship, not per person — where commitments don’t get lost in chat threads.

Wafa organises around spaces: one space per relationship (family, a friend, a business partner). Inside a space, you create promises — each with a title, description, due date, and time in PKT. Reminders fire via Web Push before the deadline. Files attach via Cloudflare R2. When you’re offline, the action queues in IndexedDB and syncs on reconnect.

Design Process

Wireframes first,
code second.

I sketched the full information architecture and screen flows before writing a line of code. The screens below are from the running application — they match the original wireframe layout closely enough that no structural rework was needed after the first commit.

Wafa spaces home screen showing three spaces: My Life, Chacha, and Meshlink
wafa-1.jpeg · real device
New promise form with fields for title, description, due date, and time in PKT
wafa-3.jpeg · real device
Promise detail view showing description, notes section, and file attachments
wafa-4.jpeg · real device

Architecture Decisions

Three decisions
I’d make again.

arch-01 rls-in-postgresql-not-in-api-routes
Decision Row-Level Security policies enforced in PostgreSQL via Supabase RLS, not in Next.js API route middleware.
Why API-route guards are easy to miss — a new route added without the guard check silently exposes another user’s data. RLS at the database layer is a hard floor: no query reaches rows the calling user doesn’t own, regardless of how the API route is written. With ~15 policies in place, I can add API routes without a per-route security audit.
Rejected Middleware-only guards — too easy to forget on a new endpoint; a missed guard would be invisible until a cross-user query surfaced it.
arch-02 invite-tokens-as-sha256-hashes-raw-token-only-in-url
Decision Space invite links carry a raw random token in the URL query string. The database stores only the SHA-256 hash. On accept, the server hashes the incoming token and compares; raw token never touches the database.
Why If the space_invites table is ever read by a third party (misconfigured RLS, a backup leak), the stored hashes are useless without the raw tokens. The raw token lives only in the URL shared between the inviting user and the recipient — a channel Supabase never sees.
Rejected Storing the raw token directly — any DB read leak would immediately expose valid invite tokens.
arch-03 pkt-offset-applied-at-write-time-in-api-not-display-time
Decision When a user picks a due time in PKT, the API converts it to UTC (utcHour = (hour - 5 + 24) % 24) before writing next_run_at to the database. Display converts back to PKT. Conversion happens in two explicit, tested utility functions — nowhere else.
Why Storing the raw PKT hour and converting at cron time means every place that reads next_run_at must remember to apply the offset. It’s the kind of bug that passes unit tests and breaks in production only for users in a non-UTC timezone.
Rejected Storing PKT hours raw and converting at reminder-fire time — I hit exactly this bug first. Reminders fired 5 hours late.

Problems Hit

Five bugs I hit
and actually fixed.

ios-safari-losing-supabase-session-on-tab-close

Symptom Users on iOS Safari were logged out every time they closed the tab. Closing and reopening the PWA from the home screen required signing in again.
Root cause Supabase stores the session in localStorage by default. iOS Safari aggressively purges localStorage for PWAs that haven’t been opened recently — the purge window is shorter than most users’ usage pattern.
Fix Switched Supabase session storage to cookie-based sessions. Cookies persist across iOS Safari’s localStorage eviction and survive PWA reinstalls.

reminder-fires-5-hours-late-pkt-utc-mismatch

Symptom Push notifications for promise reminders arrived exactly 5 hours after the time the user set. A promise due at 09:00 PKT fired the notification at 14:00 PKT.
Root cause The API was writing the PKT hour directly to next_run_at as if it were UTC. PKT is UTC+5, so a 09:00 PKT entry stored as 09:00 UTC fires at 14:00 PKT.
Fix Added explicit conversion at write time: utcHour = (hour - 5 + 24) % 24. Wrapped in a pktToUtcHour utility and a corresponding utcToPktHour for display. Both are tested. Neither is called anywhere except those two functions.

bottom-sheet-backdrop-eating-all-pointer-events

Symptom Tapping the backdrop behind an open bottom sheet had no effect on mobile. The sheet stayed open; the dismiss tap didn’t register.
Root cause The bottom sheet was rendered inside a scroll container. The scroll container established a new stacking context. The backdrop div sat visually behind the sheet but was positioned inside the wrong layer — pointer events hit the scroll container before reaching the backdrop handler.
Fix Moved the bottom sheet and its backdrop to a React portal: createPortal(sheetMarkup, document.body). Both now render at the body level, outside all scroll containers and stacking contexts. Dismiss tap works reliably.

ios-service-worker-push-hanging-on-install

Symptom VAPID push notifications worked on Android and desktop. On iOS, the subscription registered without error but no notification ever arrived. No errors in the service worker console.
Root cause The service worker hadn’t progressed past the installing state before the push was attempted. On iOS, a service worker that doesn’t explicitly call self.skipWaiting() and self.clients.claim() stays in waiting indefinitely. No active worker means no push event handler is registered.
Fix Added explicit install and activate event handlers to the service worker. The install handler calls self.skipWaiting(); the activate handler calls self.clients.claim(). The push handler is now guaranteed to be active before the first subscription fires.

vercel-hobby-cron-daily-limit-blocking-reminder-cadence

Symptom Reminders were only firing once per day at most. Promises due at arbitrary times were missed entirely.
Root cause Vercel Hobby plan limits cron jobs to once per day. The reminder system required polling every 5 minutes to catch promises with sub-hour granularity.
Fix Moved the reminder cron out of Vercel entirely. A GitHub Actions workflow runs on schedule */5 * * * *, calls the reminder API endpoint with a shared secret, and has no plan-based frequency limit. GitHub Actions free tier is generous enough for this cadence.

What Shipped

Honest scale.
Personal project.

3 DB migrations
11 tables
8 enums
~15 RLS policies
20+ API routes
30+ components
R2 Cloudflare attachments
VAPID Web Push · tested on iOS
IDB IndexedDB offline queue

What’s still
being built.

auth Magic-link login — currently using password-based auth; magic link removes the password surface entirely.
pwa iOS splash screens — the installed PWA shows a blank white screen on launch; proper splash assets needed per device resolution.
session visibilitychange session refresh — long-backgrounded tabs silently expire; need a token refresh on document becoming visible.
offline Offline sync conflict resolution — the IndexedDB queue replays actions on reconnect but doesn’t yet handle the case where the server state changed while offline.
feature Suggestion lifecycle — one person can propose a commitment, the other can accept or counter. Designed; not yet implemented.

What I’d Do Differently

One honest
paragraph.

Timezone handling should have been a first-class concern from migration 1. I should have defined a pktToUtc and utcToPkt utility, written tests for both, and documented the invariant — all timestamps stored in UTC, all PKT display conversions go through the utility — before the first reminder fired. Instead I wrote the raw PKT hour into the database, hit the 5-hour drift bug in production, and spent time patching what a single deliberate decision at the schema stage would have prevented entirely.

If you have a problem
worth solving — let’s talk.