Tally — Time Tracker
A cross-platform SaaS time tracker for freelancers and small teams — native iOS app (iPhone, Apple Watch, Mac via Catalyst) paired with a full React web dashboard, both running on a shared Supabase backend. One account, every platform, one subscription that works everywhere.
Problem
Freelancers and small teams needed a simple way to track billable hours, set client goals, and generate invoices — without paying $10–20/month like most competitors charge. The market is full of over-engineered enterprise tools and subscription-heavy apps that charge monthly for features a solo freelancer uses once a week. The goal was to build something that works everywhere a freelancer works — phone, watch, laptop, browser — at a price that doesn't eat into their margins.
Solution
A native iOS app for iPhone, Apple Watch, and Mac (via Catalyst) paired with a full web dashboard built in React. Both run on a shared Supabase backend — same database, same auth, same subscription status. One account unlocks everything everywhere. The Pro tier is a one-time $9.99 purchase instead of a recurring subscription, which is the core product differentiator.
Pricing
Free
- 1 client
- 7-day history
- Live timer + manual entry
Pro
- Unlimited clients
- Full history
- Reports & charts
- Invoice generation
- CSV export
- Works on all platforms
Business
- Everything in Pro
- Team workspaces
- Admin/member roles
- Combined team dashboard
Tech Stack
iOS
Web
Backend
Project Snapshot
- Platforms: iOS (iPhone), watchOS, macOS (Catalyst), Web
- Status: Web live on Vercel · iOS under App Store review
- Type: Cross-platform SaaS — real product, not a portfolio demo
- Team: Solo
- Role: Full-stack developer — iOS, web, backend, payments, deployment
Key Features
Cross-Platform Subscription Sync
Buy Pro on iOS via StoreKit, unlock it on the web — and vice versa. Supabase is the single source of truth for subscription status. A StoreKit transaction listener on iOS and a Stripe webhook on the web both write to the same profiles table. Both platforms check that record, not the payment system directly.
Live Timer + Manual Entry
Start and stop a live timer or add sessions manually with a start/end time. Works on iPhone, Apple Watch, and the web dashboard. Sessions sync to Supabase in real time so switching platforms mid-session doesn't lose data.
Client Reports & Charts
Hours and earnings broken down by client, rendered with Chart.js on web and SwiftUI Charts on iOS. Filter by date range, see totals vs. goals, and identify which clients are consuming the most time.
Invoice Generation
Generate a professional invoice directly from tracked sessions for a given client and date range. Pulls in session data, calculates totals, and produces a downloadable PDF — no manual entry required.
Team Workspaces
Business tier users can create a workspace, invite members, and see a combined dashboard showing hours across the whole team. Admins can view or edit any member's sessions; members only see their own.
Voice Control (Web)
Start and stop the timer by speaking — "start timer" / "stop timer" — using the Web Speech API. No button tap required when your hands are occupied. Falls back silently on browsers that don't support it.
CSV Export
Export any date range of sessions to CSV for use in spreadsheets or accounting software. Headers match common invoice tools so the data drops in cleanly without reformatting.
Dark Mode
Both platforms follow system appearance automatically. On iOS, SwiftUI's environment-based color scheme handling covers it. On the web, prefers-color-scheme CSS media query plus a manual toggle stored in localStorage.
Technical Decisions
Supabase as Subscription Source of Truth
Rather than verifying Apple receipts or querying Stripe on every request, both payment systems write their results to a profiles table in Supabase. The app checks one record, not two separate payment APIs. This also makes adding a third platform (e.g., Android) straightforward — just write to the same table.
Edge Functions vs. a Dedicated Backend
The web frontend is static on Vercel and has no Node server. All server-side logic — Stripe webhooks, email via Resend, team invite handling — runs in Supabase Edge Functions (Deno). The tradeoff is cold start latency on infrequent calls, but it eliminates a whole server to maintain and deploy.
StoreKit 2 over StoreKit 1
StoreKit 2's async/await API and Transaction.currentEntitlements make it significantly simpler to verify what a user has purchased without managing receipt data manually. The listener-based model also means entitlement changes (refunds, upgrades) are reflected immediately without polling.
One-Time Pricing as a Product Decision
The $9.99 one-time Pro tier is a deliberate differentiator — most time-tracker competitors charge monthly. This required using StoreKit's non-consumable IAP on iOS (not a subscription) and a one-time Stripe payment on the web, not mode: "subscription". Both write to the same is_pro flag in Supabase.
SwiftData for Local Persistence
Active sessions and recent data are stored locally in SwiftData so the app is usable offline and snappy on first open. Supabase is synced on network availability. SwiftData's @Model macro and ModelContainer make this straightforward compared to CoreData boilerplate.
React + Vite over Next.js
A time tracker dashboard has no SEO requirements and no pages that need server-side rendering. Vite's fast dev server and simple build output was the right call — no SSR complexity, no framework overhead, just a React SPA deployed as static files on Vercel.
Challenges
Cross-Platform Subscription Sync — Two Payment Systems, One Source of Truth
The hardest part of building Tally wasn't the timer or the charts — it was making a purchase on iOS unlock the web app, and a purchase on the web unlock the iOS app. Apple and Stripe are completely separate systems with no awareness of each other.
The solution is using Supabase as the single source of truth. When a user buys Pro on iOS, StoreKit verifies the transaction and calls a Supabase Edge Function that updates is_pro = true on their profile. When they buy on the web, Stripe's webhook calls a different Edge Function that writes the same flag. Both the iOS app and the web dashboard read only from Supabase — they never check Apple or Stripe directly at runtime.
Deno.serve(async (req) => {
const signature = req.headers.get('stripe-signature');
const body = await req.text();
const event = await stripe.webhooks.constructEventAsync(body, signature, webhookSecret);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const userId = session.metadata.supabase_user_id;
const plan = session.metadata.plan; // 'pro' or 'business'
await supabase
.from('profiles')
.update({ is_pro: true, plan, plan_updated_at: new Date().toISOString() })
.eq('id', userId);
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
});
func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
if transaction.productID == "com.chstudios.tally.pro" {
await self.unlockPro(userId: currentUserId)
await transaction.finish()
}
}
}
}
func unlockPro(userId: String) async {
try? await supabase
.from("profiles")
.update(["is_pro": true, "plan": "pro"])
.eq("id", userId)
.execute()
}
One-Time IAP + Web Subscription Interop
Apple's App Store treats a one-time purchase (non-consumable IAP) completely differently from a subscription. On the web, Stripe also distinguishes between mode: "payment" and mode: "subscription". The Business tier is a recurring monthly charge on both platforms; Pro is a one-time purchase on both.
This meant maintaining two separate Stripe Price objects and two separate StoreKit product IDs, with the webhook and transaction listener both mapping product identifiers to the correct plan value before writing to Supabase. Getting the mapping wrong would grant the wrong tier silently — a user paying $9.99 one-time could end up on Business, or vice versa.
Team Role Enforcement at the Database Level
Team workspaces need admin/member separation: admins can see and edit all sessions in the workspace; members can only see their own. Enforcing this only in the UI is not security — a member with the right API call could read anyone's data.
Supabase Row Level Security (RLS) policies enforce the access rules at the database level. The policy on sessions checks the caller's role in the workspace_members table before allowing a read. Admins pass; members only get rows where user_id = auth.uid(). The API never returns rows the caller isn't allowed to see, regardless of what the client requests.
What I Learned
- How StoreKit 2 works — non-consumable IAP, transaction verification, and the entitlement listener pattern
- How to wire two completely different payment systems (StoreKit + Stripe) to a single shared database record
- Supabase Edge Functions in Deno — structuring serverless functions for Stripe webhooks and transactional email
- Row Level Security — writing RLS policies in Supabase to enforce access rules at the database layer, not just the UI
- SwiftData's
@ModelandModelContainer— local persistence with offline support - Mac Catalyst — extending an iOS app to macOS with minimal additional code and the tradeoffs in that approach
- Web Speech API — browser-native voice input and graceful fallback for unsupported environments
- Chart.js on the web vs. SwiftUI Charts on iOS — similar mental models, very different APIs
Next Iteration
- App Store approval pending — once live, add the App Store badge and link to this page
- Add screenshots and a short demo video once the iOS app is publicly available
- Apple Watch complication for current timer state visible on the watch face
- Stripe Customer Portal for Business subscribers to manage and cancel their subscription without contacting support
- Webhook idempotency guard — current Stripe webhook handler doesn't deduplicate retries; adding a check against a
processed_eventstable would make it bulletproof on retry storms
Outcome
Tally is the most architecturally complex thing I've built. Not because any single piece is difficult — timers, charts, and invoices are each manageable in isolation — but because making them work together across four platforms with two payment systems and a shared backend requires every layer to be correct at once. The cross-platform subscription sync is the part I'm most proud of: it's the kind of system that fails silently if you get it wrong, and getting it right required understanding how StoreKit, Stripe, Supabase Auth, and RLS all interact. Coming from iOS-only development, building the full-stack web side in parallel forced me to think about the product as a unified system rather than platform-specific features.