Inspiration

Every habit app on the store is the same shape: a solo ledger with social features bolted on top — HabitShare's followers, Habitica's party quests, Strava's kudos, Duolingo's friend streaks. That last one is the closest proof this mechanic works — it's the strongest retention feature in a language-learning app used by hundreds of millions of people. But it's a feature of a single-domain course product. It can't be pointed at the gym, at reading, at quitting scrolling, at anything.

So we asked the inverse question: what if the social bond wasn't a feature on top of the tracker — what if it was the data model? Pick a habit. Invite exactly one person. Share one streak. If either of you skips, it breaks for both. One rule, and it turns out that one rule does three jobs at once: it's the growth loop (the app is unusable alone, so every install has to invite someone), it's the retention loop (a partner who notices when you skip is the strongest primitive in consumer habit apps), and it's the product.

What it does

Duosti is a two-person shared-streak habit tracker. You pick a habit, invite one person, and from then on there is exactly one streak between the two of you — a day only counts when both of you check in, and a missed day breaks the streak for both, blamelessly (no red, no shaming copy, no leaderboard).

  • No solo mode, no feed, no follower count. Just the pair.
  • Unlock Together. Either person subscribes, and both of you get Premium — a RevenueCat promotional entitlement, not a shared login.
  • The viral loop pays for itself. When someone who joined your pair later starts their own pair and it becomes real, both of your original pair earn a bonus Premium week — the growth mechanic and the monetization mechanic are literally the same system.
  • Every push comes from a person. "Alex checked in." "Alex is waiting on you." Never "Duosti misses you" — we ship no re-engagement blast and no promotional push, and the app says so out loud during onboarding.

How we built it

Flutter + Riverpod on the client, with Drift/SQLite as the actual source of truth — every check-in writes locally first, synchronously, before any network call, and the app is fully usable offline: checking in, viewing the chain, reading stats, all of it. Supabase (Postgres, RLS, Edge Functions, Realtime) is a mirror and a mailbox, never where truth lives. RevenueCat is the only purchase surface in the app — no code path branches on which SKU someone bought. OneSignal drives every push, keyed by external ID to the same anonymous Supabase identity.

Two pieces made or broke everything else:

The StreakEngine is pure domain logic — no I/O, no Flutter import — and it never increments a streak. Every check-in, local or synced from a partner, triggers a full re-fold of the day-result ledger. The hardest part: a "day" runs cutoff-hour to cutoff-hour, not midnight to midnight, so a 1:30am gym session still counts for yesterday — and that day-key has to be computed by comparing the local hour against the cutoff and decrementing the calendar date, never by subtracting a Duration. Absolute subtraction is silently wrong the moment a spring-forward DST boundary is in the window, and it's the single most common bug in this entire class of app.

Unlock Together grants the partner a lease, not a deed — a 7-day RevenueCat promotional entitlement, renewed daily by a cron sweep while the sponsor keeps paying, and revoked immediately on a cancellation webhook. If our backend goes down, the lease simply expires within a week instead of becoming a permanent giveaway. The same reasoning drives the viral bonus: it reserves a beneficiary before granting anything, and only marks them "paid" after RevenueCat confirms — so a crash mid-grant can't burn a reward, and a failed grant just gets retried the next night.

Challenges we ran into

The DST day-key bug, described above, took a full test matrix (spring-forward, fall-back, cutoff-hour boundaries, timezone-mismatched partners) before we trusted it.

Making grant/revoke fail safe, not just correct. It's not enough for the lease logic to work when everything succeeds — every missed webhook, timed-out function, or out-of-band refund had to degrade to "the partner keeps Premium for one extra week," never to a permanent grant and never to an unfairly early revoke.

The viral bonus's anti-abuse problem. check_ins rows are client-writable with any date, and anonymous accounts are free to create — so "this is a real second pair" can't be proven by trusting the client's own reported event. We had to re-derive eligibility entirely from server-controlled timestamps (how old the original pair actually is, by the database's own clock) instead. Before this went anywhere near real money, we ran a dedicated adversarial review whose only job was to try to break it — it found six real issues (a payment race that could burn a reward on a crash, a cap that could be drained by skipped users, an overclaimed anti-abuse justification, a concurrent-sweep race, and more). Five got fixed. The sixth — a RevenueCat platform behavior that can silently clear an unrelated bonus — we chose to document as an accepted, disclosed limitation rather than build a much larger tracking system to close a rare edge case.

Things that looked done and weren't. Twice, a TestFlight build shipped as a pure white screen because a flutter clean run between config and archive silently dropped the production environment variables — now caught by a build-phase guard that fails the build outright. Separately, we discovered mid-project that anonymous sign-in had been switched off in the production Supabase project (breaking every new install) and that two Edge Functions had never actually run, because required secrets were missing and nothing alerted us — they'd been silently inert since the day they were deployed. The lesson each time was the same: dashboard and infrastructure state that looks configured has to be tested against the live system, not trusted.

Accomplishments that we're proud of

  • A genuinely local-first app — check-ins, the chain, stats, all fully usable offline, no special-casing.
  • Unlock Together shipped as a lease with a real fail-safe story we can point to in the actual webhook and cron code, not just describe.
  • The viral bonus — growth and monetization sharing one mechanism, adversarially reviewed before it touched a single dollar, live in production.
  • Pair-level economics, not user-level. As of today, our production numbers show 2.00 humans served per paying subscription — meaning the same subscription serves this app's users at roughly half the per-human cost of a typical solo app. (Early sample — three pairs, our own test accounts — reported honestly as such, not dressed up.)
  • Over 1,400 automated tests plus a dedicated SQL/RLS regression suite, and a standing practice of running an adversarial review before shipping anything that touches money, auth, or sync — every Critical and Important finding those reviews raised got fixed before ship, not just noted.

What we learned

Fail-open is a discipline, not a slogan. The streak must never break because of our sync latency. A lease must never become permanent because a webhook got lost. An optional personalization call must never stand between a person and the paywall. Each of those had to become an explicit, tested code path — "we'll be careful" isn't a design.

The gap between "configured" and "working" is invisible until you test the live path. More than once this project, something we believed was done — a dashboard mapping, a webhook, a secret — turned out to be silently inert, and the only way we found out was by driving the real SDK against production and looking at what actually came back, not at what the dashboard claimed.

Reserve, then settle — never the other order. Any operation that touches both our database and an external system that can fail independently (a payment provider, a push service) needs to record the intent first and only confirm success after the external system agrees. It's the one pattern that made both Unlock Together and the viral bonus crash-safe.

What's next for Duosti: Habit Tracker for Two

  • Round out the OneSignal Journey story beyond the first-week reminder we just shipped — an un-partnered win-back and a comeback message for a broken streak.
  • A visual pass so the paywall and onboarding carry the same twin-person, Ember/Teal identity the rest of the app already has.
  • Track pair-level economics over time as real pairs — not just our own test accounts — start using Unlock Together.
  • A confirmed-delivery fix for push notifications and consolidating two reminders that can currently land within the same hour into one.
  • A broader premium habit template library beyond today's small starter set.

Built With

Share this project:

Updates

Submission history