Release 1.6.1

- Sync reminders: new "Sync reminders when mirroring" option (global and
  per-route) copies source event alarms into mirrored placeholders. CLI flag
  --sync-reminders.
- CLI polish: --help, --list-calendars, --status (all support --json).
  Scheduled/headless runs record last-run time/result/summary. Failure paths
  now exit with distinct nonzero codes (2 = no calendar access, 3 = no saved
  routes) instead of always exiting 0.
- Fix settings silently wiped on upgrade: SettingsPayload's auto-synthesized
  Codable threw on any settings blob missing a field added since (e.g. the
  new syncReminders key), failing the whole decode and letting the next
  autosave persist an empty state over real routes/filters. Custom decode
  now falls back per-field like Route already did, and loadSettingsFromDefaults
  recovers routes from the legacy routes.v1 key if settings.v2 comes back
  empty, repairing installs already hit by this.
- Roadmap: event-driven background sync (SMAppService + EKEventStoreChanged +
  wake notification) and an external MCP wrapper around the CLI queued next;
  decided against direct Google/CalDAV API integration since EventKit already
  covers it via System Settings accounts.

All 47 unit tests pass (45 existing + 2 new SettingsPayloadTests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 13:10:23 +02:00
co-authored by Claude Sonnet 5
parent ad6ae396da
commit 61cea918a6
11 changed files with 455 additions and 32 deletions
+20 -4
View File
@@ -5,14 +5,15 @@ final class BlockMathTests: XCTestCase {
private let d = Date(timeIntervalSince1970: 0)
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil) -> Block {
private func block(_ startMin: Int, _ endMin: Int, id: String? = nil, alarmOffsets: [TimeInterval]? = nil) -> Block {
Block(
start: d.addingTimeInterval(TimeInterval(startMin * 60)),
end: d.addingTimeInterval(TimeInterval(endMin * 60)),
srcStableID: id,
label: nil,
notes: nil,
occurrence: nil
occurrence: nil,
alarmOffsets: alarmOffsets
)
}
@@ -44,6 +45,21 @@ final class BlockMathTests: XCTestCase {
XCTAssertTrue(mergeBlocks([], gapMinutes: 10).isEmpty)
}
func testMergeBlocksPreservesFirstAlarms() {
let b1 = block(0, 10, alarmOffsets: [-900, -3600])
let b2 = block(10, 20, alarmOffsets: [-600])
let merged = mergeBlocks([b1, b2], gapMinutes: 0)
XCTAssertEqual(merged.count, 1)
XCTAssertEqual(merged[0].alarmOffsets, [-900, -3600])
}
func testBlockEqualityIgnoresAlarms() {
let b1 = block(0, 10, alarmOffsets: [-900])
let b2 = block(0, 10, alarmOffsets: [-1800])
XCTAssertEqual(b1, b2)
XCTAssertEqual(Set([b1, b2]).count, 1)
}
func testMergeBlocksUnsortedInput() {
let blocks = [block(30, 40), block(0, 10), block(10, 20)]
let merged = mergeBlocks(blocks, gapMinutes: 0)
@@ -150,8 +166,8 @@ final class BlockMathTests: XCTestCase {
}
func testUniqueBlocksByIDDifferentOccurrence() {
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600))
let b1 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d, alarmOffsets: nil)
let b2 = Block(start: d, end: d.addingTimeInterval(600), srcStableID: "a", label: nil, notes: nil, occurrence: d.addingTimeInterval(3600), alarmOffsets: nil)
let result = uniqueBlocks([b1, b2], trackByID: true)
XCTAssertEqual(result.count, 2)
}
@@ -0,0 +1,64 @@
import XCTest
@testable import BusyMirror
final class SettingsPayloadTests: XCTestCase {
// Only the fields present in the very first schema. Every key added since
// (syncReminders, filterByWorkHours, workHoursStart/End, excludedTitleFilters,
// excludedOrganizerFilters, mirrorAcceptedOnly, selectedSourceID/TargetIDs,
// appVersion, exportedAt) is deliberately missing here, simulating a
// settings.v2 blob written by an older build.
private let oldSchemaJSON = """
{
"daysBack": 3,
"daysForward": 10,
"mergeGapHours": 1,
"hideDetails": false,
"copyDescription": true,
"mirrorAllDay": true,
"overlapMode": "skipCovered",
"titlePrefix": "🪞 ",
"placeholderTitle": "Busy",
"autoDeleteMissing": true,
"routes": []
}
"""
func testDecodeOldSchemaMissingNewerKeysDoesNotThrow() throws {
let data = oldSchemaJSON.data(using: .utf8)!
let payload = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
// Fields present in the old blob are preserved.
XCTAssertEqual(payload.daysBack, 3)
XCTAssertEqual(payload.daysForward, 10)
XCTAssertEqual(payload.overlapMode, "skipCovered")
// Fields missing from the old blob fall back to their defaults instead
// of failing the whole decode.
XCTAssertEqual(payload.syncReminders, false)
XCTAssertEqual(payload.filterByWorkHours, false)
XCTAssertEqual(payload.workHoursStart, 9)
XCTAssertEqual(payload.workHoursEnd, 17)
XCTAssertEqual(payload.excludedTitleFilters, [])
XCTAssertEqual(payload.mirrorAcceptedOnly, false)
XCTAssertNil(payload.selectedSourceID)
XCTAssertNil(payload.selectedTargetIDs)
}
func testEncodeDecodeRoundTrip() throws {
let route = Route(sourceID: "a", targetIDs: ["b"], privacy: true, copyNotes: false, syncReminders: true, mergeGapHours: 1, overlap: .allow, allDay: false)
let original = ContentView.SettingsPayload(
daysBack: 2, daysForward: 5, mergeGapHours: 0, hideDetails: true, copyDescription: false,
mirrorAllDay: false, filterByWorkHours: true, workHoursStart: 8, workHoursEnd: 18,
excludedTitleFilters: ["standup"], excludedOrganizerFilters: [], mirrorAcceptedOnly: true,
overlapMode: "allow", titlePrefix: "🪞 ", placeholderTitle: "Busy", autoDeleteMissing: true,
routes: [route]
)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(ContentView.SettingsPayload.self, from: data)
XCTAssertEqual(decoded.daysBack, original.daysBack)
XCTAssertEqual(decoded.excludedTitleFilters, original.excludedTitleFilters)
XCTAssertEqual(decoded.routes.count, 1)
XCTAssertEqual(decoded.routes[0].sourceID, "a")
}
}