What hora's First Public Beta Broke in Five Days
Five days into hora's public beta, real calendars exposed SwiftData hangs, OAuth edge cases, and sync assumptions my test account never could.

Five days after hora entered public TestFlight, the app had moved from build 71 to build 89. I had shipped three patch releases, closed roughly 24 issues, and learned how little a development calendar can tell you about calendar software.
This is a historical build note from April 2026. hora has since reached the Mac App Store, but the first beta week still explains several decisions in the current app: tighter SwiftData queries, background export work, defensive Google API handling, and a preference for real traces over guesses.
The beta did not reveal one spectacular failure. It revealed a cluster of ordinary assumptions that stopped being true on someone else's Mac.
Week one in numbers
- 0.6.0 build 71 to 0.6.1 build 89 in five days.
- Three patch releases built around failures from real use.
- About 24 issues closed, including bugs, interaction polish, and small missing workflows.
- Eight Sentry issue groups triaged in the first 48 hours.
- Four main-thread hang groups with the same underlying SwiftData pattern.
Those figures were not a growth dashboard. hora did not ship product analytics that recorded what people did in their calendars. I had crash and performance diagnostics through Sentry, plus direct reports from testers.
That distinction mattered to me. A calendar needs enough diagnostics to recover from failures without turning a person's schedule into product telemetry.
Sentry arrived one day after the people did
I had postponed diagnostics until the beta was already open. There was always another visible feature that felt more urgent.
Adding Sentry on April 25, one day after the first public build, was the most valuable correction of the week. The setup was modest: the Cocoa SDK, release identifiers based on MARKETING_VERSION and the CI build number, and an Xcode Cloud script that uploaded dSYMs.
Within hours, I had call stacks for failures I could not reproduce locally. Without them, the reports would have sounded like "the app sometimes freezes." With them, four apparently different hangs pointed to one shape.

Four SwiftData hangs, one pattern
The affected views did different jobs. One exported widget data. Another calculated invitation badges. A third prepared notifications. All held or fetched a broad set of Event models and then read properties from them on the main actor.
The sequence looked like this:
- A view or service accessed a large collection of events.
- A computed property iterated the collection.
- Reading
attendeesJSONfaulted models into memory and decoded data. - SwiftUI repeated the work during rendering or state updates.
With 30 events in my test account, the cost was invisible. With 3,000 events in a real account, it became a visible main-thread pause.
The worst example was the widget exporter. It fetched every event, then filtered the next 48 hours in memory while running on @MainActor.
The corrected direction was to fetch only the required time window in a dedicated context away from the main actor:
final class WidgetDataExporter {
func exportEvents(container: ModelContainer) async throws {
try await Task.detached(priority: .utility) {
let context = ModelContext(container)
let now = Date()
let end = now.addingTimeInterval(48 * 3600)
let descriptor = FetchDescriptor<Event>(
predicate: #Predicate {
$0.startDate >= now && $0.startDate <= end
}
)
let events = try context.fetch(descriptor)
// Encode the small result and update the widget files.
}.value
await MainActor.run {
WidgetCenter.shared.reloadAllTimelines()
}
}
}The same principle tightened invitation queries to events that actually contained attendee data and removed broad model iteration from notification and badge paths.
The rule I wrote down was simple: if a main-actor function iterates SwiftData models, assume it can perform I/O until proven otherwise.
Launch was also doing too much synchronously
One issue appeared during startup while the app opened its ModelContainer against a large cold store. The first thing affected users saw was a beachball.
I moved container preparation out of the synchronous launch path and kept a small launch screen visible until the store was ready.
That change looked cosmetic in a screenshot. It was architectural in use. "The app is loading" and "the app is frozen" can describe the same duration, but they are not the same product experience.
Google accounts brought their own history
My Google account had a fresh OAuth grant and predictable usage. Public testers arrived with older grants, multiple accounts, different Workspace policies, and much busier calendars.
Two failures were especially useful.
Missing OAuth scopes
A request returned 403 Insufficient authentication scopes even though sign-in itself had succeeded. Treating every 403 as a generic API error gave the user no path forward.
The fix was to recognize the missing-scope reason and request the required authorization again. Authentication had succeeded. Authorization for that operation had not.
Different forms of quota pressure
Google Calendar can report both general rate limits and per-user query limits. Retrying both in the same way meant the client could keep adding pressure to an already limited endpoint.
I added exponential backoff with jitter during the first beta week. A local token bucket followed on May 5, after the beta had shown why a successful retry should not release every waiting request at once.
This later became part of the broader sync architecture described in Real-time Google Calendar Sync on macOS and Google Calendar API vs CalDAV.
The small fixes mattered too
The first week was not only performance work.
- Escape began dismissing sheets, search, and the Go to Date overlay consistently.
- The menu bar showed how long an active meeting had left instead of hiding it at the start time.
- Changing a conference provider cleared the old link both locally and in Google Calendar.
- Event edits refreshed the local grid immediately instead of waiting for the next sync signal.
- Widget links survived a cold launch and opened the intended event.
- Signing out explicitly stopped live Google watch channels before deleting the OAuth token.
- The old focus-triggered refresh disappeared once push notifications made it redundant.
Each item closed a gap between "the feature exists" and "the workflow remains coherent."
What I paused
I had planned to move directly into natural-language Quick Add, focus planning, and in-app calendar management. Instead, feature work stopped while the 0.6 branch stabilized.
That was the correct choice. A new planning tool would not help someone whose existing calendar froze during launch.
Some roadmap ideas returned later in better form. Quick Add and Focus Time became part of hora 1.0. Others stayed out because beta feedback changed their priority. The 1.0 retrospective has the current version of that story.
What the first week taught me
You only see your own data distribution during development.
My calendar had dozens of relevant events. Testers had thousands. My OAuth grant was current. Theirs carried history. My API usage was calm. Theirs combined multiple accounts, widgets, menu bar updates, edits from phones, and colleagues moving meetings.
The most useful beta question was not "does this feature work?" It was "what size, state, or history makes this feature stop working?"
Sentry supplied the traces. Testers supplied the context. Neither would have been enough alone.
That first public week made the roadmap less exciting for a few days and made the app more dependable. I would make that trade again.
If you use Google Calendar on a Mac and want to try the current release, download hora Calendar from the Mac App Store.


