Release 1.9.0

- Sidebar-navigation redesign: NavigationSplitView with Routes / Schedule /
  Activity Log replaces the 2x2 panel-card grid. Primary actions moved to
  the toolbar (dry-run/write, sync status, Sync Now); Export/Import/Reveal
  Log/Cleanup/Refresh/Recheck Permission moved to a toolbar overflow menu.
- Routes are now a collapsed-by-default list -- click a row to expand its
  editor instead of always showing every field for every route.
- Schedule view leads with live auto-sync status; the manual launchd
  schedule is demoted to an explicit optional section underneath.
- Activity Log is readable rows (status icon + text) with a search filter
  and a Clear button, instead of a monospaced text dump.
- Menu bar dropdown got icons and real Cmd+,/Cmd+Q shortcuts.
- Explored as a design mockup first (published as an Artifact, light+dark),
  direction confirmed before writing any SwiftUI.

Fixed along the way: routes were being silently dropped on most launches.
reloadCalendars() pruned routes against a freshly-created EKEventStore's
calendar fetch right after a permission grant, which can under-report
calendars for a moment (especially remote/Exchange accounts) before they
finish loading -- wrongly concluding a route's calendar was gone. Only
survived because of a routes.v1 legacy backup key that itself never got
updated after its first write, so any real route edit made in an affected
session would have been silently reverted on the next launch. Fixed by
skipping the prune specifically on the post-grant reload
(reloadCalendars(pruneRoutes: false)) and keeping the legacy backup current
on every save instead of frozen.

All unit tests pass. Verified on a real install: routes stopped
disappearing across repeated launches (checked via --status and reading
the UserDefaults plist directly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 09:37:21 +02:00
co-authored by Claude Sonnet 5
parent 4e29b3716c
commit 77e3eeaf7b
7 changed files with 443 additions and 306 deletions
+41 -24
View File
@@ -15,6 +15,8 @@ struct RoutesSectionView: View {
let canAddRoute: Bool
let onAddRoute: () -> Void
@State private var expandedRouteID: UUID?
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
@@ -56,36 +58,51 @@ struct RoutesSectionView: View {
@ViewBuilder
private func routeCard(for routeBinding: Binding<Route>) -> some View {
let route = routeBinding.wrappedValue
let isExpanded = expandedRouteID == route.id
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
Button {
expandedRouteID = isExpanded ? nil : route.id
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12)
.padding(.top, 3)
VStack(alignment: .leading, spacing: 8) {
sourceSummaryView(for: route)
targetSummaryView(for: route)
}
Spacer(minLength: 12)
}
Spacer(minLength: 12)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove") }
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider()
if isExpanded {
Divider()
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
Toggle("Private", isOn: routeBinding.privacy)
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
Toggle("Copy description", isOn: routeBinding.copyNotes)
.disabled(isRunning || route.privacy)
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
.disabled(isRunning)
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
.disabled(isRunning)
.help("Mirror all-day events for this source.")
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
HStack(spacing: 16) {
mergeGapField(for: routeBinding)
overlapPicker(for: routeBinding)
Spacer(minLength: 0)
Button(role: .destructive) {
routes.removeAll { $0.id == route.id }
} label: { Text("Remove Route") }
}
}
}
.padding(12)