WidgetData
The data the main app shares with its home-screen and lock-screen widgets. The app writes a JSON snapshot into an App Group container; the widget extension reads it and renders a precomputed timeline. Widget buttons run App Intents in the app process.
- Model:
Shared/Sources/MinutaShared/Models/WidgetData.swift - Period summary:
Shared/Sources/MinutaShared/Models/WidgetPeriod.swift - Timeline builder:
Shared/Sources/MinutaShared/Utilities/WidgetTimelineBuilder.swift - Writer:
Minuta/Sources/Services/WidgetDataService.swift - Intents:
Minuta/Sources/WidgetIntents.swift(compiled into app and extension),Minuta/Sources/Services/WidgetIntentService.swift(app) - Recent tags:
Minuta/Sources/Services/RecentTagsStore.swift - Widget extension:
Minuta/Widgets/MinutaWidgets.swift(bundle, home and lock screen widgets),Minuta/Widgets/MinutaSummaryWidget.swift(time by tag)
Build status
The widget extension builds and embeds on iOS only (platformFilter: iOS on the target dependency in Minuta/project.yml); Mac Catalyst builds ship without it. The App Group group.tools.minuta.apps is registered under the TestFlight publishing team, not the personal free team the project is signed with, so the committed entitlements files stay empty and the group entitlement is applied only to TestFlight builds through Minuta/TestFlight/*.entitlements (see Publishing to TestFlight). In personal-team builds containerURL(forSecurityApplicationGroupIdentifier:) returns nil and widgets show their placeholder, so the flow is verified by unit tests locally and on a device from TestFlight.
The model
WidgetData is a Codable, Sendable, Equatable snapshot, a denormalized widget-friendly view rather than the live records:
public struct WidgetData: Codable, Sendable, Equatable {
public var runningTimers: [WidgetTimer]
public var todayRecordsCount: Int
public var todayCompletedDuration: TimeInterval // completed records only
public var recentTags: [WidgetTag] // up to 4, most-recently-used
public var dailyTotals: [WidgetDayTotal] // completed time by start day and tag
public var summaryTags: [WidgetTag] // tags named by dailyTotals and running timers
public var lastUpdated: Date
} WidgetTimer and WidgetTag are trimmed projections of TimeRecord and Tag carrying only what the widget renders (id, start time, tag name/color, comment). Everything time-dependent takes a date instead of reading the clock, so precomputed timeline entries render correctly:
WidgetTimer.duration(at:): elapsed time as of a given date (never negative).WidgetData.todayTotal(at:):todayCompletedDurationplus the part of each running timer that falls on that date’s day, so the total grows while a timer runs.WidgetData.rolledOver(): the same timers and tags with today’s counters zeroed, used for the midnight entry.
Decoding accepts the legacy todayTotalDuration key written by app versions up to 1.0.3, a missing lastUpdated, and snapshots without dailyTotals, summaryTags and WidgetTimer.tagId (app versions up to 1.0.6); encoding writes only the current keys.
Shared constants live in WidgetDataConstants: filename = "widget-data.json", appGroupIdentifier = "group.tools.minuta.apps", maxRecentTags = 4, the three widget kinds homeWidgetKind / lockScreenWidgetKind / summaryWidgetKind, and staleSnapshotAge (24 h).
Period summary
The Time by Tag widget shows hours per tag for a period chosen in its settings. The extension cannot read storage, so the snapshot carries the raw material and the widget does the sum:
dailyTotals: oneWidgetDayTotal(start of day, tag id or nil, seconds) per day and tag, built byWidgetData.dailyTotals(from:calendar:)from completed records. A record counts whole on the day it started, as in the app’s History and the CSV export. The days start atWidgetPeriod.earliestStart(at:), the earlier of the current week’s and the current month’s start, and the order is fixed (day, then tag id) so an unchanged state encodes to the same bytes.WidgetPeriod(today,thisWeek,thisMonth) gives the interval around a date; the week starts on the calendar’s first weekday.WidgetData.summary(for:at:calendar:)sums the days inside the period and adds the elapsed time of each running timer that started in it (to the timer’stagId). The result,WidgetPeriodSummary, has the total and one row per tag, longest first and by name on a tie; untagged time is the row “Untagged”.rows(limit:)folds what does not fit into a last row “Other”.
Daily totals instead of three ready-made sums let the widget roll over on its own: every timeline entry sums the period for its own date, so the day, the week and the month change at midnight even when the app has not run. rolledOver() keeps the totals for the same reason.
App Group sharing
The main app and the widget extension cannot read each other’s sandboxed containers directly. They share through an App Group container resolved by identifier:
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: WidgetDataConstants.appGroupIdentifier)?
.appendingPathComponent(WidgetDataConstants.filename) This is independent of the user-chosen Automerge storage folder: widgets never read the .automerge files, they only read the snapshot the app publishes. The recent-tag list (RecentTagsStore) lives in the App Group UserDefaults under recentTagIds so a background intent run and the app agree on it.
The app builds both through WidgetDataService.forApp(testLaunch:) and RecentTagsStore.forApp(testLaunch:) (AppState and WidgetIntentService.live()). A test launch (--test-storage=, StorageLocationManager.isTestLaunch) publishes no snapshot and keeps its recent tags in a suite of its own (MinutaTests.recentTags), so tests never change what the real widgets show. Mac Catalyst publishes nothing: it has no widgets, though a Mac App Store build carries the App Group.
Writing the snapshot
WidgetDataService is an actor with an injectable file URL and reload hook (tests use a temp file and a counter). AppState.syncWidgetData() calls updateWidgetData(runningTimers:todayRecords:periodRecords:tags:recentTagIds:) after every mutation that the widget renders: loadData (which also covers scene activation, external-change reloads and storage reinitialisation), start/stop/stop-all, record delete/undo/update, tag create/delete/archive/unarchive/rename. periodRecords are the records between WidgetPeriod.earliestStart and latestEnd (WidgetDataService.periodRecords(using:), one ranged read); without a snapshot to write (canPublish false: Mac Catalyst, personal-team builds, test launches) AppState skips that read. The service builds the snapshot and then:
- decodes the file already on disk and compares it with the new snapshot (
lastUpdatedexcluded); an identical snapshot is neither rewritten nor reloaded, which keeps back-to-back publishes (an intent run followed by AppState’s reload) at one WidgetKit reload; - otherwise writes atomically and calls
WidgetCenter.shared.reloadTimelines(ofKind:)for the three Minuta kinds only.
WidgetKit budgets reloads at roughly 40-70 per widget per day; reloads caused by an app intent or while the app is in the foreground do not count.
Reading in the widget
WidgetDataReader (in MinutaWidgets.swift) decodes the file, returning an empty WidgetData() if the container or file is missing. MinutaTimelineProvider.getTimeline asks WidgetTimelineBuilder.slots(for:now:) for the entries and always uses policy: .atEnd:
- A timer is running: an entry for now plus one per minute for the next 60 minutes, aligned to minute boundaries. Elapsed time is shown without seconds as
H:MM(DurationFormatter.hoursMinutesFormat, e.g.0:05,1:05), so one entry per minute is exact. Entries are free; the reload at the end costs one budget unit per hour. - No timer: an entry for now and one at the next midnight (rolled over), nothing else changes until the app publishes a new snapshot.
- If midnight falls inside the window, the entries from midnight on use
rolledOver()data.
Text(_, style: .timer) and .relative were not an option: both always show seconds.
Views render through MinutaEntry.elapsed(_:) and MinutaEntry.todayTotal (the entry’s own date), never Date(). When the snapshot is older than staleSnapshotAge the small and medium widgets replace the “Today” caption with “Open Minuta to refresh”.
Widget families
The bundle ships three widgets:
MinutaHomeWidget(home screen):systemSmall,systemMedium,systemLarge: running timer with stop button, today summary, and quick-start tag buttons.MinutaLockScreenWidget(lock screen, iOS):accessoryCircular,accessoryRectangular,accessoryInline. While timers run: the circular gauge shows the first timer’sH:MMand+Nfor the others, the rectangular one lists up to three timers (tag,H:MM), or two and a “+N more” line, and the inline one shows the first timer and+N. With none running, today’s total.MinutaSummaryWidget(“Time by Tag”, home screen):systemMedium(up to 4 rows) andsystemLarge(up to 10 rows). A header with the period and its total, then one row per tag: colored dot, name, time in the compact format (2h 15m). An empty period shows “No records”; a stale snapshot replaces the period title with “Open Minuta to refresh”. It is anAppIntentConfiguration: the period (Today, This Week, This Month; This Week by default) is theperiodparameter ofSummaryWidgetConfigurationIntent, picked through Edit Widget. That intent lives in the extension only, since it configures and performs nothing. The timeline is the one the other widgets use (WidgetTimelineBuilder); the widget gallery shows sample numbers (context.isPreview).
Live Activity
While at least one timer runs, the running timers are also a Live Activity (iOS, lock screen and Dynamic Island), which goes away after the last stop. Unlike the widgets it needs no App Group: the app starts and updates it itself, with no snapshot file.
- Content:
LiveActivityContent(MinutaShared): the firstLiveActivityContent.maxTimers(8) running timers asWidgetTimers (WidgetTimer.make(runningTimers:tags:), the builder the snapshot uses too), oldest first, andrunningCount. Eight keeps the content under ActivityKit’s 4 KB limit with long names and comments (tested).TimersActivityAttributes(Sources/TimersActivityAttributes.swift, compiled into the app and the extension, like the intents) wraps it as itsContentState. - When:
LiveActivityPlan.decide(current:timers:)(MinutaShared) gives start, update (only when the content changed), end or nothing.LiveActivityService(Sources/Services/LiveActivityService.swift) carries it out through ActivityKit, reading the activity fromActivity.activitieseach time so one started before a relaunch, or by a background intent launch, is the one updated and ended; extra activities are ended. It runs fromAppState.syncWidgetData()(before the App Group check) and fromWidgetIntentService.publish(), so a stop from a widget, Shortcuts or the activity’s own button ends it with noAppState. A stop on another device or by the CLI shows when the app next loads data. Nothing on Mac Catalyst, for a test launch, or with Live Activities turned off for Minuta in Settings.NSSupportsLiveActivitiesis set inSources/Info.plist. - Look (
Widgets/TimersLiveActivity.swift): lock screen, up to three rows (tag color dot, tag name or “Timer”, comment, time ticking with seconds viaText(timerInterval:), a Stop button), then “+N more” and Stop All. Dynamic Island: compact is a stopwatch in the first tag’s color and its time, expanded the same rows. - Stop buttons:
StopTimerFromWidgetIntentandStopAllTimersFromWidgetIntentalso conform toLiveActivityIntent(both targets, not on Catalyst), which runsperform()in the app process, from the lock screen too. - iOS ends an activity after 8 hours; a timer running longer loses it until the next sync.
Checked in the iPhone 17 simulator (iOS 26): the lock screen activity with four timers (three rows, “+1 more”, Stop All), the compact and expanded Dynamic Island, and the expanded Stop button stopping the timer in storage and ending the activity.
Interactive intents
Widget buttons use StartTimerFromWidgetIntent (optional tag ID), StopTimerFromWidgetIntent (timer ID) and StopAllTimersFromWidgetIntent, all isDiscoverable = false so Shortcuts keeps showing only StartTimerIntent / StopTimerIntent.
Only the app can resolve the security-scoped bookmark of the user-picked storage folder, so perform() has to run in the app process. WidgetKit’s rules: by default an intent runs in the widget extension; an intent type that also exists in the app runs in the app only while the app is already running, and the app is not launched for it. An intent conforming to ForegroundContinuableIntent (or LiveActivityIntent, AudioPlaybackIntent) is performed in the app process, which the system launches in the background if needed. Hence:
Sources/WidgetIntents.swiftis compiled into both targets (Apple’s documented setup for interactive widgets; the types arepublicbecause Release-mode resolution fails for internal duplicates).perform()callsWidgetIntentHost.shared, a hook the app registers at launch inAppContainer.init(WidgetIntentService.Host) and the extension never registers, so the extension copy is a no-op. A runtime hook rather than a build flag: the TestFlight xcconfigs overrideSWIFT_ACTIVE_COMPILATION_CONDITIONSglobally, which would compile the app branch into the extension.- The app-side copies conform to
ForegroundContinuableIntentin an@available(iOSApplicationExtension, unavailable)extension. The iOS 26 SDK deprecates it in favour ofsupportedModes = [.background], which needs iOS 26 at runtime; the deployment target is iOS 18, so the conformance stays (marked@available(*, deprecated)to silence the warning). WidgetIntentServiceworks straight from storage (StorageLocationManager.shared.storageURL,HybridStorageService,TimeTrackingService, the write lock): start (unknown tag ID starts untagged; while the tags file is unreadable the ID cannot be checked and is used as passed), stop by ID (stale ID is a no-op), stop all. The Shortcuts actionsStartTimerIntent/StopTimerIntent(Sources/AppIntents.swift) go through it too:start(tagName:comment:)matches the name case-insensitively and creates an unknown tag (as the app and the CLI do), an empty name starts untagged. A stop is bracketed by.minutaTimersStoppingand.minutaTimersStopped(ids inuserInfo["ids"], posted on the main actor): a liveAppStatetakes the timers out ofrunningTimersat once and treats them as stopped until a load that started after the stop has read them, so an open editor’s auto-save cannot reopen them. After each operation it rebuilds the snapshot throughWidgetDataService(so the widget updates even when noAppStateexists) and posts.minutaDataChanged, which makes a liveAppStatereload; the service’s unchanged-snapshot check keeps that at a single reload.- Neither a shared framework (
AppIntentsPackage) nor theMinutaSharedSwiftPM package is used for the intents: a framework would not change the process rule, and App Intents metadata extraction from SwiftPM targets is unreliable.
Earlier versions wrote a pending-timer.json request that the app consumed on its next activation, so a tap did nothing until the app was opened; that path is gone.
Tests
Shared/Tests/MinutaSharedTests/Models/WidgetPeriodTests.swift: period intervals (week start by calendar), daily totals, the summary (order, Untagged, Other, running timers, rollover on an old snapshot).Shared/Tests/MinutaSharedTests/Utilities/WidgetTimelineBuilderTests.swift,Models/WidgetDataTests.swift,Utilities/DurationFormatterTests.swift(swift test): timeline shape, midnight rollover,duration(at:)/todayTotal(at:), legacy JSON decoding,H:MMformatting.Minuta/Tests/WidgetIntentServiceTests.swift(MinutaTests, simulator): start/stop/stop-all against a temp storage folder, the Shortcuts start by tag name (unknown name creates the tag, case-insensitive match, empty name), recent tags, the daily totals and tags published after a start and a stop, the write/reload dedup, and that a test launch publishes nothing and leaves the App Group recent tags alone.Minuta/Tests/AppStateEditorSaveTests.swift: an auto-save after a stop by a Shortcut or a widget button writes nothing.Shared/Tests/MinutaSharedTests/Models/LiveActivityPlanTests.swift: start, update only on a change, end, the 8-timer cap with the full count, the 4 KB limit,WidgetTimer.make(order, tags, duplicate tag ids).Minuta/Tests/LiveActivityServiceTests.swift:LiveActivityServiceagainst a fake ActivityKit controller, from the app and from a widget intent start and stop.
The widget’s look and the publish path were checked once in the simulator with a build that carries the TestFlight entitlements (CODE_SIGN_ENTITLEMENTS=$PWD/TestFlight/Minuta.entitlements, which gives the simulator the App Group container): the widget gallery renders both sizes, and the app wrote the expected totals. Placing a widget on the simulator’s home screen crashes its SpringBoard (iOS 26 simulator, any widget), so the configured widget is checked on a device from TestFlight.
Related
- TimeRecord: the source records that
WidgetTimerprojects from. - iOS UI: the App Intents table.