SWE Case Study
Split
A calendar-first productivity web app where the calendar schedules your todos. Bidirectional Google/Outlook sync with optimistic-concurrency-controlled events.
Snapshot
At a Glance
Role
Founder & Engineer
Sole developer
Duration
12 mo
2025–2026
Tests
505
Vitest + Playwright across 3 workspace projects
ADRs
15
Architecture Decision Records
01
Context
Split is a calendar-first productivity app I built and shipped over roughly twelve months between 2025 and 2026, then launched to a private QA cohort on 2026-03-31. The system constraint it operates against is the split most people live with daily: the calendar and the todo list are separate surfaces, and the user is the integration layer that translates between them.
Split inverts that pattern. Todos are scheduled into calendar blocks, not stored adjacent to one. That choice cascades into every architectural decision in this case study — sync semantics, conflict handling, identity, and what counts as “an event.”
I was the sole engineer on this project. Backend schema, frontend scheduler, auth surface, billing, observability, and the deploy pipeline are all me. The rest of this page covers the system shape and the two engineering decisions I’d defend in interview the hardest.
02
What I Built
The system is a Next.js 15 App Router client talking to a Convex 1.33 deployment that holds both the relational schema and the serverless function surface. Auth runs through Clerk 6 via ConvexProviderWithClerk, so identity is a JWT signed at the edge and verified inside every Convex action. Crucially, identity always derives server-side from ctx.auth — never from a caller-supplied parameter (more on that in Hard Parts).
Backend surface. Convex holds a 20-table schema with a soft-delete pattern threaded through every domain entity, so deletion is reversible and the audit trail survives. Outbound work to third parties (Google Calendar two-way sync, Outlook Graph push, Stripe billing webhooks) runs through Convex Action Retriers — durable, exponentially backed-off jobs that survive transient failures without leaving the user’s data half-synced. Gemini 2.5 Flash gets called via raw fetch() against the REST endpoint; I skipped the SDK because the surface was small enough that the SDK’s opinions about retries and streaming cost more than they bought.
Edge surface. Submission paths go through Upstash Redis for rate limiting before ever touching Convex, so spam and accidental bursts are absorbed at the edge instead of consuming function-execution time. Sentry 8 captures errors on both client and server with PII scrubbed in beforeSend. PostHog tracks 22 typed events — no string-typed event names, every event passes through a generated TypeScript discriminated union, so renaming an event at the call site fails the build until the schema and the dashboard are aligned. The full test suite is 505 cases across three Vitest workspace projects plus a Playwright E2E layer using @clerk/testing.
Other engineering on Split
03
Hard Parts
clerkId removal big-bang migration (ADR-001)
Originally every public Convex action accepted a clerkId parameter on its signature. The client passed its own user id; the server trusted it. That is the textbook trust-the-caller antipattern: any authenticated user could impersonate any other by passing a different id, and nothing in the type system prevented it.
I removed clerkId from every action signature in one coordinated migration. Identity now derives entirely from ctx.auth on the server — there is no caller-controlled identity field. The web repo and the mobile app branch (fix/remove-clerkid-from-actions) merged together in a single window with no deprecation period.
The tradeoff was explicit: I chose a single mass-migration over a months-long parallel-run with both shapes alive. The auth surface is small and load-bearing; keeping a deprecated caller-supplied identity field around for even a few weeks would have meant every reviewer (me) holding two mental models in their head, and any miss would silently re-introduce the impersonation surface. Big-bang here was the riskier-feeling but actually-safer path because the rollback is one revert, not a feature-flag flip.
CalendarScheduler — the algorithmic core (ADR-009)
The scheduler that makes “your calendar schedules your todos” work is a 1,361-LOC pure-function module. It scans the user’s calendar for free time windows, respects working hours and timezone, applies a configurable task buffer (0/5/10/15 minutes between tasks), and places todos into the resulting slots. Every part of that is testable in isolation because the module is decomposed into eight named pure functions — findFreeSlots, resolveConflicts, expandRecurrence, normalizeTimezone, and four others. No method holds state; the whole thing is (input) => placement.
The performance trick is a debounced execution keyed by an input hash. The scheduler runs every time the user’s todo list, calendar, or settings change — naive recomputation on every keystroke would burn CPU and produce flicker. The debounce delays execution by a few hundred milliseconds; the input hash ensures two identical inputs in rapid succession (which happen during React state-batching) coalesce into one run. The hash is the full input shape — todos plus events plus settings plus working hours plus timezone — so a stale schedule is impossible. If anything changed, the hash differs and the scheduler reruns.
The hardest correctness lesson was timezone discipline. Tests construct dates with new Date(y, m, d, h) for local time, never ISO strings — ISO strings carry the test runner’s timezone and break tests on machines configured differently. The scheduler itself converts everything to UTC internally and only renders local time at the boundary. DST transitions in particular would silently produce a one-hour shifted placement otherwise — a bug a user would never report but would erode trust over weeks.
04
What I'd Change
CalendarScheduler is one 1361-LOC file (ADR-009). The scheduler is decomposed into eight named pure functions — slot-finder, conflict resolver, recurrence expander, timezone normalizer, and so on — but they all live in a single file. I argued for that at the time on the grounds that co-location makes the call graph readable, the test file maps one-to-one to the implementation file, and the eight functions share enough fixture data that splitting would have meant either duplicated test setup or a shared helper module nobody asked for.
With the benefit of hindsight, I’d split it. A scheduler/ directory with one file per pure function would be easier to skim in a code review, easier to grep when chasing a bug, and easier to swap an implementation under test. The co-location argument was real but it optimized for the original author (me) at the expense of every future reader. I’d take the file-count cost.
Built at Ramiro Labs.