The v0.6 QA grind: six groups, one week, a lot of taps
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:
hora://event/<id>?date=<iso8601>— thedateparam lets the widget skip a SwiftData lookupMainWindowhandles.openEventInApp, switches to Day, waits ~350ms for the view to mount- Broadcasts a second notification
.focusEventInCalendarwith the event ID DayViewscrolls itsScrollViewReaderto that hour;EventPopoverModifieropens 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.
ToolbarItem(placement: .principal)rendered correctly in the sheet but was invisible inside.popover. Moved the title to an inline centredTextabove the form — identical in both contexts.NavigationStackreserved an empty toolbar strip above the centred header in the popover. Dropped it entirely, moved Cancel/Save into a bottom action bar.- The description field was a
TextEditorwith a hardcoded 100pt box. Short notes wasted space, long notes showed a scrollbar styled for macOS Sequoia's default (black thumb in light mode — the "wrong colour" the user flagged). Now it's dynamic: measured viadocument.body.scrollHeightthroughWebPage.callJavaScript, clamped between 30 and 150pt. Injected::-webkit-scrollbarCSS so the scrollbar thumb matches the current colour scheme.
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.