Wafa
A private PWA for shared commitments between people you trust — installable, offline-capable, push-notifying.
status: in-progress
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.
Architecture Decisions
Three decisions
I’d make again.
SHA-256 hash. On accept,
the server hashes the incoming token and compares; raw token never touches
the database.
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.
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.
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.
Problems Hit
Five bugs I hit
and actually fixed.
ios-safari-losing-supabase-session-on-tab-close
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.
localStorage
eviction and survive PWA reinstalls.
reminder-fires-5-hours-late-pkt-utc-mismatch
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.
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
div sat visually behind the sheet but was positioned
inside the wrong layer — pointer events hit the scroll container
before reaching the backdrop handler.
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
self.skipWaiting() and self.clients.claim()
stays in waiting indefinitely. No active worker means no
push event handler is registered.
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
*/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.
in progress
What’s still
being built.
visibilitychange session
refresh — long-backgrounded tabs silently expire; need a
token refresh on document becoming visible.
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.