Skip to content

Six QA Groups and the Week hora Stopped Feeling Fragile

A founder note from hora 0.6: recurring deletes, widget deep links, RSVP gestures, editor polish, and the SwiftData migration I reverted.

6 min read
Six QA lanes converging into a stable hora Calendar build on a dark Mac interface

Most of this week was not about shipping a feature. It was one QA pass split into six groups, A through F.

At the time, hora was still approaching its first public beta. The app is now available on the Mac App Store, but this was the week it began to feel less like a project running on my own machine and more like something another person could trust with a real calendar.

The work was not glamorous. A recurring event could return after deletion. RSVP buttons occasionally ignored a click. A widget opened the correct day but left you hunting for the event. None of these bugs looked dramatic in a roadmap. Each one could make a calendar feel unreliable.

Here is what I fixed, what I reverted, and what that week changed about the way I test hora.

Group A: deletes that came back from the dead

The report was short: "I delete one occurrence of a recurring event, refresh, and it comes back."

Google Calendar can return 410 Gone when a client deletes an occurrence that has already been cancelled. That is normal for recurring-event exceptions because a previous sync may already have applied the same deletion.

My delete path treated every non-2xx response as failure. The repository then tried to be helpful by restoring the local event with syncState = .failed.

The server had accepted the deletion. hora undid it locally.

The fix was small once I understood the sequence. For a delete request, 404 and 410 are idempotent success states:

if (200..<300).contains(status) || status == 404 || status == 410 {
    return
}

That same pass caught a second trust-breaking edge case. If the saved default calendar had been hidden, the editor could create an event successfully and then make it appear to vanish. The editor now falls back to the first visible writable calendar.

The delete path treats an already removed recurring occurrence as success instead of restoring it locally.

Tapping an event in the desktop widget or menu bar originally opened Today. Technically, the link worked. Practically, the user still had to find the right hour and reopen the event.

The corrected flow became:

  1. The widget opens hora://event/<id>?date=<iso8601>.
  2. The main window switches to Day view.
  3. Once the calendar view exists, hora broadcasts the event ID.
  4. Day view scrolls to the correct hour and opens the event popover.

The handoff uses two stages because the scroll target and popover do not exist when macOS first delivers the URL. Trying to make one notification do everything produced a race. Waiting for the view and then focusing the event was less clever and more reliable.

I also separated the meeting Join action from the enclosing widget link. Nested Link views compete for the same click on macOS widgets. Sibling links behave predictably.

Group C: one editor, two containers

The event editor appeared as both a sheet and a popover. It was the same SwiftUI view inside two containers with very different behavior.

ToolbarItem(placement: .principal) looked correct in a sheet and disappeared in a popover. NavigationStack fixed part of that, then reserved an empty toolbar strip above the form.

I stopped trying to persuade the toolbar to be universal. The title moved into the content and Cancel and Save moved into a bottom action row. It looked the same in both places because it was now the same layout.

The description field received the same treatment. A fixed 100-point TextEditor wasted space for short notes and gave long notes an awkward scrollbar. The replacement measures document height, clamps it between 30 and 150 points, and keeps scrolling only for genuinely long content.

This was a useful reminder: sharing a view does not mean every platform container shares the same layout contract.

Group D: matching Mac muscle memory

Double-clicking an empty calendar slot created an event at the nearest 15-minute point. That was mathematically faithful to the cursor and wrong for the interaction.

Apple Calendar creates a full-hour event from the same gesture. People already carry that expectation. hora now snaps a double-click to the hour, while drag-to-create remains precise.

The navigation chevrons had a similar problem. A visually 28 by 28 point button only accepted clicks on the visible glyph because it used a plain button style. Adding a rectangular content shape made the whole control clickable.

Neither fix is impressive in a feature list. Both make the app feel less like it is asking the user to adapt to it.

Group E: RSVP buttons versus row gestures

Invitation rows supported single-click selection and double-click expansion. Inside each row sat Accept, Maybe, and Decline buttons.

The parent row used onTapGesture, which sometimes won the gesture contest and swallowed the child button click. The response would appear only after a later sync corrected the local UI.

Replacing the parent handlers with simultaneous gestures let the row observe clicks without taking ownership away from the buttons:

.simultaneousGesture(TapGesture(count: 1).onEnded { onTap() })
.simultaneousGesture(TapGesture(count: 2).onEnded { onDoubleTap() })

The lesson was broader than RSVP. A calendar row often contains several interaction levels. Parent gestures should not make child controls probabilistic.

Group F: small escape hatches

Privacy and Terms still pointed to old URLs. Feedback still opened the GitHub issue tracker. Those links moved to the current pages and hello@horacal.app.

I also added a Login Screen action to the developer menu. It signs out every test account and returns the app to onboarding. It saves less than a minute per run, but onboarding bugs are much easier to reproduce when resetting the state is boring.

The SwiftData migration I reverted

One change looked harmless: adding an explicit inverse relationship annotation to Event.calendar. SwiftData had already inferred that relationship, so there was no functional gain. I still bumped the schema, added a lightweight migration stage, and shipped it to testers.

A real store crashed during launch while macOS attempted the migration.

HoraApp.init()
  -> Schema(versionedSchema: HoraSchemaV2)
  -> NSPersistentContainer load
  -> migrateStoreWithContext
  -> NSLightweightMigrationStage.init
  -> abort()

I reverted the schema to V1 and deleted the migration. Cascade deletion continued to work because the inverse had already been inferred.

The important part was not that SwiftData had a sharp edge. It was that I had accepted migration risk for an annotation that changed nothing for the user.

"The change is tiny" is not a safety argument for a persistent store. The first question should be whether the migration needs to exist at all.

What this QA week changed

Before this pass, I grouped work by feature: editor, widget, invitations, sync. The failures did not respect those boundaries. A delete bug crossed the Google API, repository, and local state. A widget bug crossed URL handling, view lifecycle, scrolling, and presentation.

From then on, I started testing complete promises instead:

  • Delete an occurrence and make sure it stays deleted after a fresh sync.
  • Tap a widget event and make sure the exact event is visible.
  • Respond to an invitation and verify both the immediate UI and server state.
  • Open an old data store before trusting a schema change.

That shift carried into the first public beta retrospective, where real accounts exposed performance assumptions my development calendar never could. It also shaped the later work on Google Calendar sync.

hora has moved far beyond 0.6, but the product standard is still the same: calendar actions must remain true after the next sync, not only look successful for a moment.

If your schedule lives in Google Calendar and you want to use the current version, try hora Calendar on the Mac App Store.

Related stories

What hora's First Public Beta Broke in Five Days
Build notes

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.

Maciej Szamowski6 min read
Week One Building hora: 107 Commits Later
Build notes

Week One Building hora: 107 Commits Later

A candid snapshot of hora's first ten days: 107 commits, three Swift packages, 21 resolved issues, useful workflows, and plenty still rough.

Maciej Szamowski7 min read
Building a Native Mac Calendar App in 48 Hours
Build notes

Building a Native Mac Calendar App in 48 Hours

What I cut, rebuilt, and shipped during a 48-hour sprint on hora, including PKCE auth, Day View, FreeBusy sharing, and WidgetKit.

Maciej Szamowski6 min read
Mobile Beta Sign-up

Want to stay updated?

Subscribe for information about iOS/iPadOS beta availability.