Eleven Years, One Week, and an AI Co-Pilot: Rebuilding TravelTimes for iOS 26.4
A decade-old side project, six major features, one week. How spec-driven AI-assisted development compressed months of work into a focused sprint on a real codebase with real constraints — and where the AI got it wrong.
Eleven Years, One Week, and an AI Co-Pilot: Rebuilding TravelTimes for iOS 26.4
A decade-old side project became a laboratory for what AI-assisted development actually looks like on a real codebase with real constraints.
About eleven and a half years ago, I opened Xcode and created a new project called TravelTimes. It was October 2014. Swift was three months old. I wanted a simple app that showed real-time Wisconsin DOT travel route data for my commute, and I wanted to learn this new language Apple had just dropped on the world.
The first commit message was "Initial project creation." Twenty-two days later, I submitted it to the App Store.
That app has been in continuous maintenance ever since. It survived the Swift 1.2 migration, the Swift 2.2 rewrite, the Swift 3 rename-everything apocalypse, and every major iOS release from 8 through 26. It picked up CocoaPods, then dropped them. It added Crashlytics, then Fabric, then Buddybuild, then removed all of them. It has been my quiet companion through a decade of platform evolution. It is the kind of side project that teaches you things your day job never will, precisely because nobody is watching and the stakes are low enough to experiment.
In April 2026, I rebuilt most of it in a week. Not because I mass-generated throwaway code, but because I had a collaborator that could keep up with the pace of my thinking.
The Spec That Started the Sprint
I have written before about the shift from vibe coding to spec-driven development: the idea that the highest-leverage thing you can do with an AI coding tool is give it clear constraints, defined interfaces, and unambiguous acceptance criteria before asking it to generate a line of code.
On April 6th, I wrote a feature spec for TravelTimes v9. It was not long (a page and a half), but it was specific. Here is the kind of thing it contained:
iCloud Favorites Sync. Migrate from UserDefaults to NSUbiquitousKeyValueStore. Create a FavoritesStore protocol so persistence can be faked in tests. Write to both local and cloud. Fall back to local when iCloud is unavailable. Handle one-time migration from local-only storage. Do not overwrite remote data with an empty local store on fresh install. Target: 8+ unit tests covering migration, fallback, and the empty-store edge case.
Every feature had a block like that. Integration points with the existing DataManager and AppDelegate were called out explicitly. Error handling expectations were stated, not implied. Test coverage targets were numbers, not vibes.
That spec became the contract between me and the AI for the entire week. Every session started with it. Every implementation was measured against it. The spec was not overhead. It was the force multiplier.
Six Features in Six Days
Here is what landed between April 6 and April 12.
iCloud Favorites Sync touches everything. The favorites store, the detail view's toggle, the settings screen, the Watch app: all of them read from the same source of truth, and I was about to move that source of truth to the cloud. Migrated from UserDefaults to NSUbiquitousKeyValueStore with a protocol abstraction so the persistence layer could be faked in tests. The system writes to both local and cloud, falls back gracefully when iCloud is unavailable, and handles the one-time migration from local-only storage. For conflict resolution, I went with last-write-wins keyed on the full favorites array. Simple, and the right trade-off for an app where favorites change infrequently and the data is cheap to rebuild. Eight unit tests cover the edge cases, including the critical "don't overwrite remote data with an empty local store" scenario, the one that would silently erase a user's favorites on a fresh install and make you wonder why your bug report says "all my routes are gone."
Active Incidents is where the AI saved me the most time and also got something meaningfully wrong. The WisDOT feed publishes roadwork and closure events, but matching them to the app's routes turned out to be harder than parsing JSON. The feed uses "WI-33" while route labels say "WIS 33." It uses "US-45" while labels say "US 45." A compound highway like "I-41/US 45" needs to be split and each segment normalized independently. Claude Code's first attempt at the roadway matcher handled the basic cases but missed the compound splitting entirely. It normalized the full string "I-41/US 45" as a single token, which matched nothing. Zero incidents displayed on I-41. I caught it during manual QA against the live WisDOT feed, described the compound pattern, and the second pass nailed it. The matcher grew to handle three dialect variants, twelve unit tests pin down the normalization logic, and now the Route Details screen shows collapsible incident groups sorted by severity. That is the feature that makes the app genuinely useful during Wisconsin's endless construction season.
Live Activities taught me that ActivityKit documentation and ActivityKit reality are two different things. The widget extension needs its own target. The NSSupportsLiveActivities key has to be in the main app's Info.plist, not the extension's. The extension's version and build number must match the host app exactly, or App Store Connect rejects the binary silently. No error, just silence. I burned a commit just surfacing the real ActivityKit error in a toast instead of a misleading "widget not installed" message, a debugging affordance that paid for itself immediately. The live activity shows route status on the lock screen, auto-expires after an hour to prevent stale data, and supports deep linking back into the app. Updates are pushed at 60-second intervals from the app process while it is in the foreground; there are no background push updates via APNs because the WisDOT feed does not support webhooks, and polling from a server I do not run was not worth the complexity for a side project.
Share Sheet was the small feature that forced the best refactoring. Extracting the relative-time formatting logic into a reusable RelativeTimeFormatter and building a ShareTextBuilder as a pure, testable struct resulted in eight unit tests. The kind of feature where the tests took longer to write than the implementation, and the codebase is better for it.
Route Details Redesign. The detail screen used to be a vertical stack of labels. It communicated data but not meaning. You had to read it to understand it. Replaced it with a horizontal three-column stats card, where each incident category renders as a tinted card row with a severity icon, count badge, and disclosure chevron. The stats card surface uses Liquid Glass, Apple's new material-based UI language in iOS 26: translucent, depth-aware, and layered in a way that establishes hierarchy without adding visual noise. The delay icon tint updates dynamically based on severity. Small details, but they transformed the screen from something you read into something that tells you at a glance whether your commute is going to be fine or terrible.
Apple Watch Companion has a personal history. The original TravelTimes had Watch support in March 2015, back when WatchKit was brand new and Swift was at version 1.2. I removed it years ago when watchOS moved to native apps. Bringing it back felt like closing a loop. It is a SwiftUI app running on watchOS 26 that shows favorited routes synced from the iPhone via WCSession.updateApplicationContext. When the session(_:didReceiveApplicationContext:) delegate fires on the Watch side, the complication reloads its timeline via WidgetCenter.shared.reloadAllTimelines(). The watch app can also send a "refresh" message to the phone, the phone refetches from WisDOT, and the fresh data flows back through the session. One-directional data flow, event-driven updates, no polling, no battery waste. Testing this required a real device pair. The Simulator does not surface the WCSession timing issues that matter, like what happens when the phone delivers a context update while the Watch app is backgrounded. On hardware, I confirmed the data arrives on next foreground with no user intervention.
The Bugs That Teach You Things
The feature work was only half the story. The other half was the parade of integration bugs that no amount of unit testing catches until you run the real thing.
The WisDOT feed omits plannedEndDate for indefinite incidents. My decoder required it, so a single missing field caused the entire event array to fail decoding. Every event disappeared. The fix was making the field optional and adding a regression test, but the real lesson was about defensive JSON decoding in a world where you do not control the API schema. This is a place where the AI's instinct was actually wrong. It generated a strict Codable model with no optionals, which is the textbook approach but the wrong one for a government feed with inconsistent data contracts.
The incident group header card intercepted touch events before they reached the expand/collapse button because the tinted background view was a subview of the button and isUserInteractionEnabled defaulted to true. The card looked right but did nothing when you tapped it. One line fix: card.isUserInteractionEnabled = false.
App Store Connect hung in "Processing" for hours because the Watch widget extension had an export artifact from a local archive build embedded in the bundle. Validation passed. Upload succeeded. It just never finished processing. Finding that one required inspecting the built product with find and noticing a file that should not have been there.
These are the kinds of bugs that take 15 minutes each once you know what to look for, but could eat an afternoon if you are encountering them for the first time.
What AI-Assisted Development Actually Looked Like
I have been using AI coding tools on this project since mid-2025. The commit history reads like a timeline of the space: Codex for early refactoring, Cursor for performance analysis, Devin for specific fixes, Kiro for documentation, and Claude Code for the sustained feature work that made up the v9 sprint.
A few things made the April sprint work, and a few things did not.
What worked: context management. Each day I started a new Claude Code session, fed it the feature spec and pointed it at the relevant source files. The spec was the continuity mechanism, not the AI's memory. When I described the iCloud sync requirement, the implementation accounted for the existing DataManager notification pattern, the DetailViewController favorite-toggle flow, and the AppDelegate launch sequence, because the spec described those integration points and the model could read the existing code. When the Live Activity toast showed the wrong error, the fix came with an understanding of the full error propagation chain from ActivityKit through the manager to the view controller.
What worked: speed on the well-specified path. The iCloud sync feature (protocol abstraction, dual-write strategy, migration path, eight tests) would have taken me a full weekend to build alone. Not because I did not know how; I have been writing NSUbiquitousKeyValueStore code since iCloud launched. The friction is in the typing, the test setup boilerplate, the context-switching between implementation and verification. With the spec and an AI collaborator, it landed in an evening session. That compression happened on every feature, and it compounded across the week.
What did not work: trusting the first pass on unfamiliar data. The WisDOT feed normalization and the Codable strictness issues both came from the same root cause: the AI generated plausible code that matched the documented behavior of the API, not the actual behavior. I caught both in testing. This is the pattern I see over and over: AI-generated code is correct against the spec but brittle against the real world. The human's job is to be the one who knows the real world is messier than the spec.
My review process: I read every line of generated code before committing. Not skimming. Reading. Every file, every test, every edge case handler. When something looked structurally wrong, I described the problem and asked for a revision rather than hand-editing. When something was a one-line fix, I just fixed it. The goal was to understand the entire codebase as if I had written it myself, because I was the one who would be debugging it at midnight when the WisDOT feed schema changed again.
Here is the thing I keep coming back to: AI-assisted development is not about generating code faster. It is about compressing the feedback loop between intent and working software. The features I built in six days were features I already knew how to build. I have been writing UIKit for over a decade. I know how WCSession works. I know how ActivityKit fails. The AI did not teach me those things. My 30 years of shipping software did. What the AI did was eliminate the friction between knowing what I wanted and having it exist.
Eleven Years in the Rearview
The TravelTimes repo has 179 commits from October 2014 to today. Sixty Swift files. North of 20,000 lines of code. The app went from Storyboards to fully programmatic UIKit. From NSURLConnection to URLSession with retry logic and request deduplication. From a flat table view to an adaptive split-view layout with sidebar navigation, design tokens, and Liquid Glass.
The contributor list includes me, buddybuild's SDK bot, GitHub Copilot, OpenAI's Codex, Cursor Agent, Devin AI, Kiro, and Claude Code. That is a strange sentence to write. It is also an honest representation of how software gets built in 2026.
I think about what 2014-me would make of this codebase. He wrote the whole thing in 22 days using a language he was learning as he went. He would not recognize the architecture: the design tokens, the split-view layout, the Watch connectivity, the live activity lifecycle. But he would recognize the impulse. It is still the same app: a table view that shows how long it takes to drive home. The engineering grew up around a simple idea, layer by layer, year by year, and this last week added more layers than any week before it.
The Side Project as Laboratory
Side projects have always been where I learn things first. TravelTimes taught me Swift before I used it professionally. It taught me Auto Layout when that was still painful. It taught me WatchKit, Today Extensions, and notification scheduling before any of those mattered at work.
Now it is teaching me how AI-assisted development actually works at the feature level. Not on greenfield demos or toy apps, but on a real codebase with real history, real App Store constraints, and real users who will notice if the incident data disappears because the JSON decoder choked on a missing field.
The laboratory has to be real, or the lessons do not transfer.
If you have worked through the context-management problem in Claude Code, built against government APIs with inconsistent data contracts, or developed your own trust-but-verify process for generated code, I want to hear what you learned. Find me on LinkedIn or check out TravelTimes's landing page.
Share this post
Related Posts
How I Built a Production Notification System for TravelTimes in One Day with Claude Code
From 'users want commute alerts' to 1,800 lines of shipped, App Store-ready code in a single coding session. A deep dive into architecture, edge cases, and what AI-assisted iOS development actually looks like.
From iOS to Android in a Weekend: How I Used Claude Code to Build a Complete App Without Writing a Single Line of Code
I took a production iOS app, pointed Claude Code at it, and had a fully functional Android app in eight hours over a weekend. Here's exactly how it worked.
The New Abstraction Layer: AI, Agents, and the Leverage Moving Up the Stack
Andrej Karpathy put words to something many engineers are quietly feeling right now. We've been handed a powerful alien tool with no manual, and we're all learning how to use it in real time.