Imported from MikeMcQuaid/MinMaxCal (
AGENTS.md). Install upstream withnpx skills add MikeMcQuaid/MinMaxCal. Copyright stays with the author.
Agent Instructions for MikeMcQuaid/MinMaxCal
Most importantly: run script/style --fix before finishing any change,
read README.md and ARCHITECTURE.md before changing anything and
update them in the same commit when behaviour they describe changes.
This repository is readme-driven: documentation leads, code follows.
MinMaxCal is a native SwiftUI macOS menu bar app showing the next
calendar event or reminder, the coming agenda and a full-screen takeover
when something is due. See the Status section of README.md for the
slice order.
Write sentence-case imperative commit messages without
conventional-commit prefixes such as feat:, fix: or chore:.
Commands
script/bootstrap: installBrewfiledependencies and generateMinMaxCal.xcodeprojwith XcodeGenscript/build: build the app with xcodebuild, defaulting to Release (XCODE_CONFIGURATIONoverrides it), versioned from the nearest reachable tag and numbered bymain's commits;MinMaxCal.appin the repository root symlinks the Release outputscript/install: build, then copy the app into /Applications so the login item and the running copy survive rebuildsscript/zip: zip the built app asMinMaxCal-<version>.zipscript/package: sign the built app with the Developer ID certificate, notarise it and zip it; every step needs credentials from the environmentscript/test: run the unit testsscript/analyze: static analysis (SwiftLint analyzer and periphery for dead code and unused imports)script/style: run all linters;--fixalso applies safe fixesscript/icons: renderApp/Iconsto PNG previews in.test-scratchat menu bar and Dock sizes
Repository Structure
README.md: user-facing features; the product specificationARCHITECTURE.md: system design, packages and data flowsAGENTS.md: this file;CLAUDE.mdis a symlink to itPackage.swift,Sources/,Tests/: the Swift package targetsApp/: the app shell;project.ymldefines the XcodeGen target (the generated.xcodeprojstays gitignored);App/Iconsholds the icon sources andApp/Assets.xcassetsthe catalogueiOSApp/: the iPhone agenda shell; theMinMaxCal-iOSscheme sharesMinMaxCalAgenda, Data and Domain with the MacwatchOSApp/: the read-only watch agenda;MinMaxCal-watchOSshares the same model and rows and is embedded in the iPhone appscript/: development tasksBrewfile: development dependencies.github/workflows/tests.yml: CI;release.yml: the release pipeline, dispatched frommain
Code Standards
- Swift 6.4 with strict concurrency: App, Agenda and Features use
MainActor default isolation; Domain, Data and Intents are
nonisolated, and
EventKitCalendarSourceis the only actor - Every SwiftLint and SwiftFormat rule is enabled; disable per line with a comment explaining why (configuration excludes only rules that conflict with other enabled rules or tools, with reasons)
- SwiftLint needs the full Xcode selected via xcode-select; CommandLineTools alone cannot load SourceKit
- UK English (organised, colour) in documentation, comments and UI strings; proper nouns keep their official spellings
- Keep comments minimal; prefer self-documenting code
- Two-space indentation, four-space for Swift (see
.editorconfig)
UI Principles
- One implementation per concern: the agenda row, the join button, the complete button, the calendar marks and the item details (with its notes and location links) each have exactly one shared view used by the agenda and the takeover alike. Before adding a second approach to any such concern, get explicit confirmation.
- Use the macOS UI as the default on iOS. Share layout and behaviour, including time columns, truncation and details expansion; keep only platform sizing and system integration separate.
- The menu bar title is the app: it must be correct within a minute of any change and never wider than the configured limit. Anything that delays or widens it is a bug.
- Buttons follow Apple HIG and Liquid Glass, in that order, then this app's conventions: at most one primary action per surface, rendered prominent and bound to Return (Join, Complete); every other button is plain glass, icon-only with hover help when the icon is unambiguous and short text otherwise. Escape snoozes for the shortest duration, or dismisses when snooze is unavailable, from any display. In a row of actions the primary button trails and Dismiss leads, as in a system dialog.
- Button and menu titles use title case (
Choose Calendars…,5 Minutes); toggles, labels, footers and help use sentence case. - Every clickable row is a button for the pointer, the keyboard and
VoiceOver alike: it highlights under the pointer (a quaternary
fill, no animation, as a menu does),
focusable(interactions: .activate)lets Full Keyboard Access reach it, Return and Space activate it, and it is an accessible element with a label, a default action and a hint that contains its children (.contain, never.combine, which would swallow the buttons inside it). A takeover announces itself throughAccessibilityNotificationand marks its panel modal. - Presentation animates briefly (a 0.2 second fade) and not at all when Reduce Motion is on.
- A takeover is the one surface allowed to interrupt; everything else stays in the menu bar item and never steals focus or activates the app without a click. When a takeover is dismissed, completed or snoozed, the front goes back to the app that had it; Join leaves the front to the call's app.
Performance
The app is always running, so an idle minute is the budget.
- An idle minute costs arithmetic only: no EventKit fetch, file read, JSON decode or formatter, regex or detector construction. Fallback fetches run every five minutes on mains, fifteen on battery or in Low Power Mode; changes still fetch immediately. An empty agenda needs no minute timer.
- Trigger streams buffer
newest(1); a rebuild never queues behind another, however many notifications a sync posts. - Costly Foundation objects (
NSDataDetector,NumberFormatter, the HTML importer's output) are built once and cached; a view body never constructs one. - Anything a takeover panel shows is computed once, never once per display.
- Sleeps carry a tolerance where lateness is harmless (the minute
tick, five seconds) and none where it is not (the takeover alarm).
App Nap may slow the menu bar but never a takeover: the alarm holds
a
ProcessInfoactivity for its final five minutes and sleeps with zero tolerance. The app does not opt out of App Nap as a whole (NSAppSleepDisabledstays unset). - A rebuild publishes only the values that changed.
- Settings text fields commit on Return or focus loss
(
TextField(value:format:)), never per keystroke. - Measure on the host after
script/install: Activity Monitor's Energy Impact should idle near zero and Instruments (Time Profiler, SwiftUI, Energy Log) shows anything a unit test cannot.
Platform Notes
Hard-won on macOS 27 beta; check before assuming they expired.
-
EventKit exposes no travel time, no conference URL and no snooze;
README.mdrecords the decisions taken for each (no leave alert, links parsed from URL, location and notes, app-local snooze). Do not reach for private API or KVC to get around any of them. -
EKEvent,EKReminderandEKCalendarobjects must not cross threads; only Domain values leave the EventKit actor. -
Recurring events share
calendarItemIdentifier; identity always includesoccurrenceDate. -
calendarItemExternalIdentifieris the invitation's iCalendar UID and is the first merge key; it can be nil for local events. -
An
EKEventStorecreated without requesting access can build events for tests but cannot save, list calendars or set attendees. -
Calendar access cannot be granted inside a sandvault session: TCC prompts need the host user's GUI session. Agents run unit tests;
script/installand a real launch are how the host user checks the app against real calendars. -
LSUIElementapps open windows behind the frontmost app unlessNSApp.activate()runs first; Settings and the takeover do this, the agenda popover never does. -
MenuBarExtrain window style cannot be opened or closed from code; design nothing that needs it. -
SMAppService.mainAppregisters whatever path the running bundle has; only the /Applications copy may register, never a build directory. -
SwiftUI
List/Sectioncrash AppKit's outline diff when rows are removed conditionally; the agenda is a plainScrollView. -
A
Labelas theMenuBarExtralabel shows its icon only, and.task/.onAppearon that label never run; the label is a bareImagethenText, and the refresh loop starts from the app. -
The
MenuBarExtrawindow proposes no height to flexible views, so aScrollViewthere collapses to nothing; the agenda's carriesfixedSize(horizontal: false, vertical: true)under itsframe(maxHeight:)to size to its rows up to the cap. -
Text("\(someInt)")applies digit grouping; useString(_:). -
Trailing closures after multiline calls fight SwiftFormat; keep them single-line or make the closure a non-final argument.
-
Length-limit splits use cross-file extensions; same-file grouping extensions are banned by SwiftLint.
-
SettingsStore.changes(UserDefaults.didChangeNotification) is the cross-module signal bus for settings. The stream subscribes when first iterated, not when created, so start consuming before writing; a test that writes first hangs forever. -
UserDefaultsis notSendableon the macOS 27 SDK, which is whySettingsStoreis@MainActorrather than a nonisolated class. -
A MainActor type cannot conform to a
Sendableport in an isolated way (cannot form main actor-isolated conformance ... to SendableMetatype-inheriting protocol); test fakes guard their state withMutexfromSynchronizationinstead. -
Under MainActor default isolation a nested type gets the default too; a nonisolated enum's nested structs each need
nonisolatedbefore they can conform toFormatStyle. -
Swift
Regexis notSendable, so a nonisolatedstatic letregex is rejected;NSDataDetectorandNSRegularExpressionareSendableand may be statics. Domain builds its regex literals per call; Features holds them on MainActor types. -
SwiftLint's
nestingrule allows one level of nested types, so aParseableFormatStyleis its ownParseStrategyrather than nesting one. -
AppIntentinheritsSendableandperform()is nonisolated, so an intent cannot be a MainActor type, andnonisolated structrejects the@Parameter,@Propertyand@Dependencywrappers ('nonisolated' cannot be applied to mutable stored properties); intents live in the nonisolatedMinMaxCalIntentstarget and hop to the main actor withMainActor.run. App Shortcuts phrases must sit in the app bundle, soAppShortcutsis inApp/. A test bundle built for a newer macOS can reference AppIntents symbols the runner lacks (allowedExecutionTargets, macOS 27) and fail todlopen, so the Intents target weak-links AppIntents. Keep the deployment target at macOS 26 and gate any newer APIs by availability. -
NSAnimationContext.runAnimationGroup's completion handler is a nonisolated@Sendableclosure, so it cannot capture windows; take a@MainActor @Sendableclosure instead (which may capture them) and call it from an explicitly@Sendablehandler throughMainActor.assumeIsolated. -
NSRunningApplication.activate(from:options:)hands the front to another app only while this app is active; call it before the takeover windows fade, not after. -
A menu bar app with its popover closed is an App Nap candidate, and a napped app's timers run seconds late. Activity Monitor's Energy tab shows an App Nap column.
ProcessInfo.beginActivitywith.userInitiatedalso blocks idle system sleep; use.userInitiatedAllowingIdleSystemSleep. -
xcodebuild sandboxes the macro plugin server too, so
@Observablefails withproduced malformed responsein a sandvault session;script/buildpassesOTHER_SWIFT_FLAGS=$(inherited) -disable-sandbox. The$(inherited)matters: overriding the flags outright drops the package's default isolation settings. -
XcodeGen's
info.pathandentitlements.pathgenerate those files; hand-written ones are referenced throughINFOPLIST_FILEandCODE_SIGN_ENTITLEMENTSand excluded from the sources. -
SwiftFormat's
propertyTypesrule guesses a type from a static member's name, so a factory such asFixtures.ledgerStore()needs an explicit type annotation or it becomes: Fixtures. -
--enable allin.swiftformatcannot be narrowed by--disable; clashes are settled on the SwiftLint side or per line. -
rmis interactive in the sandbox shell; userm -fin scripts and agent commands or a deletion silently does nothing. -
cannot execute tool 'metal' due to missing Metal Toolchaincan break app builds inside a sandbox when the host user has the toolchain mounted. Eject the host user's mount, as the host user, then ask for the component again inside the sandbox:cd ~/Library/Developer/DVTDownloads/MetalToolchain/mounts diskutil eject <hash> xcodebuild -downloadComponent MetalToolchain # in the sandbox
Required Before Each Commit
- Run
script/style --fixand resolve anything it cannot fix - Run
script/testandscript/analyzewhen Swift changed - Run
script/iconsand look at the output when an icon changed - Reread changed documentation for UK English, working links and 72-column wrapping of this file
- Confirm
README.mdandARCHITECTURE.mdstill describe behaviour after your change
Key Guidelines
- Documentation first: update
README.mdandARCHITECTURE.mdbefore writing code whose behaviour they describe. - Anything calendar or reminder related that the public system APIs do not provide is a decision for the user, not a computation to invent: ask before building a substitute.
- Derive everything from EventKit on demand; cache nothing across launches except the selection, the rules, the takeover ledger and sync ownership. Within a launch the stores that own those files keep a copy in memory and write through.
- Keep dependency directions clean: Domain depends on nothing, Data and Features depend on Domain and App composes them.
- Treat invitation content as untrusted: titles, notes and URLs render as text and only the detector's own URLs are ever opened.
- Follow YAGNI and DRY: build only what the current slice needs and inline variables and functions used only once. For non-trivial parsing, prefer widely used, well maintained libraries, Apple's own first, over bespoke reimplementations.
- Never place app files in bare
/tmp: per-user scratch belongs in that user's macOS temporary directory and test scratch in the gitignored.test-scratchof the checkout, which each run sweeps. - Keep diffs minimal and follow existing structure.
