Date: 2026-05-10 Status: Pre-marketing blocker. Marketing/ads push paused until session reliability is solved. Theme: v0.7.0 — Native Android + PWA Hardening
Code review found the situation is better than the initial "PWA timer breaks when phone is idle" framing suggested:
- ✅ Timer is already drift-resistant.
app/modules/entry-types/timed/TimedInput.vuecomputes elapsed time from a storedsessionStartTime(aDate.now()timestamp), not a counter. Backgrounding/visibility changes don't desync the displayed time. - ✅ Wake Lock is already wired to sessions.
requestWakeLock()is called onbeginSession()and released onstopTimer()(around lines 603 and 729). Screen stays on while the session page is foregrounded. - ✅ Service worker already handles push notifications.
app/workers/sw.tshas apushlistener andshowNotificationplumbing for weekly rhythms. - ❌ Bells stop firing when the phone is locked or the app is backgrounded. The JS event loop suspends,
setIntervaldoesn't tick,<audio>doesn't play. This is the real pain. - ❌ No service-worker-scheduled notifications. The SW only fires on incoming push from the server — nothing schedules local notifications when a session starts.
The path splits into two halves that share most of the codebase:
- PWA hardening — service-worker-scheduled bells, recovery UX, honest "screen off" mode. Benefits all users (web + future Android) and is the foundation for Capacitor.
- Capacitor Android — wraps the existing Nuxt frontend, adds reliable local notifications + foreground service for sessions.
A successful Android v1 ships when:
- User starts a 20-minute session, locks phone, puts it in pocket → bells fire at intervals, completion sound plays at 20 min ✅
- Same flow works on PWA in latest Chrome with screen on / browser foregrounded ✅
- Existing voice capture, wins flow, and sync to server work in the Android shell ✅
- App is signed, listed, and live on Google Play Store ✅
- F-Droid submission accepted (or in queue) — aligned with AGPL ethos ✅
Explicit non-goals for v1:
- iOS (deferred until revenue covers $99/yr Apple developer cost)
- Background sync of entries created offline (separate problem — defer)
- Native widgets / home-screen complications
- Tablet/foldable optimisation
- Native voice transcription (keep using server endpoint)
These are blockers; resolve before phase 1 starts.
Capacitor can't run the Nuxt server in-device. Pick one:
- (A) Cloud-only Android. Static frontend in APK; all API calls go to
https://tada.living/api. Self-hosters use the PWA, not the APK. - (B) Configurable backend. First-run screen lets user enter their server URL. APK ships with
tada.livingdefault but self-hosters can repoint.
Recommendation: (B). Aligned with AGPL ethos, low extra cost (one settings page + URL persistence), unlocks r/selfhosted as an audience for the APK, not just the PWA.
Cookies in Capacitor WebView are flaky across app restarts. Pick one:
- (A) Stick with cookies +
CapacitorCookiesplugin. Minimal code change. - (B) Migrate to bearer token (JWT) stored via
@capacitor/preferences. Cleaner long-term but requires server-side changes too.
Recommendation: (A) for v1. Defer JWT migration. If cookies cause real problems, add a token endpoint later.
The Nuxt app has no SSR routes detected — nuxi generate should work. But some pages may not pre-render cleanly.
- Action: spike
nuxi generateonce before Phase 1. ~30 min. If it fails, revisit decision.
Capacitor 8.3.3 (Capacitor 7 was current when the plan was first drafted in May 2026; the v8 series shipped shortly after and is the new default — Phase 3 scaffolded with 8.3.3). Android Studio Hedgehog or later, Java 17, Gradle 8.x.
Benefits everyone immediately and the Capacitor build inherits all of it. Not throwaway — it's the foundation.
The core fix. When a session starts, the page registers scheduled notifications with the SW. The SW fires them via setTimeout (or Notification Triggers where available) so they survive page suspension.
- Add
scheduleSessionNotifications(intervals: number[], totalMs: number)inapp/workers/sw.ts. - Page-side: on
beginSession(), post a message to SW with bell schedule. - Page-side: on
stopTimer()/ pause, post a message to clear pending notifications. - Audio file URL passed in payload so SW can include sound on Android.
- Caveat: iOS Safari doesn't support
Notification Triggers. Document this. Capacitor solves it natively for Android.
When page IS visible and JS is alive, prefer in-page audio for bells (better latency, no notification spam). Only fall back to SW notifications when page is hidden.
- Visibility check before in-page bell playback.
- SW notification fires only when
document.visibilityState === "hidden".
Timer recomputes elapsed correctly, but the user comes back to a stale-looking UI for a frame.
- On
visibilitychange→visible, force immediatetimerTick()recalc. - Subtle "session continued in background" toast if elapsed jumped >5s while hidden.
Classic iOS Safari trick — play a silent looping audio track to keep the page from being suspended. Ugly but effective.
- Behind a feature flag, off by default. Add only if iOS PWA users complain.
The original plan called for a "Screen will stay on" vs "Lock the phone" toggle. Dropped: the runtime already makes the right choice without asking the user. Wake-lock is requested on every beginSession(), so the screen stays on while the page is foregrounded; if the user locks the phone anyway, the SW-scheduled bells fire via notifications. The fireSessionBell handler skips the notification when any visible client exists, so there's no double-ring when the page is alive (app/workers/sw.ts:131). Adding a toggle would push a decision onto the user that the system can answer itself.
Audited the actual offline behaviour: Workbox precaches static assets, navigations are NetworkFirst with /offline.html fallback, audio files are CacheFirst (30d), /api/* is NetworkOnly. An in-progress timed session keeps ticking because its state lives in localStorage and the page is fully client-side once loaded. Creating, editing or syncing entries needs the network — there is no IndexedDB queue and no background sync.
design/philosophy.md"Offline-first" claim rewritten to "Offline-resilient" with an honest description of what works and what doesn't.docs/dev/v010-snagging-list.mditem 1.3 closed.app/public/offline.htmlcopy updated so users hitting the fallback see something true ("Pages you've already opened still work…").- Full offline-first entry queueing is parked as a v0.8.0+ candidate in
design/roadmap.md— it's a real engineering project (IndexedDB queue, background sync, optimistic UI, conflict handling), not a quick win.
Phase 1 deliverable: PWA bells reliably fire on Android Chrome with screen locked. iOS still requires native. Limitation documented honestly.
Capacitor needs a static dist/ to bundle. Updated 2026-05-12: Phase 2 also delivers the IndexedDB read-cache that makes offline integral to the Android app (option A from the offline scoping decision — see MEMORY.md › project › v0.7.0 offline must be integral to Android). Write-queue offline is parked for v0.8.0.
Spike result: all 48 routes prerendered cleanly in 46s. .output/public/ is the static bundle. Service worker built to 26.33 kB (8.74 kB gzip) with 18 precache entries. No skipped pages, no errors. The Nitro prerender plugins (ourmoji-scheduler, weekly-rhythms) are gated on import.meta.prerender so they don't try to spin up during the build.
No fallback to Capacitor server.url mode needed — the static-bundle path proceeds as planned.
- Targets static output to a stable path Capacitor can read (
./dist). - Sets
NUXT_PUBLIC_API_BASE_URLenv at build time. - Add to
package.json:"build:capacitor": "NUXT_PUBLIC_API_BASE_URL=https://tada.living nuxi generate".
Currently API calls use relative paths (/api/...). In a static APK these resolve against the WebView origin. Need to rebase.
Two paths — investigate first:
- Lower-effort: configure Capacitor's
server.urlto redirect/api/*tohttps://tada.living/api/*. No code changes needed. - More work: add a
useApi()composable that readsruntimeConfig.public.apiBaseUrland prepends to relative paths.
- First-run settings page with server URL input.
- Persist via
@capacitor/preferences(web fallback:localStorage). - Validate URL by hitting
/api/healthbefore saving. - Default
https://tada.livingso the typical Play Store user doesn't see the picker — surface it only when no value is persisted yet, or via Settings → Advanced.
The offline gate for Android v1. Same code runs on the PWA.
useApiCachecomposable layered underuseApi(). On a successful GET, write{ url, body, status, etag, fetchedAt }to IndexedDB keyed by request URL + auth-user-id.- On a failed GET (offline, 5xx, timeout), serve the cached body and surface a
fromCache: trueflag the UI can show as a subtle "cached" badge. - TTL: 7 days. Per-route override for endpoints that should never be cached (e.g.
/api/auth/*,/api/v1/health). - Invalidate cache entries that share a path prefix with a successful mutation (e.g.
POST /api/v1/entriesbusts/api/v1/entries*reads). - Online/offline detection via
navigator.onLine+ first-failed-request signal. Don't trustnavigator.onLinealone — captive portals lie. - Mutations (POST/PUT/DELETE) while offline: throw
OfflineWriteError(code: "OFFLINE_WRITE") and surface an honest toast — "You're offline — couldn't save. Try again when you reconnect." We do not silently optimistic-update or actually queue the change (that's option B / v0.8.0); the toast must not promise a sync that won't happen. - Tests: vitest unit tests for the cache layer covering hit/miss/stale/eviction; an integration test that simulates offline by stubbing
$fetchto reject.
Phase 2 deliverable: npm run build:capacitor produces a static .output/public/ that talks to a remote backend with offline read-cache support. Browsing entries, timeline, rhythms works in airplane mode after first online load.
Phase 3 scaffolding was completed in the devcontainer; the parts that need Android Studio / Java run on the user's local machine. See docs/dev/android-build-handover.md for the exact local checklist.
@capacitor/{core,cli,android,preferences,app,splash-screen,local-notifications,assets}installed at version 8.x.app/capacitor.config.tscreated withappId=living.tada.app,appName="Ta-Da!",webDir=".output/public", WebView originhttps://app.tada.living, allow-navigationtada.living+*.tada.living.npx cap add androidran cleanly and scaffolded the Gradle project underapp/android/.- Helper scripts:
bun run android:sync,android:open,android:run,android:assets. - Remaining: open
app/androidin Android Studio on the local machine, sync Gradle, run on a Pixel 6 / API 34 AVD, confirm the Nuxt frontend loads and login succeeds.
- Capacitor 8 ships the cookie store as part of
@capacitor/core(the standalone@capacitor/cookiespackage from v6 is gone). Same-site cross-origin behaviour was the actual risk, not cookie persistence. - WebView origin
https://app.tada.livingis on the same registrable domain ashttps://tada.living, soSameSite=Laxcookies set by/api/auth/loginare eligible to be sent on cross-subdomain fetches. plugins/api-client.client.tsnow setscredentials: "include"whenever a non-emptyapiBaseUrlis configured, so cross-origin$fetchcalls actually carry the cookie.server/middleware/cors.tsdefault allow-list now includeshttps://app.tada.living; production should also pinCORS_ALLOWED_ORIGINSexplicitly.- Remaining: verify on emulator that login persists across an app restart. If the Android WebView drops cookies (rare since API 31, but possible on some OEMs), fall back to a session token stored via
@capacitor/preferences— flagged in the handover doc.
- 92 Android assets generated from
app/public/icons/tada-fullicon.pngvia@capacitor/assets. Background#10b981(brand green), dark splash#0c8e6f. strings.xmlalready has the rightapp_name="Ta-Da!"andpackage_name="living.tada.app".- SplashScreen plugin configured in
capacitor.config.ts— 1000ms launch duration, brand background, centre-crop.
- App Links intent filter on
MainActivityforhttps://tada.living/*withandroid:autoVerify="true". Verification will only succeed oncehttps://tada.living/.well-known/assetlinks.jsonis published with the release signing-key fingerprint (Phase 6 task). - Web Share Target wired as a
SENDtext/plainintent filter, surfacing shared text/URLs to the app via standard Android share-sheet routing. The existing PWAshare_targetconfig points at/shareand continues to work in the WebView.
Phase 3 deliverable: A real Android app that loads Ta-Da!, lets you log in, and works for everything except backgrounded sessions. Scaffolding committed; final emulator validation is a one-evening task on the user's local machine (see handover doc).
This is what makes the native version solve the problem.
- Install
@capacitor/local-notifications. - Request permission on first session start.
- Bridge: when the page-side session starts, call native API to schedule notifications at all interval times + completion time, with sound from
public/sounds/. - Cancel scheduled notifications on pause / stop.
- Test: lock phone, put in pocket, wait 10 min — bell should fire.
Keeps Android from killing the WebView while a session is running. Shows "Session active — 12:34 elapsed" persistent notification.
- Use
capacitor-foreground-servicecommunity plugin (verify maintenance status — if unmaintained, alternative is a small custom plugin, ~1 extra day). - Started on
beginSession(), stopped onstopTimer(). - Notification updates every minute with elapsed time.
- User can tap to return to app, or stop session from the notification.
- Confirm Wake Lock API works in Capacitor WebView (it should — Android 9+ supports it).
- Audio focus: configure
<audio>to play inBACKGROUND_AUDIOcategory so bells aren't ducked by other apps.
- Notification permission (Android 13+ runtime grant).
- Battery optimisation exemption prompt (optional but recommended for meditation users).
Session UI needs to know: "if running on Android, use native scheduled notifications; if running in browser, use SW scheduled notifications." Single composable handles routing.
useSessionNotifications()returnsschedule()/cancel()that delegates to either native or SW.
Phase 4 deliverable: Lock-the-phone meditation works the same as Insight Timer.
- App killed mid-session → restart → recover state from localStorage (already partially via
useSessionRecovery). - App backgrounded for hours → returning to a finished session shows completion correctly.
- Network drop during sync → entries queue locally, retry on reconnect (partial offline; revisit fully in Phase 7).
- Test on at least: 1 budget Android (Samsung A-series or similar), 1 mid-range (Pixel a-series), 1 power-user (Pixel flagship).
- Test on Android 12, 13, 14 if possible (notification permission rules differ).
- Battery impact check: 1 hour session should not drain >5%.
- Decide: integrate Sentry, or rely on Play Store crash reports.
- Recommend Play Store native reports for v1 (free, no PII concern).
- "Available on Google Play" badge once submitted.
- F-Droid badge once accepted.
- iOS users continue with PWA install for now — be explicit.
- Pay $25 one-time developer fee. (Google Play Console)
- Generate signing key, store in password manager + backed up off-machine.
- Set up CI signing flow if possible (or document manual signing).
- Listing: short description (80 chars), full description (4000), screenshots (min 2), feature graphic.
- Privacy policy URL → tada.living/privacy.
- Initial review: usually 1-7 days.
- Target audience: 17+ — declare honestly (no third-party tracking).
- Fork fdroiddata.
- Add metadata YAML pointing at the GitHub release.
- Submit MR; reviews are slow (weeks/months) but the listing is signal not volume.
- F-Droid only accepts fully open builds — verify no proprietary deps (Stripe SDK etc. only loaded in cloud-mode, should be fine).
- v1.0 release notes — brief, honest, "Android only for now, iOS waiting on revenue."
- Coordinate with marketing push: r/selfhosted post specifically calls out F-Droid availability.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Nuxt 4 has a static-export incompatibility we can't easily work around | Medium | High (blocks Phase 2) | Spike nuxi generate in Phase 0 (30 min) before committing |
| Capacitor cookie handling breaks auth on real devices | Medium | High | Phase 3.2 includes @capacitor/preferences fallback path |
| Foreground service plugin is unmaintained | Medium | Medium | Investigate alternatives in Phase 4.2; worst case ~1 extra day |
| Battery optimisation on Samsung/Xiaomi kills sessions despite foreground svc | High on those OEMs | Medium | Document OEM-specific battery whitelist steps in onboarding |
| Play Store rejects for missing privacy policy / data declarations | Low | Medium | Use Phase 6.1 carefully; existing privacy page covers most |
build:capacitor env-var rebase doesn't work and need real refactor |
Medium | Medium | Phase 2.3 has two paths (server.url config OR composable) |
| iOS users feel left behind, write negative reviews on PWA | Low | Low | Be loud about "Android first because solo project + costs" |
| Phase | Days | Cumulative |
|---|---|---|
| 0. Decisions + spike | 0.5 | 0.5 |
| 1. PWA hardening | 3-5 | 4-5.5 |
| 2. Static export + offline | 4-7 | 8-12.5 |
| 3. Capacitor shell | 2-3 | 10-15.5 |
| 4. Native plugins | 3-4 | 13-19.5 |
| 5. Polish | 2-3 | 15-22.5 |
| 6. Distribution | 2-3 | 17-25.5 |
Realistic range: 3.5-5 weeks of focused solo work. Hobbyist pace (evenings + some weekends): 7-12 weeks. Update on 2026-05-12: Phase 2 grew by 3-5 days to absorb the offline read-cache that's now in v0.7.0 scope.
- iOS native. Wait for revenue. Capacitor makes it a 1-2 week add-on later.
- Tablet UI. Phone-first. Tablet works because PWA, but no special layout.
- Wear OS / watch face. Out of scope.
- Background sync of all entries. Sessions handle the lock-screen problem; full offline-first sync is a separate project. Defer to Phase 7+.
- Replace the PWA. Web stays. Android is additional. Self-hosters keep using PWA on whatever device.
- End of Phase 0: if
nuxi generatedoesn't work cleanly, escalate decision — either invest extra refactoring days or pivot to Capacitor withserver.url=https://tada.living(loads remote, no static bundle). Less ideal but viable. - End of Phase 1: if PWA bells now work well enough on Android Chrome with screen locked, consider stopping there for a couple of weeks. See if real users still ask for native before doing phases 2-6.
- End of Phase 4: if foreground service work balloons, accept "session must be foregrounded" as a v1 limitation, document, ship.
- Phase 6 stalls: if Play Store review drags >2 weeks, ship F-Droid first and add Play Store later.
- 30-min spike: run
nuxi generatein this repo. Note errors. (Phase 0 D3.) - Pick D1 = (B) unless there's a reason against — the AGPL-aligned choice.
- Start Phase 1.1 (SW-scheduled notifications) — highest-leverage single change and benefits current PWA users immediately.
- Marketing/ads campaign parked — campaign material in
tada.living/REDDIT-CAMPAIGN.mdandtada.living/POSITIONING.mdstay drafted but unposted until v1.0 native is in beta (or Phase 1 demonstrates the PWA is good enough).
This repo (engineering):
design/philosophy.md— needs offline-first claim updated (Phase 1.6)design/decisions.md— records the May 2026 decision to add native Android alongside the PWAdesign/roadmap.md— v0.7.0 theme is this work; post-v0.7.0 candidates listed theredesign/alternatives.md— competitive analysis (older; pointer to marketing-side refresh)
Marketing repo (tada.living):
POSITIONING.md— three pillars, who-we're-for, anti-claims, Reddit titlesREDDIT-CAMPAIGN.md— marketing tactics, posting schedule (parked until v1.0 native ships)MARKETING-PLAN.md— overall marketing approach