An iOS and watchOS app that tracks return-to-office attendance against a configurable target without making a single network call. The engineering story behind the app at /deskdays/.
DeskDays is the kind of app that nobody asks for until they need it. Your employer announces an RTO mandate. You are required to be in the office some percentage of your workdays, with rules about which days count, how sick days are treated, what the reporting period is, and whether your two-week vacation pulls your numerator down or not. The compliance lives on a spreadsheet somewhere in HR and shows up in your performance review with no warning.
DeskDays is the spreadsheet, but it lives on your phone, it understands the policy, and it never tells anyone anything. That last part is the constraint that shaped every other decision in the app.
Why this matters
DeskDays is small on purpose, but it exercises the parts of Apple-platform engineering that break real apps: local persistence, schema migration, watch sync, entitlement boundaries, background triggers, privacy constraints, and testable business logic. The product is simple enough to explain and complicated enough to prove taste.
The privacy claim is the architecture
Most attendance-tracking products solve this problem by uploading your location, your WiFi network, your calendar, or all three to a server that runs the math. The data leaves the device, an operator has access to it, and the privacy policy explains in soothing language that they will probably not abuse it.
DeskDays does not have a server. There is no account creation, no sign-in, no cloud sync, no analytics SDK, no advertising framework, no crash reporter that phones home, no third-party dependency of any kind. The entire app is Swift, SwiftData, and a small handful of Apple frameworks. The privacy claim is enforced by the absence of code that could violate it, not by a policy document.
This sounds like marketing copy, but it is a real engineering constraint. It rules out:
- Any background sync between iPhone and Apple Watch via iCloud, which would force the data through Apple’s servers
- Any crash analytics, which would require a third-party SDK
- Any A/B testing or feature flagging that runs against a remote config
- Any “share progress with a coworker” feature that does not happen entirely device-to-device
The benefit is that the privacy claim survives any audit. The cost is that you write more code.
SwiftData schema versioning, the careful way
DeskDays stores everything locally in SwiftData, Apple’s modern persistence framework. The schema is small (essentially UserSettings and DayStatus) but the migration story is where this gets interesting.
The app is on its third schema version. V1 and V2 are frozen as snapshot classes that point to copies of the model types as they existed at the time. V3 is the live schema and points directly at the current UserSettings and DayStatus classes. A DeskDaysMigrationPlan declares the path from V1 to V2 (added reminder and fallback fields) and from V2 to V3 (live types, lightweight migration).
The non-obvious rule, learned the hard way: never change what model types a frozen schema version points to. SwiftData tracks model class identity, not just property layout. If the on-device database was written with V3 referring to UserSettings.self and you change V3 to point at UserSettingsV3.self, the next launch sees a schema mismatch, the migration fails, and the catch block in ModelContainer.standard nukes the user’s data. There is an assertionFailure in that catch block to crash the debug build loudly, because silent data loss is worse than a crash.
Additive changes (a new property with a default value) do not need a migration stage. The example in the codebase is fiscalYearStartMonth: Int on UserSettings, added without a schema bump because SwiftData and SQLite handle additive changes themselves.
Anyone who has shipped a Core Data app has a war story about the migration that ate the test data. The DeskDays approach (frozen snapshots, explicit version pointers, an assertion that crashes loud) is the boring discipline that prevents the war story.
Watch sync without iCloud
The Apple Watch companion is a Pro feature. The watch shows your current status, lets you check in from your wrist, and reflects the iPhone’s view of the world.
The obvious way to share data between the iPhone app and the watch app is shared SwiftData (App Group, shared store, both sides read and write). The problem is that the easy version of this routes through iCloud, which violates the privacy claim. The version that does not route through iCloud (App Group with local-only SwiftData) works in theory but fights you on every model change.
DeskDays uses WatchConnectivity instead. The iPhone is the source of truth. PhoneSessionManager builds a small summary dictionary (current period state, today’s status, target percentage, on-track indicator) and ships it to the watch via updateApplicationContext. The watch’s WatchSessionManager receives the dictionary and renders. Check-ins from the watch travel back as sendMessage commands. Every data-mutation point on the iPhone calls PhoneSessionManager.shared.sendSummary() so the watch is always within one update of correct.
This is more code than the shared-SwiftData approach. It is also the only architecture that keeps the privacy claim true and that keeps the watch app’s storage trivial.
Five check-in methods, one decision tree
The free tier has manual check-in: tap a day, pick a status. The Pro tier adds four more methods, and the design challenge is that they have to compose without producing a check-in storm.
- Geofence check-in uses CoreLocation to fire a local check-in when you cross into your configured office radius. The location data is never stored. The geofence boundary is the only thing the app retains.
- WiFi check-in reads the name of your current WiFi network and compares against your configured office SSID. The SSID is stored once in settings and never logged.
- NFC check-in scans an NFC tag stuck near your desk or the office door. The tag payload is matched against your configured tag identifier.
- QR check-in scans a QR code generated by the app or printed by a coworker. Same matching logic as NFC.
All four methods run through the same internal API: each one produces a “check-in event” with a source, the app deduplicates events for the same day, and the manual override always wins. If the geofence and WiFi both fire as you walk into the building, the user sees one check-in. If a smart reminder later marks the day as remote because you forgot to log it manually and were not at the office, the geofence event would have already won and the reminder is suppressed.
The user-facing surface is a single setting toggle for each method plus a configuration screen for the matching parameters. The internal architecture is one event pipeline with five inputs.
StoreKit 2 freemium gating
The free tier exists because compliance tooling that costs money the day you install it is a non-starter for most users. The Pro tier exists because shipping an iOS app costs more than zero dollars and the math has to work.
StoreManager.shared.isPro is the single source of truth for entitlement. Every Pro feature gates on it at the boundary: the geofence settings page is hidden if not Pro, the watch app shows an upsell screen if not Pro, the burndown chart prompts to upgrade. There is no client-side license cracking surface because there is no license check; StoreKit 2 transactions are validated against the App Store and the result is cached locally.
A two-week free trial is wired through subscription product configuration in App Store Connect, not through bespoke logic in the app. Apple handles the trial expiry, the receipt refresh, and the cross-device entitlement; the app reads the result.
Tests, project structure, and Tuist
The project is generated by Tuist (.xcodeproj and .xcworkspace are gitignored, regenerated on every clean build). Seven targets: iOS app, iOS keyboard extension, watchOS app, and four test targets.
Test coverage is in the high hundreds: roughly 180 iOS unit tests, 200 iOS UI tests, and 110 tests in the DeskDaysCore package alone. The package split (a DeskDaysCore SPM package for business logic, a DeskDaysUI package for shared components) lets the period-calculation logic run in the fast SPM test loop without touching SwiftUI or SwiftData. The math gets the most tests because the math is what users will notice if it is wrong.
What this proves
Three things this case study is meant to demonstrate, concretely:
- Privacy architecture as a constraint, not a marketing claim. The reason the app has no network calls is that the code that would make them does not exist. The privacy policy is a description of the binary, not an aspiration.
- SwiftData migration discipline. Frozen schema snapshots, explicit version pointers, a crash-on-failure catch block, and additive-versus-structural change rules. Boring, careful, and the reason the app has not eaten anyone’s data.
- Modern Apple platform breadth. SwiftData, SwiftUI, StoreKit 2, WatchConnectivity, CoreLocation, CoreNFC, the Network framework, App Intents, WidgetKit, Tuist, and a multi-target test setup, all in one shipping app.
For visitor-facing screenshots and feature detail, see the feature page.