← All posts

2026-04-13

The v0.6 QA grind: six groups, one week, a lot of taps

swiftuimacosswiftdatagoogle-calendarqadevlog

Most of last week was not "ship a new feature." It was a single QA pass, split into six groups (A–F). Unsexy work — but the kind that makes the difference between "technically works" and "you can actually live in this app."

Here's what came out of it.

A — Deletes that came back from the dead

The bug report: "I delete a recurring instance, refresh, it reappears." Reproduced, stared at it, then found it in the logs. Google Calendar returns 410 Gone when you DELETE an event that's already cancelled (which happens constantly for recurring instance exceptions, because the previous sync may have applied the same deletion).

My GoogleCalendarService.deleteEvent was throwing on anything non-2xx. CalendarRepository.deleteEvent caught the throw and helpfully re-inserted the event with syncState = .failed. The delete succeeded on the server. My app then undid it locally.

One-line fix: treat 404 and 410 on DELETE as idempotent success.

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

Same pass: if your stored defaultCalendarID points to a calendar where showEvents == false, the editor now falls back to the first visible writable calendar. Otherwise you'd create an event and never see it again.

B — Widget and menubar deep-links that actually land

Before: tapping an event from the menu bar extra or the widget navigated you to Today. That's it. You still had to scroll to the right hour and click the event yourself. The widget also had no per-event deep-link — every tap resolved to hora://today.

Now the flow is:

  1. hora://event/<id>?date=<iso8601> — the date param lets the widget skip a SwiftData lookup
  2. MainWindow handles .openEventInApp, switches to Day, waits ~350ms for the view to mount
  3. Broadcasts a second notification .focusEventInCalendar with the event ID
  4. DayView scrolls its ScrollViewReader to that hour; EventPopoverModifier opens the popover when its event's ID matches

Two notifications instead of one because the scroll target and the popover don't exist at the moment the URL arrives.

The Join pill needed its own care: a Link nested inside another Link on macOS widgets swallows one of the taps. Made them siblings.

C — Editor polish (the toolbar that shouldn't exist)

The event editor has two lives: as a sheet (from the "+" button) and as a popover (from drag-to-create on the calendar grid). Same SwiftUI view, two containers, two completely different quirks.

D — Double-click snaps to the full hour

Apple Calendar: double-click an empty slot, you get an event starting on the hour. hora was creating events at the nearest 15-minute mark inside the cursor's hour — so 13:15, 13:30, 13:45 depending on where exactly you clicked. Correct to the pixel, wrong for muscle memory.

Also: chevron nav buttons (day/week/month headers, mini-calendar) used .buttonStyle(.plain) on bare Image labels, so hit-testing only caught the glyph pixels. A 28×28 button was actually ~14×14 of tappable area. .contentShape(Rectangle()) on the label fixed it.

E — RSVP buttons that refused to respond

This one was mean. The Invitations sidebar cells had .onTapGesture(count: 1) and .onTapGesture(count: 2) at the row level to handle select and expand. Inside each row were three RSVP buttons: Accept, Maybe, Decline, each a Button(.plain).

The taps on the buttons sometimes landed, sometimes were eaten by the parent row — and on the eaten ones, the RSVP only appeared after the next background sync repaired the UI. From the user's side it looked like the app was just broken.

SwiftUI's gesture composition on macOS: .onTapGesture on a parent competes with child Button hit-testing. .simultaneousGesture(TapGesture()) doesn't.

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

Inner buttons keep their own hit handling; row-level select/expand fire alongside.

F — Legal URLs and a Dev Menu escape hatch

Privacy/Terms in the About tab pointed at the old paths. Feedback still linked to the GitHub issue tracker. Fixed to horacal.app/privacy, horacal.app/terms, mailto:hello@horacal.app.

Added a "Login Screen" action in the Developer tab — signs out all accounts and returns to LoginView. Sounds trivial; saves me 30 seconds every time I need to reproduce an onboarding bug.

The one I had to revert

Earlier in the cycle I landed an annotation-only SwiftData change: adding an explicit @Relationship(inverse: \Calendar.events) on Event.calendar. SwiftData had already auto-inferred that inverse. Bumped the schema to V2, wrote a migration plan with a lightweight stage.

Shipped it. A real user's app crashed on launch:

HoraApp.init() → Schema(versionedSchema: HoraSchemaV2) →
NSPersistentContainer load → migrateStoreWithContext →
NSLightweightMigrationStage.init → dealloc (exception) → abort()

On macOS 26, NSLightweightMigrationStage.init aborts when opening an existing V1 store against V2 — even when the only change is an annotation SwiftData was already inferring. Zero functional improvement, 100% migration risk.

Reverted the whole thing. Schema back to V1, HoraSchemaV2 deleted. Cascade delete still works because the inverse is auto-inferred.

The lesson is one I keep relearning: with SwiftData migrations, "the change is tiny" is not a safety argument. The only safe migration is the one you didn't need.

What's next

v0.6 is closing out. The last QA items are minor, TestFlight has a healthy group on it, and Google's OAuth verification is finally moving. After that: drag-and-resize polish for events, then a real push on performance for accounts with thousands of events.

Also — I finally fixed the appearance-switching bug from last week's post. Write-up on that going up next.


Follow the build at @moto_szama, check out hora Calendar, or reach out at hello@horacal.app.

Stay in the loop

Get notified when hora launches. No spam.

or follow along
@moto_szama Star on GitHub